TensorFlow-和-Keras-深度学习第三版-全-

TensorFlow 和 Keras 深度学*第三版(全)

原文:annas-archive.org/md5/60d4088bf4379874079ff1222f4a0d5b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

深度学*与 TensorFlow 和 Keras,第 3 版 是一本简明而全面的现代神经网络、人工智能和深度学*技术的介绍,专为软件工程师和数据科学家设计。本书是之前由同一作者编写的 深度学*与 Keras [1] 和 TensorFlow 1.x 深度学*实用手册 [2] 的自然延续。

本书提供了过去六年间学*技术演变的非常详细的全景图。书中展示了使用 TensorFlow 2.x 编写的几十个深度神经网络,这是一种基于 Keras 风格 API 的模块化网络库 [1]。

人工智能AI)为本书讨论的所有内容奠定了基础。机器学*ML)是人工智能的一个分支,而深度学*DL)又是机器学*的一个子集。本节将简要讨论这三个概念,您将在本书的其余部分中经常遇到它们。

人工智能指的是机器模仿通常由人类展示的智能行为的任何活动。更正式地说,它是一个研究领域,旨在让机器复制认知能力,如学*行为、与环境的主动互动、推理和推断、计算机视觉、语音识别、问题解决、知识表示和感知。人工智能建立在计算机科学、数学、统计学,以及心理学和其他研究人类行为的科学的基础上。构建人工智能有多种策略。在 20 世纪 70 年代和 80 年代,“专家”系统变得非常流行。这些系统的目标是通过使用大量手动定义的“如果-那么”规则来表示知识,进而解决复杂问题。这种方法在特定领域的小问题中有效,但无法扩展到更大规模的问题和多个领域。后来,人工智能越来越多地转向基于统计方法的机器学*方法。

机器学*(ML)是人工智能的一个子学科,专注于教会计算机如何学*,而无需为特定任务编写程序。机器学*背后的核心理念是,可以创建从数据中学*并进行预测的算法。机器学*有三种不同的广泛类别:

  • 监督学*,其中机器会接收输入数据和期望的输出,目标是从这些训练样本中学*,以便对机器从未见过的数据做出有意义的预测。

  • 无监督学*,其中机器只接收输入数据,机器随后必须自己找到某些有意义的结构,没有外部监督或输入。

  • 强化学*,在这种学*方式中,机器作为一个代理与环境进行交互。机器会根据预期的行为给予“奖励”,而不期望的行为则会受到“惩罚”。机器通过学*如何调整自己的行为来最大化奖励。

深度学*在 2012 年席卷全球。那一年,ImageNet 2012 挑战赛启动,目标是通过一个大型人工标注数据集的子集来预测照片的内容。一种名为 AlexNet 的深度学*模型在此挑战中达到了 15.3%的 Top-5 错误率,相比之前的最新技术成果取得了显著提升。据《经济学人》报道,突然间,人们开始关注这个领域,不仅仅是人工智能社区,整个技术行业都开始关注它

这仅仅是个开始。今天,深度学*技术已经成功应用于众多不同的领域,包括但不限于医疗、环境、绿色能源、计算机视觉、文本分析、多媒体、金融、零售、游戏、模拟、工业、机器人和自动驾驶汽车。在这些领域中,深度学*技术能够解决的问题,准确度远超过以往的方法。

回顾过去的八年,深度学*(DL)对科学和工业的贡献令人着迷且振奋人心。没有理由相信接下来的八年里,深度学*的贡献会减少;事实上,随着深度学*领域的不断进步,我们预期会看到深度学*带来更多令人兴奋和迷人的贡献。

本书将带你进入深度学*的魔力世界。我们将从简单的模型开始,逐步介绍越来越复杂的模型。整个过程将始终以实践为主,配以适量的代码供你操作。

本书适合人群

如果你是一个有机器学*经验的数据科学家,或是一个接触过神经网络的人工智能程序员,那么你会发现本书是一个很好的深度学*入门书籍。如果你是一个对深度学*浪潮日益感兴趣的软件工程师,本书将为你提供一个扎实的基础,帮助你拓宽相关知识。阅读本书需要具备一定的 Python 基础。

本书内容涵盖

第一章使用 TensorFlow 的神经网络基础,我们将在这里学* TensorFlow 的基础知识,这是 Google 为机器学*和深度学*开发的开源库。此外,我们还将介绍神经网络和深度学*的基础知识,这两者是*几年在机器学*领域取得巨大进展的方向。本章的目标是提供进行基础但完全实践的深度学*所需的所有工具。

第二章回归与分类,集中讲解机器学*技术中的基本任务:回归和分类。我们将学*如何使用 TensorFlow 构建简单的单变量、多变量回归模型。我们还将使用逻辑回归解决多类别分类问题。

第三章卷积神经网络,讲解了如何使用深度学*卷积神经网络(ConvNets)高精度地识别 MNIST 手写字符。我们使用 CIFAR 10 数据集构建一个包含 10 个类别的深度学*分类器,并使用 ImageNet 数据集构建一个包含 1,000 个类别的高精度分类器。此外,我们还研究了如何使用大型深度学*网络,如 VGG16,以及非常深的网络,如 InceptionV3。最后,我们将讨论迁移学*。

第四章词向量,描述了分布式表示和词向量的起源与理论,并描绘了从静态的基于词的词向量到更动态且富有表现力的基于句子和段落的词向量的进展。我们还探讨了如何将词向量的理念扩展到包括非词序列,例如图中的节点或 Web 应用中的用户会话。本章还包含了多种类型的词向量使用示例。

第五章递归神经网络,描述了一个重要的神经网络架构子类,专门用于处理序列数据,如自然语言或时间序列。我们将介绍该领域的重要架构,例如LSTM长短期记忆网络)和GRU门控递归单元),并展示如何将它们扩展以处理双向状态和跨批次的状态。我们还提供了使用不同拓扑结构的 RNN 的示例,用于特定任务,如文本生成、情感分析和词性标注。我们还描述了流行的 seq2seq 架构,该架构使用一对 RNN 在编码器-解码器管道中解决多种 NLP 任务。

第六章变压器,讲解了变压器,这是一种深度学*架构,彻底改变了传统的自然语言处理领域。我们首先回顾了该架构背后的关键直觉和各种变压器类别,并深入分析最流行的模型。接着,我们重点讨论了基于原始架构和流行库(如 Hugging Face 和 TensorFlow Hub)的实现。之后,我们简要讨论了评估、优化以及使用变压器时常见的最佳实践。最后一部分专门讨论了如何将变压器应用于计算机视觉任务,这是一个与 NLP 完全不同的领域,这需要仔细定义注意力机制。最终,注意力就是你所需要的!而在注意力的核心,除了向量之间的余弦相似度,别无他物。

第七章无监督学*,深入探讨无监督学*模型。将涵盖聚类和降维所需的技术,如 PCA、k-means 和自组织映射。还将详细讲解玻尔兹曼机及其在 TensorFlow 中的实现。所涵盖的概念将进一步扩展,构建限制玻尔兹曼机RBMs)。

第八章自编码器,描述了自编码器,这是一类神经网络,试图将输入重建为目标。将介绍不同种类的自编码器,如稀疏自编码器、卷积自编码器和去噪自编码器。该章将训练一个去噪自编码器,从输入图像中去除噪声。将演示如何使用自编码器创建 MNIST 数字。还将介绍构建 LSTM 自编码器以生成句子向量的步骤。最后,我们将学*如何构建变分自编码器来生成图像。

第九章生成模型,聚焦于生成对抗网络GANs)。我们从第一个提出的 GAN 模型开始,利用它生成 MNIST 字符。该章展示了如何使用深度卷积 GAN 创建名人图像。还讨论了各种 GAN 架构,如 SRGAN、InfoGAN 和 CycleGAN。该章介绍了各种有趣的 GAN 应用。最后,章节以 TensorFlow 实现的 CycleGAN 来转换冬夏图像作结。

第十章自监督学*,概述了在计算机视觉、音频和自然语言处理领域中用于自监督学*的各种策略。涵盖了通过自回归生成、掩码生成、关系预测等策略进行自预测,及其混合方法。还将介绍对比学*,这是一种流行的自监督学*技术,并将其应用于各种预设任务中的不同领域。

第十一章强化学*,聚焦于强化学*,涵盖 Q 学*算法和贝尔曼方程。该章讨论了折扣奖励、探索与开发、以及折扣因子。它解释了基于策略和基于模型的强化学*。我们将构建深度 Q 学*网络DQN)来玩 Atari 游戏。最后,我们将学*如何使用策略梯度算法训练智能体。

第十二章概率 TensorFlow,介绍了 TensorFlow Probability,这是一个建立在 TensorFlow 之上的库,用于执行概率推理和统计分析。该章演示了如何使用 TensorFlow Probability 生成合成数据。我们将构建贝叶斯网络并进行推理。该章还介绍了不确定性、偶然性和认知不确定性的概念,并讲解如何计算训练模型的不确定性。

第十三章AutoML 简介,介绍了 AutoML,其目标是让那些不熟悉机器学*技术的领域专家也能轻松使用 ML 技术。我们将在简要讨论基础知识后,进行一个使用 Google Cloud Platform 的实操练*,并做大量的动手操作。该章节涵盖了自动数据准备、自动特征工程和自动模型生成。然后,我们将介绍 AutoKeras 和 Google Cloud AutoML,它提供了多种针对表格、视觉、文本、翻译和视频处理的解决方案。

第十四章深度学*背后的数学,讲解了深度学*背后的数学原理。这个话题相当高级,并不一定是从业者所必需的。然而,作为了解我们在操作神经网络时“引擎盖下”发生了什么的推荐阅读,它非常重要。我们从历史背景介绍开始,然后复*高中时学过的导数和梯度的概念,并介绍梯度下降法和反向传播算法,这两种通常用于优化深度学*网络的算法。

第十五章张量处理单元,讨论了 TPU。TPU 是 Google 开发的专用 ASIC 芯片,用于以超快的速度执行神经网络的数学运算。其计算核心是一个流水线乘法器,可以并行计算多个点积(行 * 列),从而加速基本深度学*操作的计算。可以把 TPU 看作是专门用于深度学*的协处理器,专注于矩阵或张量运算。我们将回顾迄今为止的四代 TPU,以及针对物联网的 Edge TPU。

第十六章其他有用的深度学*库,介绍了其他深度学*框架。我们将探索 Hugging Face、OpenAI 的 GPT-3 和 DALL-E 2。该章节还介绍了另一种非常流行的深度学*框架——PyTorch。我们还会讨论 H2O.ai 及其 AutoML 模块。章节最后简要讨论了 ONNX 深度学*模型的开源格式。

第十七章图神经网络,介绍了图和图机器学*,特别强调了图神经网络和流行的深度图库DGL)。我们描述了在图神经网络中常用的各种图层的理论(以及 DGL 中可用的图层),并提供了用于节点分类、链接预测和图分类的 GNN 示例。我们还展示了如何使用自己的图数据集并定制图层来创建新的 GNN 架构。接着,我们将讨论图机器学*领域的一些前沿进展,例如异构图和时序图。

第十八章机器学*最佳实践,关注在训练和生产过程中获取最佳模型的策略和实践。该章节从两个不同的角度讨论最佳实践:数据最佳实践和模型最佳实践。

第十九章TensorFlow 2 生态系统,介绍了 TensorFlow 生态系统的不同组件。我们介绍了 TensorFlow Hub,这是一个用于预训练深度学*模型的仓库。章节还讨论了 TensorFlow Datasets —— 一个现成数据集的集合。我们还将讨论 TensorFlow Lite 和 TensorFlow JS —— 针对移动和嵌入式系统以及 Web 的框架。最后,本章介绍了联邦学*,一种去中心化的机器学*框架。

第二十章高级卷积神经网络,展示了卷积神经网络CNNs)的更多高级应用。我们将探索如何在计算机视觉、视频、文本文档、音频和音乐等领域应用 CNN。最后,我们将总结卷积操作的部分内容。

下载示例代码文件

本书的代码包托管在 GitHub 上,地址为packt.link/dltf。我们还有来自丰富书籍和视频目录的其他代码包,您可以在github.com/PacktPublishing/找到。快来看看吧!

下载彩色图像

我们还提供了一个 PDF 文件,包含本书中使用的截图/图表的彩色图像。您可以在此下载:static.packt-cdn.com/downloads/9781803232911_ColorImages.pdf

使用的约定

本书中使用了多种文本约定。

CodeInText:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“每个神经元都可以通过'kernel_initializer'参数初始化特定的权重。”

一块代码设置如下:

# Build the model.
model = tf.keras.models.Sequential()
model.add(keras.layers.Dense(NB_CLASSES,
            input_shape=(RESHAPED,),
            name='dense_layer', 
            activation='softmax')) 

当我们希望引起您对代码块中特定部分的注意时,相关的行或项将被突出显示:

# Build the model.
model = tf.keras.models.Sequential()
model.add(keras.layers.Dense(NB_CLASSES,
            input_shape=(RESHAPED,),
            **name=****'****dense_layer'****,** 
            **activation=****'softmax'****))** 

任何命令行输入或输出如下所示:

pip install gym 

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:“一个深度卷积神经网络DCNN)由多个神经网络层组成。”

警告或重要提示如下所示。

提示和技巧如下所示。

联系我们

我们始终欢迎读者的反馈。

一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中注明书名。如果您对本书的任何内容有疑问,请通过questions@packtpub.com与我们联系。

勘误表:尽管我们已尽最大努力确保内容的准确性,但错误仍然会发生。如果您在本书中发现了错误,我们将非常感激您向我们报告。请访问www.packtpub.com/submit-errata,点击提交勘误,并填写表单。

盗版:如果你在互联网上发现我们作品的任何非法复制品,请提供相关地址或网站名称。请通过 copyright@packtpub.com 与我们联系,并附上材料的链接。

如果你有兴趣成为作者:如果你在某个领域有专长,并且有兴趣写书或为书籍贡献内容,请访问 authors.packtpub.com

参考文献

  1. 《用 Keras 深度学*:用 Python 的强大功能实现深度学*模型和神经网络》,平装本 – 2017 年 4 月 26 日,安东尼奥·古利,苏吉特·帕尔

  2. 《TensorFlow 1.x 深度学*宝典》通过 Python 解决人工智能驱动问题的 90 多个独特实例,安东尼奥·古利,阿米塔·卡普尔

分享你的想法

阅读完 《用 TensorFlow 和 Keras 深度学*,第三版》 后,我们很想听听你的想法!请 点击这里直接访问亚马逊评论页面 并分享你的反馈。

你的评论对我们和技术社区都很重要,它将帮助我们确保提供优质的内容。

第一章:使用 TF 构建神经网络基础

在本章中,我们将学* TensorFlow 的基础知识,这是 Google 开发的一个开源库,用于机器学*和深度学*。此外,我们还将介绍神经网络和深度学*的基础知识,这两个领域在过去几年中经历了令人惊讶的爆发性增长。本章的目的是提供进行基本但完全动手操作的深度学*所需的所有工具。

我们将学*:

  • TensorFlow 和 Keras 是什么

  • 神经网络简介

  • 感知机和多层感知机是什么

  • 一个实际的例子:识别手写数字

本章的所有代码文件可以在 packt.link/dltfchp1 找到。

让我们开始吧!

什么是 TensorFlow(TF)?

TensorFlow 是 Google Brain 团队开发的一个强大的开源软件库,用于深度神经网络,这是本书所涉及的主题。它于 2015 年 11 月首次以 Apache 2.0 许可证发布,并迅速发展;截至 2022 年 5 月,它的 GitHub 仓库(github.com/tensorflow/tensorflow)已有超过 129,000 次提交,约有 3,100 名贡献者。仅此可以作为衡量 TensorFlow 受欢迎程度的标准。

让我们首先了解 TensorFlow 究竟是什么,以及它为何在深度神经网络研究人员和工程师中如此受欢迎。Google 称其为“机器智能的开源软件库”,但由于还有很多其他的深度学*库,如 PyTorch(pytorch.org/)、Caffe(caffe.berkeleyvision.org/)和 MXNet(mxnet.apache.org/),那么是什么让 TensorFlow 与众不同呢?像 TensorFlow 这样的深度学*库大多数都有自动微分(一个用于优化的有用数学工具),许多都是开源平台。它们大多数支持 CPU/GPU 选项,拥有预训练模型,并支持常用的神经网络架构,如递归神经网络、卷积神经网络和深度信念网络。那么,TensorFlow 还有什么特别之处呢?让我列举一下它的主要特点:

  • 它适用于所有流行的编程语言,如 Python、C++、Java、R 和 Go。TensorFlow 提供了稳定的 Python 和 C++ API,以及一个对其他语言不保证向后兼容的 API。

  • Keras – 一个高级神经网络 API,已经与 TensorFlow 集成(在 2.0 版本中,Keras 成为与 TensorFlow 交互的标准 API)。该 API 指定了软件组件应如何交互。

  • TensorFlow 允许在生产环境中部署模型,并提供易用性。

  • 最重要的是,TensorFlow 拥有非常好的社区支持。

GitHub 上的星标数(见 图 1.1)是衡量所有开源项目受欢迎程度的指标。截止到 2022 年 5 月,TensorFlow、Keras 和 PyTorch 的星标数分别为 165K、55K 和 56K,使得 TensorFlow 成为最受欢迎的机器学*框架:

图 1.1:GitHub 上各个深度学*项目的星标数

什么是 Keras?

Keras 是一个优美的 API,用于组合构建模块以创建和训练深度学*模型。Keras 可以与多个深度学*引擎集成,包括 Google TensorFlow、Microsoft CNTK、Amazon MXNet 和 Theano。从 TensorFlow 2.0 开始,由 François Chollet 开发的 Keras 被作为标准的高级 API 采纳,极大简化了编码过程,使编程变得更加直观。

神经网络简介

人工神经网络(简称“神经网络”或 ANN)代表了一类机器学*模型,灵感来自于对哺乳动物中枢神经系统的研究。每个神经网络由几个互相连接的“神经元”组成,这些神经元按“层”组织。一个层中的神经元将信息传递给下一个层中的神经元(在术语中称为“激活”),这就是网络进行计算的方式。最初的研究始于 20 世纪 50 年代初,当时引入了“感知机”[1],这是一种用于简单操作的两层网络,随后在 60 年代末期引入了“反向传播”算法,用于高效的多层网络训练(参考[2]和[3])。一些研究认为,这些技术的起源可能比通常所说的还要更早[4]。

神经网络曾是 1980 年代前的学术研究重点,之后其他更简单的方法变得更为相关。然而,自 2000 年代中期以来,随着三个因素的推动,神经网络再次引起了人们的广泛关注:G. Hinton 提出的突破性快速学*算法[3],[5],[6];2011 年左右引入的用于大规模数值计算的 GPU;以及可用于训练的大量数据集。

这些改进为现代“深度学*”铺平了道路,深度学*是一类神经网络,具有大量的神经元层,能够基于渐进的抽象层次学*复杂的模型。几年前,人们开始称其为“深度”学*,当时它利用了 3 到 5 层的神经网络。而现在,超过 200 层的网络已经很常见!

这种通过渐进抽象进行学*的方式类似于人类大脑中数百万年来进化的视觉模型。事实上,人类的视觉系统被组织成不同的层次。首先,我们的眼睛与大脑中的一个区域——视觉皮层(V1)相连接,这个区域位于大脑后部的下方。这个区域在许多哺乳动物中都很常见,负责区分基本的视觉属性,如视觉方向的微小变化、空间频率和颜色。

据估计,V1 由大约 1.4 亿个神经元组成,神经元之间有数十亿个连接。V1 然后与其他区域相连,V2、V3、V4、V5 和 V6 逐步进行更复杂的图像处理和更高级概念的识别,如形状、面孔、动物等。估计人类皮层约有 160 亿个神经元,人类皮层的约 10-25% 用于视觉处理 [7]。深度学*从人类视觉系统的这种分层组织中汲取了一些灵感:早期的人工神经元层学*图像的基本特性,而较深的层学*更复杂的概念。

本书通过提供在 TensorFlow 中工作的神经网络,涵盖了神经网络的几个主要方面。那么,让我们开始吧!

感知机

“感知机”是一个简单的算法,给定一个包含 m 个值(x[1], x[2], ..., 和 x[m])的输入向量 x,通常称为输入特征或简而言之特征,它输出 1(“是”)或 0(“否”)。从数学上讲,我们定义一个函数:

其中 w 是权重向量, 是点积 b 是偏置。如果你记得初等几何,wx + b 定义了一个边界超平面,该超平面根据分配给 wb 的值而改变位置。请注意,超平面是一个子空间,其维度比它所在的环境空间少一维。请参见(图 1.2)以获得示例:

图 1.2:超平面的示例

换句话说,这是一个非常简单但有效的算法!例如,给定三个输入特征,即颜色中的红色、绿色和蓝色的量,感知机可以尝试判断该颜色是否是“白色”。

请注意,感知机无法表达“可能”的答案。如果我们了解如何定义 wb,它可以回答“是”(1)或“否”(0)。这就是接下来将讨论的“训练”过程。

我们的第一个 TensorFlow 代码示例

tf.keras 中有三种创建模型的方式:顺序 API、函数式 API 和模型子类化。在本章中,我们将使用最简单的一个,Sequential(),而另外两种将在 第二章回归与分类 中讨论。Sequential() 模型是一个神经网络层的线性管道(堆栈)。该代码片段定义了一个单层模型,其中包含 10 个人工神经元,期望 784 个输入变量(也称为特征)。请注意,该网络是“密集”的,这意味着层中的每个神经元都与前一层中的所有神经元相连,并且与下一层中的所有神经元相连:

import tensorflow as tf
from tensorflow import keras
NB_CLASSES = 10
RESHAPED = 784
model = tf.keras.models.Sequential()
model.add(keras.layers.Dense(NB_CLASSES,
            input_shape=(RESHAPED,), kernel_initializer='zeros',
            name='dense_layer', activation='softmax')) 

每个神经元可以通过 'kernel_initializer' 参数使用特定的权重进行初始化。这里有一些选择,最常见的几种如下:

  • random_uniform:权重被初始化为在范围(-0.05,0.05)内均匀随机的小值。

  • random_normal:权重根据高斯分布初始化,均值为零,标准差较小,为 0.05。对于不熟悉高斯分布的朋友,可以将其想象成一个对称的“钟形曲线”。

  • zero:所有权重初始化为零。

完整的列表可以在线查看(www.tensorflow.org/api_docs/python/tf/keras/initializers)。

多层感知机:我们的第一个网络示例

在本章中,我们展示了一个具有多个密集层的网络的第一个示例。历史上,“感知机”是指具有单一线性层的模型,因此,如果它具有多个层,我们称之为多层感知机MLP)。请注意,输入层和输出层可以从外部看到,而其他所有位于中间的层都是隐藏的——因此有了隐藏层这个名称。在这个背景下,单一层仅仅是一个线性函数,因此,MLP 是通过将多个单一层按顺序堆叠而成的:

Diagram  Description automatically generated

图 1.3:多层感知机示例

图 1.3中,第一隐藏层中的每个节点接收一个输入,并根据其相关的线性函数的值“激活” (0,1)。然后,第一隐藏层的输出被传递到第二层,应用另一个线性函数,结果再传递到最终的输出层,该输出层由一个神经元组成。有趣的是,这种层次结构在某种程度上类似于人类视觉系统的组织,正如我们之前讨论的。

感知机训练中的问题及解决方案

让我们考虑一个单一的神经元;wb的最佳选择是什么?理想情况下,我们希望提供一组训练示例,并让计算机调整权重和偏置,使得输出中的误差最小化。

为了让这个问题更加具体化,假设我们有一组猫的图像和另一组不包含猫的图像。假设每个神经元接收来自图像中单个像素的输入。当计算机处理这些图像时,我们希望我们的神经元调整它的权重和偏置,使得错误识别的图像越来越少。

这种方法看起来很直观,但它要求权重(或偏置)的微小变化仅导致输出的微小变化。想一想:如果输出跳跃很大,我们就无法逐步学*。毕竟,孩子们是一点一点地学*的。不幸的是,感知机并没有表现出这种“逐步”行为。感知机要么是 0,要么是 1,这是一个大跳跃,无法帮助学*(参见图 1.4):

图 1.4:感知机示例——0 或 1

我们需要一些不同的东西,一些更加平滑的东西。我们需要一个从 0 到 1 逐渐变化且没有间断的函数。从数学角度来看,这意味着我们需要一个连续函数,允许我们计算导数。你可能还记得,在数学中,导数是一个函数在某一点上变化的量。对于输入是实数的函数,导数是图中某一点切线的斜率。在本章稍后,我们将讨论为什么导数对学*很重要,尤其是我们会讲到梯度下降。

激活函数:Sigmoid

Sigmoid 函数,定义为 ,如下面的图所示,当输入在 范围内变化时,其输出在 (0, 1) 范围内变化较小。从数学角度看,该函数是连续的。一个典型的 Sigmoid 函数在 图 1.5 中表示:

图 1.5:输出范围为 (0,1) 的 Sigmoid 函数

一个神经元可以使用 Sigmoid 来计算非线性函数 。注意,如果 z = wx + b 非常大且为正数,那么 ,因此 ;而如果 z = wx + b 非常大且为负数,那么 ,因此 。换句话说,具有 Sigmoid 激活的神经元行为类似于感知器,但变化是渐进的,像 0.5539 或 0.123191 这样的输出值是完全合法的。从这个意义上说,Sigmoid 神经元可以回答“也许”。

激活函数:tanh

另一个有用的激活函数是 tanh。它的定义为 ,其形状如 图 1.6 所示。其输出范围从 -1 到 1:

图 1.6:Tanh 激活函数

激活函数:ReLU

“Sigmoid” 并不是用于神经网络的唯一平滑激活函数。最*,一个非常简单的函数叫做 ReLU修正线性单元)变得非常流行,因为它有助于解决 Sigmoid 中优化时出现的一些问题。我们将在 第五章 中更详细地讨论这些问题,特别是在讨论梯度消失时,我们会提到递归神经网络。

ReLU 被简单地定义为 f(x) = max(0, x),其非线性函数在 图 1.7 中表示。如我们所见,该函数对于负值为零,对于正值线性增长。ReLU 也非常容易实现(通常只需要三条指令),而 Sigmoid 实现起来复杂得多,可能需要几个数量级的计算。这有助于将神经网络压缩到早期的 GPU 上:

图 1.7:ReLU 函数

两个额外的激活函数:ELU 和 Leaky ReLU

Sigmoid 和 ReLU 并不是用于学*的唯一激活函数。

指数线性单元ELU)定义为!,当时,其图像如图 1.8所示:

图 1.8:一个 ELU 函数

LeakyReLU 定义为!,当时,其图像如图 1.9所示:

图 1.9:一个 LeakyReLU 函数

这两个函数允许在x为负时进行小幅更新,这在某些条件下可能会很有用。

激活函数

Sigmoid、Tanh、ELU、Leaky ReLU 和 ReLU 通常被称为神经网络术语中的激活函数。在梯度下降部分,我们将看到这些渐变变化,尤其是 Sigmoid 和 ReLU 函数的渐变变化,是开发一种学*算法的基本构建块,这种算法通过逐渐减少网络所犯的错误,从而一点点地进行调整。一个使用激活函数的示例!,输入向量为(x[1], x[2],..., x[m]),权重向量为(w[1], w[2],..., w[m]),偏置为b,汇总为!,如图 1.10所示(请注意,TensorFlow 支持许多激活函数,完整的函数列表可以在线查看):

Diagram  Description automatically generated

图 1.10:激活函数在线性函数后的应用示例

简而言之:神经网络到底是什么?

用一句话来说,机器学*模型是一种计算函数的方式,它将某些输入映射到对应的输出。这个函数不过是一些加法和乘法操作。然而,当与非线性激活函数结合并堆叠成多层时,这些函数几乎可以学*任何东西[8]。我们还需要一个有意义的度量,来捕捉我们希望优化的内容(即所谓的损失函数,我们将在后面的章节中介绍),足够的数据来学*,以及足够的计算能力。

现在,可能有必要停下来问自己,“学*”究竟是什么?好吧,从我们的角度来看,学*本质上是一个旨在将已建立的观察结果[9]泛化,从而预测未来结果的过程。所以,简而言之,这正是我们希望通过神经网络实现的目标。

一个实际的例子:识别手写数字

在本节中,我们将构建一个可以识别手写数字的网络。为了实现这个目标,我们使用 MNIST(yann.lecun.com/exdb/mnist/),这是一个包含 60,000 个训练示例和 10,000 个测试示例的手写数字数据库。训练示例由人工标注,标注的是正确的答案。例如,如果手写数字是“3”,那么 3 就是与该示例关联的标签。

在机器学*中,当有一个包含正确答案的数据集时,我们称之为可以执行某种形式的 监督学*。在这种情况下,我们可以使用训练示例来改进我们的网络。测试示例也有与每个数字相关的正确答案。然而,在这种情况下,目的是假装标签是未知的,让网络进行预测,然后再重新考虑标签,以评估我们的神经网络在识别数字方面的学*效果。毫不奇怪,测试示例仅用于测试我们网络的性能。

每张 MNIST 图像为灰度图,包含 28 x 28 个像素。以下是一些数字图像的子集,显示在 图 1.11 中:

mnist.png

图 1.11:一组 MNIST 图像

One hot 编码(OHE)

我们将使用 OHE 作为一个简单的工具来编码神经网络中使用的信息。在许多应用中,将类别(非数值型)特征转换为数值变量是很方便的。例如,类别特征digit的值为 d,其中 d 属于[0–9],可以被编码成一个具有 10 个位置的二进制向量,除了 d-th 位置外,其他位置的值始终为 0,而 d-th 位置的值为 1。例如,数字 3 可以编码为 [0, 0, 0, 1, 0, 0, 0, 0, 0, 0]。

这种表示方法被称为 One-Hot-EncodingOHE),有时也简称为 one-hot,在数据挖掘中非常常见,当学*算法专门用于处理数值函数时,通常会使用这种表示方式。

在 TensorFlow 中定义一个简单的神经网络

在本节中,我们使用 TensorFlow 定义一个识别 MNIST 手写数字的网络。我们从一个非常简单的神经网络开始,然后逐步改进它。

按照 Keras 的风格,TensorFlow 提供了合适的库(www.tensorflow.org/api_docs/python/tf/keras/datasets)用于加载数据集,并将其划分为训练集 X_train 用于微调我们的网络,以及测试集 X_test 用于评估性能。在本章后续内容中,我们将正式定义什么是训练集、验证集和测试集。现在,我们只需要知道训练集是用来让神经网络从数据示例中学*的。数据会被转换为 float32,以便在训练神经网络时使用 32 位精度,并被归一化到 [0,1] 范围内。此外,我们还将真实标签分别加载到 Y_trainY_test 中,并对其进行一-hot 编码。让我们看看代码。

目前,暂时不需要过于关注为什么某些参数有特定的赋值,这些选择将在本书的其余部分中讨论。直观地说,一个 epoch 定义了训练的持续时间,BATCH_SIZE是你每次输入网络的样本数量,验证样本是用于检查或验证训练过程有效性的数据量。我们选择EPOCHS = 200BATCH_SIZE = 128VALIDATION_SPLIT=0.2,以及N_HIDDEN = 128的原因将在本章后续内容中更加清楚,当时我们会探索不同的值并讨论超参数优化。让我们来看看我们在 TensorFlow 中的第一个神经网络代码片段。阅读上去直观,但你会在接下来的页面中找到详细的解释:

import tensorflow as tf
import numpy as np
from tensorflow import keras
# Network and training parameters.
EPOCHS = 200
BATCH_SIZE = 128
VERBOSE = 1
NB_CLASSES = 10   # number of outputs = number of digits
N_HIDDEN = 128
VALIDATION_SPLIT = 0.2 # how much TRAIN is reserved for VALIDATION
# Loading MNIST dataset.
# verify
# You can verify that the split between train and test is 60,000, and 10,000 respectively. 
# Labels have one-hot representation.is automatically applied
mnist = keras.datasets.mnist
(X_train, Y_train), (X_test, Y_test) = mnist.load_data()
# X_train is 60000 rows of 28x28 values; we  --> reshape it to 60000 x 784.
RESHAPED = 784
#
X_train = X_train.reshape(60000, RESHAPED)
X_test = X_test.reshape(10000, RESHAPED)
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
# Normalize inputs to be within in [0, 1].
X_train /= 255
X_test /= 255
print(X_train.shape[0], 'train samples')
print(X_test.shape[0], 'test samples')
# One-hot representation of the labels.
Y_train = tf.keras.utils.to_categorical(Y_train, NB_CLASSES)
Y_test = tf.keras.utils.to_categorical(Y_test, NB_CLASSES) 

从上面的代码可以看出,输入层与图像中的每个像素相关联,共有 28 x 28 = 784 个神经元,每个神经元对应 MNIST 图像中的一个像素。

通常,每个像素的值会被归一化到[0,1]范围内(这意味着每个像素的强度值会被 255(最大强度值)除)。输出可以是十个类之一,每个类对应一个数字。

最后一层是一个单神经元,激活函数为'softmax',它是 sigmoid 函数的一种推广。如前所述,当输入在范围内变化时,sigmoid 函数的输出在(0, 1)范围内。同样,softmax 将一个 K 维的任意实值向量“压缩”成一个 K 维的实值向量,范围在(0, 1)之间,并且它们的总和为 1。在我们的例子中,它将前一层的十个神经元提供的十个答案汇总。我们刚刚描述的内容可以通过以下代码实现:

# Build the model.
model = tf.keras.models.Sequential()
model.add(keras.layers.Dense(NB_CLASSES,
            input_shape=(RESHAPED,),
            name='dense_layer', 
            activation='softmax')) 

一旦我们定义了模型,就必须对其进行编译,以便它可以被 TensorFlow 执行。在编译过程中有一些选择。首先,我们需要选择一个优化器,它是用于在训练模型时更新权重的特定算法。

完整的优化器列表可以在www.tensorflow.org/api_docs/python/tf/keras/optimizers找到。其次,我们需要选择一个目标函数,它被优化器用来在权重空间中导航(通常目标函数被称为损失函数代价函数,而优化过程被定义为损失最小化的过程)。第三,我们需要评估训练好的模型。

一些常见的目标函数选择(完整的损失函数列表可以在www.tensorflow.org/api_docs/python/tf/keras/losses找到)有:

  • mse,定义为预测值与真实值之间的均方误差。从数学上讲,如果d是预测值向量,y是包含n个观测值的向量,那么 。请注意,这个目标函数是每个预测中所有错误的平均值。如果预测值与真实值相差较大,那么这个差距会通过平方操作变得更加明显。此外,平方操作能够加总误差,无论给定值是正数还是负数。

  • binary_crossentropy,定义为二分类对数损失。假设我们的模型预测值为p,而目标值为c,则二分类交叉熵定义为 。请注意,这个目标函数适用于二分类标签预测。

  • categorical_crossentropy,定义为多类对数损失。类别交叉熵比较预测分布与真实分布,其中真实类别的概率设置为 1,其他类别的概率设置为 0。如果真实类别是c,而预测为y,那么类别交叉熵定义为:

    考虑多类对数损失的一种方式是将真实类别表示为独热编码向量,并且模型输出越接*该向量,损失越低。请注意,这个目标函数适用于多分类标签预测,且在使用 softmax 激活函数时为默认选择。完整的损失函数列表请参见 www.tensorflow.org/api_docs/python/tf/keras/losses

一些常见的度量标准(完整的度量标准列表请参见 www.tensorflow.org/api_docs/python/tf/keras/metrics)包括:

  • 准确率,定义为正确预测相对于总预测数量的比例。

  • 精确度,定义为正确的正向预测相对于正确和错误的正向预测数量的比例。

  • 召回率,定义为正确的正向预测相对于实际正向预测数量的比例。

完整的度量标准列表可以在www.tensorflow.org/api_docs/python/tf/keras/metrics查看。度量标准与目标函数类似,不同之处在于它们不用于训练模型,只用于评估模型。然而,理解度量标准与目标函数的区别非常重要。如前所述,损失函数用于优化你的网络,这是被选定优化器最小化的函数。而度量标准则用于判断你的网络性能。这仅供你进行评估,应该与优化过程分开。在某些情况下,理想的做法是直接优化特定的度量标准。然而,一些度量标准对于其输入是不可导的,因此不能直接用于优化。

在 TensorFlow 中编译模型时,可以选择优化器、损失函数和度量标准,它们将与给定的模型一起使用:

# Compiling the model.
model.compile(optimizer='SGD', 
              loss='categorical_crossentropy',
              metrics=['accuracy']) 

随机梯度下降法SGD)是一种特殊的优化算法,用于减少神经网络在每次训练周期后的错误。我们将在接下来的章节中回顾 SGD 和其他优化算法。模型编译完成后,可以使用fit()方法进行训练,该方法指定了一些参数:

  • epochs是指模型暴露于训练集的次数。在每次迭代中,优化器会尝试调整权重,以最小化目标函数。

  • batch_size是指在优化器进行权重更新之前观察到的训练实例的数量;每个周期通常会有多个批次。

在 TensorFlow 中训练模型非常简单:

# Training the model.
model.fit(X_train, Y_train,
          batch_size=BATCH_SIZE, epochs=EPOCHS,
          verbose=VERBOSE, validation_split=VALIDATION_SPLIT) 

请注意,我们已经为验证保留了部分训练集。关键思想是,在训练过程中,我们保留部分训练数据用于验证集的性能评估。这是一种良好的实践,适用于任何机器学*任务,并且我们将在所有示例中采用这种方法。请注意,当我们在本章后面讨论过拟合时,会再次提到验证。

一旦模型训练完成,我们可以在测试集上进行评估,该测试集包含在训练阶段从未见过的新示例。

当然,训练集和测试集是严格分开的。没有必要在已经用于训练的示例上评估模型。在 TensorFlow 中,我们可以使用evaluate(X_test, Y_test)方法来计算test_losstest_acc

#evaluate the model
test_loss, test_acc = model.evaluate(X_test, Y_test)
print('Test accuracy:', test_acc) 

恭喜!你刚刚定义了你的第一个神经网络。只需几行代码,你的计算机就能够识别手写数字。让我们运行代码并看看性能如何。

运行一个简单的 TensorFlow 网络并建立基准

那么,让我们看看当我们运行代码时会发生什么:

Model: "sequential"
_________________________________________________________________
Layer (type)                Output Shape              Param #    
=================================================================
dense_layer (Dense)         (None, 10)                7850       

=================================================================
Total params: 7,850
Trainable params: 7,850
Non-trainable params: 0
_________________________________________________________________
Train on 48000 samples, validate on 12000 samples
Epoch 1/200
48000/48000 [==============================] - 1s 31us/sample - loss: 2.1276 - accuracy: 0.2322 - val_loss: 1.9508 - val_accuracy: 0.3908
Epoch 2/200
48000/48000 [==============================] - 1s 23us/sample - loss: 1.8251 - accuracy: 0.5141 - val_loss: 1.6848 - val_accuracy: 0.6277
Epoch 3/200
48000/48000 [==============================] - 1s 25us/sample - loss: 1.5992 - accuracy: 0.6531 - val_loss: 1.4838 - val_accuracy: 0.7150
Epoch 4/200
48000/48000 [==============================] - 1s 27us/sample - loss: 1.4281 - accuracy: 0.7115 - val_loss: 1.3304 - val_accuracy: 0.7551
Epoch 5/200 

首先,网络架构被输出,我们可以看到使用的不同类型的层、它们的输出形状、需要优化的参数数量(即需要优化的权重数量)以及它们是如何连接的。然后,网络在 48K 样本上进行训练,12K 样本用于验证。一旦神经模型构建完成,它会在 10K 样本上进行测试。现在我们不深入探讨训练如何进行,但可以看到程序运行了 200 次,每次准确率都有所提高。训练结束后,我们在测试集上测试我们的模型,最终得到了在训练数据集上的约 89.96%的准确率,在验证集上的 90.70%,以及在测试集上的 90.71%:

Epoch 199/200
48000/48000 [==============================] - 1s 22us/sample - loss: 0.3684 - accuracy: 0.8995 - val_loss: 0.3464 - val_accuracy: 0.9071
Epoch 200/200
48000/48000 [==============================] - 1s 23us/sample - loss: 0.3680 - accuracy: 0.8996 - val_loss: 0.3461 - val_accuracy: 0.9070
10000/10000 [==============================] - 1s 54us/sample - loss: 0.3465 - accuracy: 0.9071
Test accuracy: 0.9071 

这意味着大约每 10 张图片中就有 1 张被错误分类。我们肯定能做得比这更好。

使用 TensorFlow 通过隐藏层改进简单网络

好的,我们在训练数据集上的准确率基准为 89.96%,在验证集上为 90.70%,在测试集上为 90.71%。这是一个不错的起点,但我们可以改进它。让我们看看怎么做。

一项初步的改进是向我们的网络中添加更多层,因为这些额外的神经元可能直观地有助于学*训练数据中的更复杂模式。换句话说,额外的层增加了更多参数,可能让模型记住更复杂的模式。所以,在输入层之后,我们添加了第一层密集层,其中有N_HIDDEN个神经元,并使用激活函数'relu'。这个额外的层被认为是隐藏层,因为它既不与输入层直接连接,也不与输出层直接连接。在第一个隐藏层之后,我们添加了第二个隐藏层,同样有N_HIDDEN个神经元,然后是一个输出层,包含 10 个神经元,每个神经元会在识别到相应的数字时被激活。以下代码定义了这个新的网络:

import tensorflow as tf
from tensorflow import keras
# Network and training.
EPOCHS = 50
BATCH_SIZE = 128
VERBOSE = 1
NB_CLASSES = 10   # number of outputs = number of digits
N_HIDDEN = 128
VALIDATION_SPLIT = 0.2 # how much TRAIN is reserved for VALIDATION
# Loading MNIST dataset.
# Labels have one-hot representation.
mnist = keras.datasets.mnist
(X_train, Y_train), (X_test, Y_test) = mnist.load_data()
# X_train is 60000 rows of 28x28 values; we reshape it to 60000 x 784.
RESHAPED = 784
#
X_train = X_train.reshape(60000, RESHAPED)
X_test = X_test.reshape(10000, RESHAPED)
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
# Normalize inputs to be within in [0, 1].
X_train, X_test = X_train / 255.0, X_test / 255.0
print(X_train.shape[0], 'train samples')
print(X_test.shape[0], 'test samples')
# Labels have one-hot representation.
Y_train = tf.keras.utils.to_categorical(Y_train, NB_CLASSES)
Y_test = tf.keras.utils.to_categorical(Y_test, NB_CLASSES)
# Build the model.
model = tf.keras.models.Sequential()
model.add(keras.layers.Dense(N_HIDDEN,
             input_shape=(RESHAPED,),
             name='dense_layer', activation='relu'))
model.add(keras.layers.Dense(N_HIDDEN,
             name='dense_layer_2', activation='relu'))
model.add(keras.layers.Dense(NB_CLASSES,
             name='dense_layer_3', activation='softmax'))
# Summary of the model.
model.summary()
# Compiling the model.
model.compile(optimizer='SGD', 
              loss='categorical_crossentropy',
              metrics=['accuracy'])
# Training the model.
model.fit(X_train, Y_train,
          batch_size=BATCH_SIZE, epochs=EPOCHS,
          verbose=VERBOSE, validation_split=VALIDATION_SPLIT)
# Evaluating the model.
test_loss, test_acc = model.evaluate(X_test, Y_test)
print('Test accuracy:', test_acc) 

请注意,to_categorical(Y_train, NB_CLASSES)将数组Y_train转换为一个矩阵,列数等于类别数,而行数保持不变。比如,假设我们有:

> labels
array([0, 2, 1, 2, 0]) 

那么:

to_categorical(labels)
array([[ 1.,  0.,  0.],
       [ 0.,  0.,  1.],
       [ 0.,  1.,  0.],
       [ 0.,  0.,  1.],
       [ 1.,  0.,  0.]], dtype=float32) 

让我们运行代码,看看这个多层网络得到什么结果:

_________________________________________________________________
Layer (type)                Output Shape              Param #    
=================================================================
dense_layer (Dense)         (None, 128)               100480     

dense_layer_2 (Dense)       (None, 128)               16512      

dense_layer_3 (Dense)       (None, 10)                1290       

=================================================================
Total params: 118,282
Trainable params: 118,282
Non-trainable params: 0
_________________________________________________________________
Train on 48000 samples, validate on 12000 samples
Epoch 1/50
48000/48000 [==============================] - 3s 63us/sample - loss: 2.2507 - accuracy: 0.2086 - val_loss: 2.1592 - val_accuracy: 0.3266 

上一个输出展示了运行的初始步骤,而以下输出展示了结论。不坏。从以下输出可以看到,通过添加两层隐藏层,我们在训练数据集上的准确率达到了 90.81%,在验证集上为 91.40%,在测试集上为 91.18%。这意味着,相比之前的网络,我们提高了测试数据集上的准确率,并且将迭代次数从 200 次减少到 50 次。这很好,但我们还想要更多。

如果你愿意,你可以自己尝试,看看如果你只添加一个隐藏层而不是两个,或者如果你添加超过两个层,会发生什么。我把这个实验留给你作为练*:

Epoch 49/50
48000/48000 [==============================] - 1s 30us/sample - loss: 0.3347 - accuracy: 0.9075 - val_loss: 0.3126 - val_accuracy: 0.9136
Epoch 50/50
48000/48000 [==============================] - 1s 28us/sample - loss: 0.3326 - accuracy: 0.9081 - val_loss: 0.3107 - val_accuracy: 0.9140
10000/10000 [==============================] - 0s 40us/sample - loss: 0.3164 - accuracy: 0.9118
Test accuracy: 0.9118 

请注意,改进会在一定数量的迭代后停止(或者变得几乎不可察觉)。在机器学*中,这种现象被称为收敛

使用 TensorFlow 通过 Dropout 进一步改进简单网络

目前,我们的基准是训练集准确率 90.81%,验证集准确率 91.40%,测试集准确率 91.18%。第二个改进非常简单。我们决定在训练过程中,以 DROPOUT 概率随机丢弃一些值,这些值在我们的内部密集网络的隐藏层中传播。在机器学*中,这是一种广为人知的正则化方法。令人惊讶的是,随机丢弃一些值的这一想法竟然能改善我们的性能。其背后的思想是,随机丢弃 迫使 网络学*到冗余的模式,这些模式对更好的泛化有帮助:

import tensorflow as tf
import numpy as np
from tensorflow import keras
# Network and training.
EPOCHS = 200
BATCH_SIZE = 128
VERBOSE = 1
NB_CLASSES = 10   # number of outputs = number of digits
N_HIDDEN = 128
VALIDATION_SPLIT = 0.2 # how much TRAIN is reserved for VALIDATION
DROPOUT = 0.3
# Loading MNIST dataset.
# Labels have one-hot representation.
mnist = keras.datasets.mnist
(X_train, Y_train), (X_test, Y_test) = mnist.load_data()
# X_train is 60000 rows of 28x28 values; we reshape it to 60000 x 784.
RESHAPED = 784
#
X_train = X_train.reshape(60000, RESHAPED)
X_test = X_test.reshape(10000, RESHAPED)
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
# Normalize inputs within [0, 1].
X_train, X_test = X_train / 255.0, X_test / 255.0
print(X_train.shape[0], 'train samples')
print(X_test.shape[0], 'test samples')
# One-hot representations for labels.
Y_train = tf.keras.utils.to_categorical(Y_train, NB_CLASSES)
Y_test = tf.keras.utils.to_categorical(Y_test, NB_CLASSES)
# Building the model.
model = tf.keras.models.Sequential()
model.add(keras.layers.Dense(N_HIDDEN,
              input_shape=(RESHAPED,),
              name='dense_layer', activation='relu'))
model.add(keras.layers.Dropout(DROPOUT))
model.add(keras.layers.Dense(N_HIDDEN,
              name='dense_layer_2', activation='relu'))
model.add(keras.layers.Dropout(DROPOUT))
model.add(keras.layers.Dense(NB_CLASSES,
              name='dense_layer_3', activation='softmax'))
# Summary of the model.
model.summary()
# Compiling the model.
model.compile(optimizer='SGD', 
              loss='categorical_crossentropy',
              metrics=['accuracy'])
# Training the model.
model.fit(X_train, Y_train,
          batch_size=BATCH_SIZE, epochs=EPOCHS,
          verbose=VERBOSE, validation_split=VALIDATION_SPLIT)
# Evaluating the model.
test_loss, test_acc = model.evaluate(X_test, Y_test)
print('Test accuracy:', test_acc) 

让我们像之前一样运行 200 次迭代,可以看到该网络在训练集上的准确率为 91.70%,在验证集上的准确率为 94.42%,在测试集上的准确率为 94.15%:

Epoch 199/200
48000/48000 [==============================] - 2s 45us/sample - loss: 0.2850 - accuracy: 0.9177 - val_loss: 0.1922 - val_accuracy: 0.9442
Epoch 200/200
48000/48000 [==============================] - 2s 42us/sample - loss: 0.2845 - accuracy: 0.9170 - val_loss: 0.1917 - val_accuracy: 0.9442
10000/10000 [==============================] - 1s 61us/sample - loss: 0.1927 - accuracy: 0.9415
Test accuracy: 0.9415 

请注意,已经多次观察到,在内部隐藏层中具有随机丢弃(dropout)功能的网络可以在未见过的测试集样本上“泛化”得更好。直观地说,我们可以将这一现象理解为每个神经元变得更有能力,因为它知道自己不能依赖于邻*的神经元。此外,这还迫使信息以冗余的方式进行存储。在测试时没有丢弃,因此我们现在使用的是所有经过高度调优的神经元。简而言之,当采用某种丢弃功能时,通常可以通过测试网络的表现来验证其有效性。

此外,请注意,训练的准确率应该仍然高于测试的准确率;否则,我们可能训练的时间还不够长。这在我们的例子中确实是这样,因此我们应该增加训练的轮次。然而,在尝试这样做之前,我们需要引入一些其他概念,这些概念可以加速训练的收敛过程。接下来我们讨论优化器。

在 TensorFlow 中测试不同的优化器

现在我们已经定义并使用了一个网络,接下来可以通过一个类比来帮助我们更好地理解网络是如何训练的。让我们关注一种流行的训练技术——梯度下降法GD)。假设有一个通用的代价函数 C(w),它是一个关于单一变量 w 的函数,如图 1.12所示:

图 1.12:梯度下降优化的示例

梯度下降法可以看作是一个需要沿着陡峭的坡道向下行进的登山者,目标是进入一个沟壑。坡道代表着代价函数 C,而沟壑代表着最小值 C[min]。登山者有一个起始点 w[0],并逐步前进;可以想象,这里几乎没有可见性,所以登山者无法自动看到前进的方向,而是采取锯齿形的路径。在每一步 r,梯度是最大增量的方向。

数学上,这个方向是偏导数的值 ,它在步长 r 时的 w[r] 点被评估出来。因此,通过采取相反的方向 ,登山者可以朝向沟壑移动。

在每一步,徒步旅行者可以决定在下次停下之前走多大一步。这就是 GD 术语中的所谓“学*率” 。请注意,如果 太小,那么徒步旅行者的步伐会非常缓慢。然而,如果 太大,那么徒步旅行者可能会跳过沟渠。

现在你应该记得,sigmoid 是一个连续函数,并且可以计算它的导数。可以证明,sigmoid 的导数是

ReLU 在 0 处不可导。然而,我们可以通过将 0 处的导数扩展到整个领域来定义它为 0 或 1,从而使其成为一个函数。

ReLU 的分段导数

一旦我们得到导数,就可以使用 GD 技术优化网络。TensorFlow 为我们计算导数,所以我们不需要担心实现或计算它。

神经网络本质上是由多个可导函数组成,具有成千上万,甚至数百万个参数。每一层网络计算一个函数,其误差应该最小化,以提高学*阶段观察到的准确性。当我们讨论反向传播时,我们将发现最小化问题比我们的简化例子要复杂一些。然而,它仍然基于相同的直觉,即沿着坡度下降,直到到达沟渠。

TensorFlow 实现了一种快速的 GD 变体,称为 随机梯度下降 (SGD),以及许多更先进的优化技术,如 RMSProp 和 Adam。RMSProp 和 Adam 除了拥有 SGD 的加速组件外,还引入了动量(速度分量)概念。这使得收敛速度更快,但也需要更多的计算。可以想象,一名徒步旅行者开始朝一个方向行进,然后决定改变方向,但会记住之前的选择。可以证明,动量有助于在相关方向上加速 SGD,同时减缓振荡[10]。

到目前为止,SGD 是我们默认的选择。那么现在让我们尝试另外两种方法。非常简单;我们只需要修改几行代码:

# Compiling the model.
model.compile(optimizer='RMSProp', 
              loss='categorical_crossentropy', metrics=['accuracy']) 

就这些,来测试一下吧。

_________________________________________________________________
Layer (type)                Output Shape              Param #    
=================================================================
dense_layer (Dense)         (None, 128)               100480     

dropout_2 (Dropout)         (None, 128)               0          

dense_layer_2 (Dense)       (None, 128)               16512      

dropout_3 (Dropout)         (None, 128)               0          

dense_layer_3 (Dense)       (None, 10)                1290       

=================================================================
Total params: 118,282
Trainable params: 118,282
Non-trainable params: 0
_________________________________________________________________
Train on 48000 samples, validate on 12000 samples
Epoch 1/10
48000/48000 [==============================] - 2s 48us/sample - loss: 0.4715 - accuracy: 0.8575 - val_loss: 0.1820 - val_accuracy: 0.9471
Epoch 2/10
48000/48000 [==============================] - 2s 36us/sample - loss: 0.2215 - accuracy: 0.9341 - val_loss: 0.1268 - val_accuracy: 0.9361
Epoch 3/10
48000/48000 [==============================] - 2s 39us/sample - loss: 0.1684 - accuracy: 0.9497 - val_loss: 0.1198 - val_accuracy: 0.9651
Epoch 4/10
48000/48000 [==============================] - 2s 43us/sample - loss: 0.1459 - accuracy: 0.9569 - val_loss: 0.1059 - val_accuracy: 0.9710
Epoch 5/10
48000/48000 [==============================] - 2s 39us/sample - loss: 0.1273 - accuracy: 0.9623 - val_loss: 0.1059 - val_accuracy: 0.9696
Epoch 6/10
48000/48000 [==============================] - 2s 36us/sample - loss: 0.1177 - accuracy: 0.9659 - val_loss: 0.0941 - val_accuracy: 0.9731
Epoch 7/10
48000/48000 [==============================] - 2s 35us/sample - loss: 0.1083 - accuracy: 0.9671 - val_loss: 0.1009 - val_accuracy: 0.9715
Epoch 8/10
48000/48000 [==============================] - 2s 35us/sample - loss: 0.0971 - accuracy: 0.9706 - val_loss: 0.0950 - val_accuracy: 0.9758
Epoch 9/10
48000/48000 [==============================] - 2s 35us/sample - loss: 0.0969 - accuracy: 0.9718 - val_loss: 0.0985 - val_accuracy: 0.9745
Epoch 10/10
48000/48000 [==============================] - 2s 35us/sample - loss: 0.0873 - accuracy: 0.9743 - val_loss: 0.0966 - val_accuracy: 0.9762
10000/10000 [==============================] - 1s 2ms/sample - loss: 0.0922 - accuracy: 0.9764
Test accuracy: 0.9764 

如你所见,RMSProp 比 SDG 更快,因为我们仅在 10 次训练中就能在训练数据集上达到 97.43% 的准确率,在验证集上为 97.62%,在测试集上为 97.64%。这是 SDG 的显著改进。现在我们有了一个非常快速的优化器,接下来我们试着将训练轮数显著增加到 250 次,结果在训练数据集上达到了 98.99% 的准确率,在验证集上为 97.66%,在测试集上为 97.77%:

Epoch 248/250
48000/48000 [==============================] - 2s 40us/sample - loss: 0.0506 - accuracy: 0.9904 - val_loss: 0.3465 - val_accuracy: 0.9762
Epoch 249/250
48000/48000 [==============================] - 2s 40us/sample - loss: 0.0490 - accuracy: 0.9905 - val_loss: 0.3645 - val_accuracy: 0.9765
Epoch 250/250
48000/48000 [==============================] - 2s 39us/sample - loss: 0.0547 - accuracy: 0.9899 - val_loss: 0.3353 - val_accuracy: 0.9766
10000/10000 [==============================] - 1s 58us/sample - loss: 0.3184 - accuracy: 0.9779
Test accuracy: 0.9779 

观察随着训练轮数增加,训练集和测试集上的准确率变化非常有用(见 图 1.13)。正如你所看到的,这两条曲线在大约 15 次训练后相交,因此在此之后就不再需要继续训练:

图 1.13:使用 RMSProp 的准确率和损失示例

好的,让我们尝试另一个优化器,Adam()。实现起来相当简单:

# Compiling the model.
model.compile(optimizer='Adam', 
              loss='categorical_crossentropy',
              metrics=['accuracy']) 

如我们所见,Adam()略好一些。使用 Adam,我们在训练数据集上的准确率为 98.94%,在验证集上的准确率为 97.89%,在测试集上的准确率为 97.82%,使用了 50 次迭代:

Epoch 49/50
48000/48000 [==============================] - 3s 55us/sample - loss: 0.0313 - accuracy: 0.9894 - val_loss: 0.0868 - val_accuracy: 0.9808
Epoch 50/50
48000/48000 [==============================] - 2s 51s/sample - loss: 0.0321 - accuracy: 0.9894 - val_loss: 0.0983 - val_accuracy: 0.9789
10000/10000 [==============================] - 1s 66us/step - loss: 0.0964 - accuracy: 0.9782
Test accuracy: 0.9782 

再次,让我们绘制当迭代次数增加时,训练集和测试集上的准确率是如何变化的(见图 1.14)。你会注意到,通过选择 Adam 作为优化器,我们能够在大约 12 次迭代或步骤后就停止:

图 1.14:使用 Adam 的准确率和损失示例

请注意,这是我们的第五个变体,并记住我们最初的基线在测试数据集上的准确率为 90.71%。到目前为止,我们已逐步改进。然而,收益现在变得越来越难以获得。请注意,我们正在使用 30% 的丢弃率进行优化。为了完整性,报告不同丢弃率下测试数据集的准确率可能会很有用(见图 1.15)。在这个例子中,我们选择了 Adam 作为优化器。请注意,优化器的选择不是一成不变的,我们可以根据问题和优化器的组合获得不同的性能:

图表

图 1.15:不同丢弃率下的准确率变化示例

增加迭代次数

让我们再试一次,将用于训练的迭代次数从 20 增加到 200。遗憾的是,这个选择将我们的计算时间增加了十倍,但并没有带来任何好处。实验失败了,但我们已经学到,如果我们花更多时间学*,结果不一定会改善。学*更多的是采用智能的技巧,而不一定是花费在计算上的时间。让我们在接下来的图表中跟踪我们的五种变体:

图表

图 1.16:不同模型和优化器的准确率

控制优化器的学*率

还有一种方法是改变优化器的学*参数。正如你在图 1.17中看到的,我们的三个实验 [lr=0.1, lr=0.01, 和 lr=0.001] 达到的最佳值是 0.1,这是优化器的默认学*率。很好!Adam 开箱即用:

图表

图 1.17:不同学*率下的准确率

增加内部隐藏神经元的数量

另一种方法是改变内部隐藏神经元的数量。我们报告了在增加隐藏神经元数量时的实验结果。我们发现,通过增加模型的复杂性,运行时间显著增加,因为需要优化的参数越来越多。然而,随着网络规模的增大,我们通过增加网络大小获得的收益越来越少(见图 1.18图 1.19图 1.20):

图表

图 1.18:内部隐藏神经元数量增加时的参数数量

另一方面,随着内部网络大小的增加,所需的时间也会增加(见图 1.19):

图表

图 1.19:内部隐藏神经元数量增加时的计算时间(秒)

请注意,在某个值之后,增加隐藏神经元的数量可能会降低准确率,因为网络可能无法很好地泛化(如图 1.20所示):

图表

图 1.20:内部隐藏神经元数量增加时的测试准确率

增加批处理计算的大小

GD 尝试最小化在训练集中提供的所有示例上的成本函数,同时考虑所有作为输入提供的特征。SGD 是一个更便宜的变体,只考虑 BATCH_SIZE 个示例。那么,让我们看看当我们更改此参数时它的表现。正如你所看到的,在我们的四个实验中,最佳准确率值出现在 BATCH_SIZE=64 时(见图 1.21):

图表

图 1.21:不同批处理值下的测试准确率

总结识别手写数字的实验

所以,让我们总结一下:通过五种不同的变体,我们能够将性能从 90.71% 提升到 97.82%。首先,我们在 TensorFlow 中定义了一个简单的层网络。然后,我们通过添加一些隐藏层来提高性能。之后,我们通过在网络中添加一些随机丢弃来改善测试集上的表现,接着通过实验不同类型的优化器来进一步提升性能:

准确率
模型 训练
简单 89.96%
两层隐藏层(128) 90.81%
Dropout(30%) 91.70%
RMSProp 97.43%
Adam 98.94%

表 1.1:不同准确率水平的实验总结

然而,接下来的两个实验(未显示在表 1.1中)并未提供显著的改进。增加内部神经元的数量会创建更复杂的模型,并需要更多昂贵的计算,但它只提供了边际的增益。如果我们增加训练轮次,也会有相同的体验。最后一个实验是改变优化器的BATCH_SIZE。这也提供了边际的结果。

正则化

在本节中,我们将回顾一些改进训练阶段的最佳实践。特别是,将讨论正则化和批归一化。

采用正则化以避免过拟合

直观地说,一个好的机器学*模型应该在训练数据上实现低误差。从数学上讲,这等同于在给定模型的情况下最小化训练数据上的损失函数:

然而,这可能还不够。一个模型可能会变得过于复杂,以便捕捉训练数据中固有表达的所有关系。这种复杂度的增加可能带来两个负面后果。首先,复杂的模型可能需要大量时间来执行。其次,复杂的模型可能在训练数据上表现非常好,但在验证数据上表现很差。这是因为模型能够在特定的训练上下文中制造多个参数之间的关系,但这些关系在更一般的上下文中实际上并不存在。这种导致模型丧失泛化能力的现象被称为“过拟合”。再次强调,学*更重要的是关于泛化,而非记忆。另一个需要考虑的现象是“欠拟合”。

这种情况发生在数据模型无法准确捕捉输入与输出变量之间的关系时,训练集和新未见数据的误差率都很高:

图 1.22:损失函数与过拟合

一般来说,如果在训练过程中,我们看到验证集的损失在初步下降后开始增加,那么我们遇到了模型复杂度的问题,导致模型过拟合训练数据。

为了解决过拟合问题,我们需要一种方法来捕捉模型的复杂度,也就是模型可以有多复杂。解决方案是什么呢?实际上,模型不过是一个权重向量。每个权重都会影响输出,除了那些为零或接*零的权重。因此,模型的复杂度可以方便地用非零权重的数量来表示。换句话说,如果我们有两个模型 M1 和 M2,在损失函数方面的表现几乎相同,那么我们应该选择最简单的模型,即非零权重数量最少的那个模型。我们可以使用一个超参数 来控制保持简单模型的重要性,如下公式所示:

机器学*中有三种不同类型的正则化方法:

  • L1 正则化(也称为 LASSO)。模型的复杂度表示为权重绝对值的和。

  • L2 正则化(也称为 Ridge)。模型的复杂度表示为权重平方和。

  • ElasticNet 正则化。模型的复杂度通过上述两种技术的结合来表示。

注意,调整正则化可以是一种提升网络泛化性能的好方法,特别是在过拟合明显的情况下。这组实验留给有兴趣的读者自行完成。

另外,值得注意的是,TensorFlow 支持 L1、L2 和 ElasticNet 正则化。完整的正则化器列表可以在 www.tensorflow.org/api_docs/python/tf/keras/regularizers 中找到。添加正则化非常简单:

from tf.keras.regularizers import l2, activity_l2
model.add(Dense(64, input_dim=64, W_regularizer=l2(0.01),
    activity_regularizer=activity_l2(0.01))) 

理解批量归一化

批量归一化是另一种正则化方法,也是*年来提出的最有效的改进之一。批量归一化可以加速训练,在某些情况下将训练周期缩短一半,并提供一定的正则化效果。在训练过程中,前面层的权重会自然变化,因此后续层的输入可能会发生显著变化。换句话说,每一层必须不断地重新调整其权重,以适应每个批次的不同分布。这可能会大大减慢模型的训练速度。关键思想是使每一层的输入在每个批次和每个周期中具有更相似的分布。

另一个问题是,sigmoid 激活函数在接*零时效果很好,但当值远离零时,它往往会“卡住”。如果神经元的输出偶尔远离 sigmoid 的零点,则该神经元将无法更新其权重。

另一个关键思想是将层的输出转换为接*零的高斯分布单位。这样,层之间的变化将显著减少。数学上,公式非常简单。通过从激活输入 x 中减去批次均值 ,使其围绕零进行居中。然后将结果除以 ,即批次方差的和 ,并加上一个小数 ,以防止除以零。接着,我们使用线性变换 来确保在训练过程中应用归一化效果。

这样, 是在训练阶段优化的参数,优化方式类似于任何其他层。批量归一化已被证明是一种非常有效的方法,可以提高训练速度和准确性,因为它有助于防止激活值过小而消失或过大而爆炸。

使用 Google Colab:CPUs、GPUs 和 TPUs

Google 提供了一款直观的工具,用于训练神经网络并免费体验 TensorFlow。你可以访问一个实际的 Colab,免费使用,地址为 colab.research.google.com/,如果你熟悉 Jupyter notebooks,你会发现这里是一个非常熟悉的基于网页的环境。Colab 代表 Colaboratory,这是一个 Google 的研究项目,旨在帮助传播机器学*教育和研究。我们将在第十五章张量处理单元中了解 CPU、GPU 和 TPU 之间的区别。

目前,重要的是要知道,CPU 是通用处理单元,而 GPU 和 TPU 是加速器,专门用于深度学*的处理单元。让我们从 图 1.23 中显示的截图开始,看看它是如何工作的:

图 1.23:Colab 中的笔记本示例

通过访问 Colab,我们可以查看以前生成的笔记本列表,也可以创建新的笔记本。支持不同版本的 Python。

当我们创建一个新的笔记本时,我们还可以选择是否在 CPU、GPU 或 Google 的 TPU 上运行,如 图 1.24 所示:

图 1.24:选择所需的硬件加速器(无、GPU 或 TPU)——第一步

通过访问笔记本设置选项,该选项位于编辑菜单中(见 图 1.24图 1.25),我们可以选择所需的硬件加速器(GPUTPU)。谷歌会免费分配这些资源,尽管它们可能会随时撤回,例如在负载特别重的期间。根据我的经验,这种情况非常罕见,你几乎随时都可以使用 Colab。然而,还是请保持礼貌,不要做类似免费挖比特币的事情——你几乎肯定会被踢出去!

图 1.25:选择所需的硬件加速器(无、GPU 或 TPU)——第二步

下一步是将你的代码插入到适当的 Colab 笔记本单元格中(见 图 1.26),然后 瞧! 你就准备好了。执行代码,享受深度学*的乐趣,无需购买非常昂贵的硬件即可开始实验!图 1.26 展示了 Google 笔记本中的代码示例:

图 1.26:笔记本中的代码示例

情感分析

我们用来测试 Colab 的代码是什么?它是一个基于 IMDB 数据集开发的情感分析示例。IMDB 数据集包含来自互联网电影数据库的 50,000 条电影评论文本。每条评论要么是正面的,要么是负面的(例如,点赞或点踩)。数据集分为 25,000 条用于训练的评论和 25,000 条用于测试的评论。我们的目标是构建一个分类器,根据文本预测二元判断。我们可以通过 tf.keras 轻松加载 IMDB,评论中的单词序列已被转换为整数序列,其中每个整数代表字典中的一个特定单词。我们还可以方便地将句子填充至 max_len,这样我们就可以将所有句子(无论长短)作为固定大小输入向量,输入到神经网络中:

import tensorflow as tf
from tensorflow.keras import datasets, layers, models, preprocessing
import tensorflow_datasets as tfds
max_len = 200
n_words = 10000
dim_embedding = 256
EPOCHS = 20
BATCH_SIZE = 500
def load_data():
    # Load data.
    (X_train, y_train), (X_test, y_test) = datasets.imdb.load_data(num_words=n_words)
    # Pad sequences with max_len.
    X_train = preprocessing.sequence.pad_sequences(X_train, maxlen=max_len)
    X_test = preprocessing.sequence.pad_sequences(X_test, maxlen=max_len)
    return (X_train, y_train), (X_test, y_test) 

现在让我们构建一个模型。我们将使用几个在第四章中详细解释的层,词嵌入。现在假设embedding()层将把评论中包含的稀疏词空间映射到一个更密集的空间中,这将使计算变得更加容易。此外,我们将使用GlobalMaxPooling1D()层,它从每个n_words特征的特征向量中取最大值。此外,我们有两个Dense()层,最后一个层由一个带有 sigmoid 激活函数的神经元组成,用于进行最终的二分类估计:

def build_model():
    model = models.Sequential()
    # Input: - eEmbedding Layer.
    # The model will take as input an integer matrix of size (batch, input_length).
    # The model will output dimension (input_length, dim_embedding).
    # The largest integer in the input should be no larger
    # than n_words (vocabulary size).
    model.add(layers.Embedding(n_words, 
        dim_embedding, input_length=max_len))
    model.add(layers.Dropout(0.3))
    # Takes the maximum value of either feature vector from each of the n_words features.
    model.add(layers.GlobalMaxPooling1D())
    model.add(layers.Dense(128, activation='relu'))
    model.add(layers.Dropout(0.5))
    model.add(layers.Dense(1, activation='sigmoid'))
    return model 

现在我们需要训练我们的模型,这段代码与我们之前在 MNIST 上做的非常相似。让我们来看一下:

(X_train, y_train), (X_test, y_test) = load_data()
model = build_model()
model.summary()
model.compile(optimizer = "adam", loss = "binary_crossentropy",
 metrics = ["accuracy"]
)
score = model.fit(X_train, y_train,
 epochs = EPOCHS,
 batch_size = BATCH_SIZE,
 validation_data = (X_test, y_test)
)
score = model.evaluate(X_test, y_test, batch_size=BATCH_SIZE)
print("\nTest score:", score[0])
print('Test accuracy:', score[1]) 

让我们来看一下网络,然后运行几个迭代:

___________________________________________________________________
Layer (type)                  Output Shape              Param #    
===================================================================
embedding (Embedding)         (None, 200, 256)          2560000    

dropout (Dropout)             (None, 200, 256)          0          

global_max_pooling1d (Global  (None, 256)               0          

dense (Dense)                 (None, 128)               32896      

dropout_1 (Dropout)           (None, 128)               0          

dense_1 (Dense)               (None, 1)                 129        

===================================================================
Total params: 2,593,025
Trainable params: 2,593,025
Non-trainable params: 0 

如以下输出所示,我们达到了 85%的准确率,对于一个简单的网络来说,这已经相当不错了:

Epoch 20/20
25000/25000 [==============================] - 23s 925ms/sample - loss: 0.0053 - accuracy: 0.9991 - val_loss: 0.4993 - val_accuracy: 0.8503
25000/25000 [==============================] - 2s 74us/sample - loss: 0.4993 - accuracy: 0.88503
Test score: 0.4992710727453232
Test accuracy: 0.85028 

下一部分将专门讨论超参数调优和 AutoML。

超参数调优和 AutoML

上述定义的实验为微调网络提供了一些机会。然而,适用于这个例子的做法不一定适用于其他例子。对于一个给定的神经网络,确实有多个参数可以优化(例如隐藏神经元的数量、批次大小、训练周期数等,具体取决于网络本身的复杂性)。这些参数被称为“超参数”,以区别于网络本身的参数,即权重和偏差的值。

超参数调优是寻找能够最小化成本函数的超参数最佳组合的过程。核心思想是,如果我们有n个超参数,那么我们可以想象它们定义了一个具有n个维度的空间,目标是找到这个空间中对应于成本函数最优值的点。实现这一目标的一种方式是创建一个网格,并系统地检查每个网格顶点处成本函数的值。换句话说,超参数被划分为不同的区间,并通过穷举法检查不同的组合值。

如果你认为这个微调超参数的过程是手动且昂贵的,那么你完全正确!然而,在过去的几年里,我们在 AutoML 领域看到了显著的成果,AutoML 是一套旨在自动调优超参数并自动搜索最优网络架构的研究技术。我们将在第十三章中进一步讨论这一内容,AutoML 简介

预测输出

一旦网络训练完成,当然可以用来进行预测。在 TensorFlow 中,这非常简单。我们可以使用以下方法:

# Making predictions.
predictions = model.predict(X) 

对于给定的输入,可以计算出几种类型的输出,包括用于计算损失值的model.evaluate()方法,用于计算类别输出的model.predict_classes()方法,以及用于计算类别概率的model.predict_proba()方法。

反向传播的实际概述

多层感知机通过一种叫做反向传播的过程从训练数据中学*。在这一段中,我们将给出一个直观的理解,更多细节见第十四章深度学*背后的数学。这个过程可以描述为一种在错误被检测到后逐步纠正的方式。让我们看看这个是如何运作的。

请记住,每个神经网络层都有一组权重,这些权重决定了给定输入集的输出值。此外,记住神经网络可以有多个隐藏层。

一开始,所有权重都有一些随机的赋值。然后,对于训练集中的每个输入,神经网络会被激活:值从输入阶段通过隐藏阶段传播前向到输出阶段,在输出阶段进行预测。

请注意,我们通过仅用绿色虚线表示少量值来保持图 1.27的简单性,但实际上,所有的值都会通过网络前向传播:

图 1.27:反向传播中的前向步骤

由于我们知道训练集中的真实观察值,因此可以计算出预测中的误差。反向传播的关键直觉是将误差反向传播(见图 1.28),使用适当的优化算法,如梯度下降(GD),调整神经网络的权重,目的是减少误差(为了简化,这里只表示了一些误差值):

图 1.28:反向传播中的反向步骤

从输入到输出的前向传播过程和误差的反向传播过程会重复多次,直到误差低于预设的阈值。整个过程在图 1.29中表示:

图 1.29:前向传播和反向传播

特征表示输入,而标签在这里用于驱动学*过程。模型以一种方式进行更新,使得损失函数逐步最小化。在神经网络中,真正重要的不是单个神经元的输出,而是每一层中调整的集体权重。因此,网络逐步调整其内部权重,以便使得预测增加正确预测标签的数量。当然,使用正确的特征集并拥有高质量的标签数据是最小化学*过程中偏差的基础。

到目前为止我们学到了什么?

在本章中,我们已经学会了神经网络的基础知识。更具体地说,我们学*了什么是感知器,什么是多层感知器,如何在 TensorFlow 中定义神经网络,如何在建立良好的基准后逐步改善指标,以及如何微调超参数空间。除此之外,我们还对有用的激活函数(sigmoid 和 ReLU)有了很好的了解,以及如何通过基于 GD、SGD 或更复杂的方法(如 Adam 和 RMSProp)的反向传播算法训练网络。

向深度学*方法迈进

在玩手写数字识别时,我们得出结论:当我们接* 99% 的准确率时,改进变得更加困难。如果我们希望有更多改进,显然需要一个新思路。我们缺少什么?思考一下。

基本的直觉是,在我们到目前为止的例子中,我们没有利用图像的局部空间结构,这意味着我们将使用图像可以作为具有数据局部性的矩阵来描述的事实。特别是,这段代码将表示每个手写数字的位图转换为一个平坦的向量,其中局部空间结构(即某些像素相互靠*的事实)消失了:

# X_train is 60000 rows of 28x28 values; we  --> reshape it as in 60000 x 784.
X_train = X_train.reshape(60000, 784)
X_test = X_test.reshape(10000, 784) 

然而,这并不是我们大脑的工作方式。请记住,我们的视觉是基于多个皮层层次的,每一层识别更多结构化的信息,同时仍然保留局部性。首先,我们看到单个像素,然后从这些像素中识别简单的几何形状,接着是越来越复杂的元素,如物体、面孔、人类身体、动物等。

第三章中,我们将看到一种特定类型的深度学*网络,即卷积神经网络CNN)。这种网络通过考虑图像中的局部空间结构(更广泛地说,任何具有空间结构的信息)以及通过逐步抽象层次进行学*的思想而被开发出来:通过一层只能学*简单模式;而通过多层可以学*多个模式。在讨论 CNN 之前,我们需要讨论 TensorFlow 架构的一些方面,并对一些额外的机器学*概念做一个实践性的介绍。

总结

在本章中,我们学*了什么是 TensorFlow 和 Keras,并介绍了感知器和多层感知器的神经网络。随后,我们通过几个优化方法看到了一个识别手写数字的实际例子。

下一章将专注于回归与分类。

参考文献

  1. Rosenblatt, F. (1958). 感知器:一种用于大脑信息存储和组织的概率模型。心理学评论,65 卷,第 386-408 页。

  2. Werbos, P. J. (1990). 通过时间的反向传播:它的作用及如何实现。IEEE 会议论文,78 卷,第 1550-1560 页。

  3. Hinton, G. E., Osindero, S., 和 Teh, Y. W. (2006). 深度置信网络的快速学*算法。《神经计算》,第 18 卷,第 1527–1554 页。

  4. Schmidhuber, J. (2015). 神经网络中的深度学*:概述。《神经网络:国际神经网络学会官方期刊》,第 61 卷,第 85–117 页。

  5. Leven, S. (1996). 反向传播的起源:从有序导数到神经网络和政治预测。《神经网络》,第 9 卷。

  6. Rumelhart, D. E., Hinton, G. E., 和 Williams, R. J. (1986). 通过反向传播误差学*表示。《自然》,第 323 卷。

  7. Herculano-Houzel, S. (2009). 人类大脑的数字:线性放大的灵长类动物大脑。《前沿人类神经科学》,第 3 卷。

  8. Hornick, K., Stinchcombe, M., 和 White, H. (1989). 多层前馈网络是通用逼*器。《神经网络》,第 2 卷,第 5 期,第 359–366 页。

  9. Vapnik, V. N. (2013). 统计学*理论的本质

  10. Sutskever, I., Martens, J., Dahl, G., Hinton, G. (2013). 初始化和动量在深度学*中的重要性。第 30 届国际机器学*大会,ICML。

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,与志同道合的人一起学*,和 2000 多名成员共同进步:packt.link/keras

第二章:回归与分类

回归和分类是几乎所有机器学*应用中的两大基本任务。它们在从工程学、物理科学、生物学、金融市场到社会科学等众多领域都有应用。这些任务是统计学家和数据科学家手中的基本工具。在本章中,我们将涵盖以下主题:

  • 回归

  • 分类

  • 分类与回归的区别

  • 线性回归

  • 不同类型的线性回归

  • 使用 TensorFlow Keras API 进行分类

  • 应用线性回归估算房价

  • 应用逻辑回归识别手写数字

本章的所有代码文件可以在packt.link/dltfchp2找到

让我们首先了解回归究竟是什么。

什么是回归?

回归通常是机器学*领域中人们接触的第一个算法。它通过学*给定的一组因变量和自变量之间的关系,让我们能够从数据中进行预测。回归几乎在每个领域都有应用;任何需要分析两者或更多事物之间关系的地方,都能找到回归的用武之地。

以房价估算为例。影响房价的因素有很多:房间数量、面积、地理位置、设施的可用性、停车空间等。回归分析可以帮助我们找到这些因素与房价之间的数学关系。

让我们设想一个更简单的世界,在这个世界中,只有房屋的面积决定其价格。通过回归分析,我们可以确定房屋面积(自变量:这些是不会依赖于其他变量的变量)与其价格(因变量:这些变量依赖于一个或多个自变量)之间的关系。之后,我们可以利用这个关系来预测任何房屋的价格,只要知道其面积。若想进一步了解自变量和因变量以及如何识别它们,可以参考这篇文章:medium.com/deeplearning-concepts-and-implementation/independent-and-dependent-variables-in-machine-learning-210b82f891db。在机器学*中,自变量通常作为输入提供给模型,而因变量则是从模型中输出的结果。

根据自变量的数量、因变量的数量以及关系类型,我们有许多不同类型的回归。回归的两个重要组成部分是:自变量和因变量之间的 关系,以及不同自变量对因变量的 影响强度。在接下来的部分中,我们将详细学*广泛使用的线性回归技术。

使用线性回归进行预测

线性回归 是最广为人知的建模技术之一。已有超过 200 年的历史,几乎从所有可能的角度进行了探索。线性回归假设输入变量(X)与输出变量(Y)之间存在线性关系。线性回归的基本思想是建立一个模型,利用训练数据在给定输入的情况下预测输出,使得预测输出 尽可能接*训练数据中的观测输出 Y。它涉及寻找预测值 的线性方程,形式为:

其中 n 个输入变量, 是线性系数,b 是偏差项。我们还可以将前述方程扩展为:

偏差项使我们的回归模型即使在没有任何输入的情况下也能提供输出;它为我们提供了一个将数据偏移以更好拟合的选项。对于输入样本 i,观测值(Y)和预测值 () 之间的误差为:

目标是找到最佳的系数 W 和偏差 b 的估计值,使得观测值 Y 和预测值 之间的误差最小化。让我们通过一些例子更好地理解这一点。

简单线性回归

如果我们只考虑一个自变量和一个因变量,那么我们得到的是一个简单的线性回归。考虑前一节定义的房价预测的例子;房屋的面积(A)是自变量,而房屋的价格(Y)是因变量。我们希望找到预测价格 A 之间的线性关系,形式为:

其中 b 是偏差项。因此,我们需要确定 Wb,使得价格 Y 和预测价格 之间的误差最小化。估计 Wb 的标准方法称为最小二乘法,即我们试图最小化误差平方和(S)。对于前述情况,表达式变为:

我们希望估计回归系数 Wb,使得 S 最小化。我们利用函数在极值点的导数为 0 的事实,得到这两个方程:

这两个方程可以解出两个未知数。为此,我们首先展开第二个方程中的求和:

看一下左侧最后一项,它只是将常数N加和了N次。因此,我们可以将其重新写为:

重排项后,我们得到:

右边的两个项可以被 (平均价格,输出)和 (平均面积,输入)替代,从而得到:

以类似的方式,我们展开关于权重W的* S*的偏微分方程:

代入偏置项b的表达式:

重排方程:

通过玩弄均值定义,我们可以从中得到权重W的值,如下所示:

其中 分别是平均价格和面积。让我们尝试在一些简单的样本数据上进行此操作:

  1. 我们导入必要的模块。这个是一个简单的例子,因此我们只使用 NumPy、pandas 和 Matplotlib:

    import tensorflow as tf
    import numpy as np
    import matplotlib.pyplot as plt
    import pandas as pd 
    
  2. 接下来,我们生成具有线性关系的随机数据。为了使其更具现实性,我们还添加了一个随机噪声元素。你可以看到两个变量(原因,area,和效果,price)呈正线性关系:

    #Generate a random data
    np.random.seed(0)
    area = 2.5 * np.random.randn(100) + 25
    price = 25 * area + 5 + np.random.randint(20,50, size = len(area))
    data = np.array([area, price])
    data = pd.DataFrame(data = data.T, columns=['area','price'])
    plt.scatter(data['area'], data['price'])
    plt.show() 
    

Chart, scatter chart  Description automatically generated

图 2.1:房屋面积与价格之间的散点图

  1. 现在,我们使用我们定义的方程来计算两个回归系数。你可以看到结果非常接*我们模拟的线性关系:

    W = sum(price*(area-np.mean(area))) / sum((area-np.mean(area))**2)
    b = np.mean(price) - W*np.mean(area)
    print("The regression coefficients are", W,b) 
    
    -----------------------------------------------
    The regression coefficients are 24.815544052284988 43.4989785533412 
    
  2. 现在,让我们尝试使用获得的权重和偏置值预测新的价格:

    y_pred = W * area + b 
    
  3. 接下来,我们将预测的价格与实际价格一起绘制出来。你可以看到,预测的价格与面积呈线性关系:

    plt.plot(area, y_pred, color='red',label="Predicted Price")
    plt.scatter(data['area'], data['price'], label="Training Data")
    plt.xlabel("Area")
    plt.ylabel("Price")
    plt.legend() 
    

    A close up of a map  Description automatically generated

    图 2.2:预测值与实际价格

图 2.2中,我们可以看到,预测值与实际房价趋势相同。

多元线性回归

上面的例子很简单,但在大多数问题中并非如此。在大多数问题中,因变量依赖于多个自变量。多元线性回归找到许多自变量(X)和因变量(Y)之间的线性关系,使得它们满足如下形式的预测值Y

其中 n个独立的输入变量,而 是线性系数,b是偏置项。

和之前一样,线性系数W[s]是通过最小二乘法来估计的,即最小化预测值()和观测值(Y)之间的平方差之和。因此,我们试图最小化损失函数(也叫平方误差,如果我们除以n,它就是均方误差):

其中求和是对所有训练样本进行的。

如你所猜测的那样,现在我们将不再是两个方程,而是会有n+1个方程,我们需要同时求解它们。一个更简单的替代方法是使用 TensorFlow Keras API。我们将很快学*如何使用 TensorFlow Keras API 来执行回归任务。

多元线性回归

有时候,自变量可能影响多个因变量。例如,考虑这样一个情况,我们希望预测火箭的速度和二氧化碳排放量——这两个将是我们的因变量,且它们都会受到传感器读取的燃料量、发动机类型、火箭机体等因素的影响。这就是多元线性回归的一个例子。在数学上,一个多元回归模型可以表示为:

其中。项表示第j个预测输出值,对应于第i个输入样本,w表示回归系数,x[ik]是第i个输入样本的第k个特征。需要求解的方程个数将是n x m。虽然我们可以使用矩阵来解这些方程,但这个过程计算量较大,因为它需要计算矩阵的逆和行列式。一个更简单的方法是使用梯度下降,并将最小二乘误差的和作为损失函数,使用 TensorFlow API 中包含的多种优化器之一。

在接下来的章节中,我们将深入探讨 TensorFlow Keras API,这是一种多功能的高级 API,可以轻松开发你的模型。

用于线性回归的神经网络

在前面的章节中,我们使用了数学表达式来计算线性回归方程的系数。在本节中,我们将看到如何使用神经网络来执行回归任务,并利用 TensorFlow Keras API 构建一个神经网络模型。

在使用神经网络进行回归之前,我们先回顾一下什么是神经网络。简而言之,神经网络是由许多人工神经元组成的网络。从第一章,《使用 TF 的神经网络基础》中,我们知道最简单的神经网络——(简单)感知器,可以用数学表达式表示为:

其中,f是激活函数。假设我们将f设为线性函数,那么上述表达式类似于我们在前一节中学*的线性回归的表达式。换句话说,我们可以说,神经网络(也叫做函数逼*器)是一个广义的回归器。接下来,让我们尝试使用 TensorFlow Keras API 构建一个简单的神经网络回归器。

使用 TensorFlow Keras 进行简单线性回归

在第一章中,我们学*了如何在 TensorFlow Keras 中构建模型。在这里,我们将使用相同的Sequential API 来使用Dense类构建一个单层感知机(全连接神经网络)。我们将继续使用相同的问题,也就是根据房屋的面积预测价格:

  1. 我们从导入所需的包开始。注意到在导入包时添加了Keras模块和Dense层:

    import tensorflow as tf
    import numpy as np
    import matplotlib.pyplot as plt
    import pandas as pd
    import tensorflow.keras as K
    from tensorflow.keras.layers import Dense 
    
  2. 接下来,我们生成数据,像之前的案例一样:

    #Generate a random data
    np.random.seed(0)
    area = 2.5 * np.random.randn(100) + 25
    price = 25 * area + 5 + np.random.randint(20,50, size = len(area))
    data = np.array([area, price])
    data = pd.DataFrame(data = data.T, columns=['area','price'])
    plt.scatter(data['area'], data['price'])
    plt.show() 
    
  3. 神经网络的输入应该进行标准化;这是因为输入会与权重相乘,如果我们有非常大的数值,那么乘积结果会很大,很快我们的度量可能会超出计算机能够处理的最大值(无穷大):

    data = (data - data.min()) / (data.max() - data.min())  #Normalize 
    
  4. 现在让我们构建模型;由于这是一个简单的线性回归器,我们使用一个仅有一个单元的Dense层:

    model = K.Sequential([
                          Dense(1, input_shape = [1,], activation=None)
    ])
    model.summary() 
    
    Model: "sequential"
    ____________________________________________________________
     Layer (type)           Output Shape              Param #   
    ============================================================
     dense (Dense)          (None, 1)                 2         
    
    ============================================================
    Total params: 2
    Trainable params: 2
    Non-trainable params: 0
    ____________________________________________________________ 
    
  5. 要训练一个模型,我们需要定义损失函数和优化器。损失函数定义了我们的模型试图最小化的量,而优化器决定了我们使用的最小化算法。此外,我们还可以定义度量指标,即在模型训练过程中我们希望记录的量。我们通过compile函数定义损失函数、optimizer(参见第一章神经网络基础与 TF)和度量指标:

    model.compile(loss='mean_squared_error', optimizer='sgd') 
    
  6. 现在模型已经定义好了,我们只需要使用fit函数训练它。注意我们使用了batch_size为 32,并通过fit函数的validation_split参数将数据划分为训练集和验证集:

    model.fit(x=data['area'],y=data['price'], epochs=100, batch_size=32, verbose=1, validation_split=0.2) 
    
    model.fit(x=data['area'],y=data['price'], epochs=100, batch_size=32, verbose=1, validation_split=0.2)
    Epoch 1/100
    3/3 [==============================] - 0s 78ms/step - loss: 1.2643 - val_loss: 1.4828
    Epoch 2/100
    3/3 [==============================] - 0s 13ms/step - loss: 1.0987 - val_loss: 1.3029
    Epoch 3/100
    3/3 [==============================] - 0s 13ms/step - loss: 0.9576 - val_loss: 1.1494
    Epoch 4/100
    3/3 [==============================] - 0s 16ms/step - loss: 0.8376 - val_loss: 1.0156
    Epoch 5/100
    3/3 [==============================] - 0s 15ms/step - loss: 0.7339 - val_loss: 0.8971
    Epoch 6/100
    3/3 [==============================] - 0s 16ms/step - loss: 0.6444 - val_loss: 0.7989
    Epoch 7/100
    3/3 [==============================] - 0s 14ms/step - loss: 0.5689 - val_loss: 0.7082
    .
    .
    .
    Epoch 96/100
    3/3 [==============================] - 0s 22ms/step - loss: 0.0827 - val_loss: 0.0755
    Epoch 97/100
    3/3 [==============================] - 0s 17ms/step - loss: 0.0824 - val_loss: 0.0750
    Epoch 98/100
    3/3 [==============================] - 0s 14ms/step - loss: 0.0821 - val_loss: 0.0747
    Epoch 99/100
    3/3 [==============================] - 0s 21ms/step - loss: 0.0818 - val_loss: 0.0740
    Epoch 100/100
    3/3 [==============================] - 0s 15ms/step - loss: 0.0815 - val_loss: 0.0740
    <keras.callbacks.History at 0x7f7228d6a790> 
    
  7. 好的,你已经成功地训练了一个神经网络来执行线性回归任务。训练 100 个 epoch 后的均方误差为训练数据上的 0.0815,验证数据上的 0.074。我们可以通过predict函数获得给定输入的预测值:

    y_pred = model.predict(data['area']) 
    
  8. 接下来,我们绘制预测数据与实际数据的图表:

    plt.plot(data['area'], y_pred, color='red',label="Predicted Price")
    plt.scatter(data['area'], data['price'], label="Training Data")
    plt.xlabel("Area")
    plt.ylabel("Price")
    plt.legend() 
    
  9. 图 2.3显示了预测数据与实际数据之间的图表。你可以看到,就像线性回归器一样,我们得到了一个良好的线性拟合:

Chart, scatter chart  Description automatically generated

图 2.3:预测价格与实际价格

  1. 如果你有兴趣了解系数Wb,我们可以通过打印模型的权重model.weights来查看:

    [<tf.Variable 'dense/kernel:0' shape=(1, 1) dtype=float32, numpy=array([[-0.33806288]], dtype=float32)>,
    <tf.Variable 'dense/bias:0' shape=(1,) dtype=float32, numpy=array([0.68142694], dtype=float32)>] 
    

从上面的结果中我们可以看到我们的系数是W= 0.69,偏差是b= 0.127。因此,使用线性回归,我们可以找到房价与面积之间的线性关系。在下一节中,我们将使用 TensorFlow Keras API 探索多元线性回归。

使用 TensorFlow Keras API 进行多元线性回归

前面一节中的示例只有一个自变量,即房子的面积,和一个因变量,即房子的价格。然而,现实生活中的问题并非如此简单;我们可能有多个自变量,并且可能需要预测多个因变量。正如你从多元线性回归的讨论中所意识到的那样,这些问题涉及到求解多个方程。我们可以通过使用 Keras API 来简化这两个任务。

此外,我们可以有多个神经网络层,也就是说,我们可以构建一个深度神经网络。深度神经网络就像是应用多个函数逼*器:

其中 L层的函数。从上面的表达式中,我们可以看到,如果f是线性函数,添加多层神经网络没有意义;然而,使用非线性激活函数(更多细节请参见第一章使用 TF 的神经网络基础)允许我们将神经网络应用于回归问题,在这些问题中,因变量和自变量以某种非线性方式相关。在本节中,我们将使用基于 TensorFlow Keras 构建的深度神经网络来预测一辆车的燃油效率,给定其气缸数、排量、加速度等。我们使用的数据来自 UCI ML 数据库(Blake, C., & Merz, C. (1998),UCI 机器学*数据库:www.ics.uci.edu/~mlearn/MLRepository.xhtml):

  1. 我们首先导入我们需要的模块。在前面的示例中,我们使用了 DataFrame 操作来标准化数据。在本示例中,我们将使用 Keras 的Normalization层。Normalization层将数据移到均值为零,标准差为一的位置。此外,由于我们有多个自变量,我们将使用 Seaborn 来可视化不同变量之间的关系:

    import tensorflow as tf
    import numpy as np
    import matplotlib.pyplot as plt
    import pandas as pd
    import tensorflow.keras as K
    from tensorflow.keras.layers import Dense, Normalization
    import seaborn as sns 
    
  2. 让我们首先从 UCI ML 数据库下载数据。

    url = 'https://archive.ics.uci.edu/ml/machine-learning-databases/auto-mpg/auto-mpg.data'
    column_names = ['mpg', 'cylinders', 'displacement', 'horsepower', 'weight', 'acceleration', 'model_year', 'origin']
    data = pd.read_csv(url, names=column_names, na_values='?', comment='\t', sep=' ', skipinitialspace=True) 
    
  3. 数据包括八个特征:mpg、气缸数、排量、马力、重量、加速度、模型年份和原产地。尽管车辆的原产地也可能影响燃油效率“mpg”(每加仑英里数),但我们只使用七个特征来预测 mpg 值。此外,我们删除包含 NaN 值的行:

    data = data.drop('origin', 1)
    print(data.isna().sum())
    data = data.dropna() 
    
  4. 我们将数据集分为训练集和测试集。在这里,我们将 392 个数据点的 80%作为训练数据,20%作为测试数据:

    train_dataset = data.sample(frac=0.8, random_state=0)
    test_dataset = data.drop(train_dataset.index) 
    
  5. 接下来,我们使用 Seaborn 的pairplot来可视化不同变量之间的关系:

    sns.pairplot(train_dataset[['mpg', 'cylinders', 'displacement','horsepower', 'weight', 'acceleration', 'model_year']], diag_kind='kde') 
    
  6. 我们可以看到,mpg(燃油效率)与其他所有变量都有依赖关系,而且这种依赖关系是非线性的,因为没有一条曲线是线性的:

一张包含文本、电子设备、显示器的图片 自动生成的描述

图 2.4:auto-mpg 数据中不同变量之间的关系

  1. 为了方便,我们还将变量分为输入变量和我们想要预测的标签:

    train_features = train_dataset.copy()
    test_features = test_dataset.copy() 
    train_labels = train_features.pop('mpg')
    test_labels = test_features.pop('mpg') 
    
  2. 现在,我们使用 Keras 的归一化层对数据进行归一化。请注意,尽管我们将输入归一化为均值为 0,标准差为 1 的值,但输出预测的'mpg'保持不变:

    #Normalize
    data_normalizer = Normalization(axis=1)
    data_normalizer.adapt(np.array(train_features)) 
    
  3. 我们构建了模型。模型有两个隐藏层,分别有 64 个和 32 个神经元。对于隐藏层,我们使用了修正线性单元ReLU)作为激活函数;这应该有助于逼*燃油效率与其他变量之间的非线性关系:

    model = K.Sequential([
        data_normalizer,
        Dense(64, activation='relu'),
        Dense(32, activation='relu'),
        Dense(1, activation=None)
    ])
    model.summary() 
    
  4. 之前我们使用随机梯度作为优化器,这次我们尝试使用 Adam 优化器(更多细节请参见第一章神经网络基础与 TF)。我们选择的回归损失函数仍然是均方误差:

    model.compile(optimizer='adam', loss='mean_squared_error') 
    
  5. 接下来,我们训练模型 100 个 epoch:

    history = model.fit(x=train_features,y=train_labels, epochs=100, verbose=1, validation_split=0.2) 
    
  6. 很酷,现在模型已经训练好了,我们可以通过绘制损失曲线来检查我们的模型是过拟合、欠拟合,还是拟合良好。随着训练 epoch 的增加,验证损失和训练损失彼此接*;这表明我们的模型已经得到了适当的训练:

    plt.plot(history.history['loss'], label='loss')
    plt.plot(history.history['val_loss'], label='val_loss')
    plt.xlabel('Epoch')
    plt.ylabel('Error [MPG]')
    plt.legend()
    plt.grid(True) 
    

图表,折线图 自动生成的描述

图 2.5:模型误差

  1. 最后,让我们比较预测的燃油效率与测试数据集的真实燃油效率。记住,模型之前从未见过测试数据集,因此这个预测来自于模型对输入与燃油效率之间关系的泛化能力。如果模型已经很好地学*了这个关系,二者应该形成线性关系:

    y_pred = model.predict(test_features).flatten()
    a = plt.axes(aspect='equal')
    plt.scatter(test_labels, y_pred)
    plt.xlabel('True Values [MPG]')
    plt.ylabel('Predictions [MPG]')
    lims = [0, 50]
    plt.xlim(lims)
    plt.ylim(lims)
    plt.plot(lims, lims) 
    

图表,散点图 自动生成的描述

图 2.6:预测燃油效率与实际值之间的图示

  1. 此外,我们还可以绘制预测值与真实燃油效率之间的误差:

    error = y_pred - test_labels
    plt.hist(error, bins=30)
    plt.xlabel('Prediction Error [MPG]')
    plt.ylabel('Count') 
    

图表,直方图 自动生成的描述

图 2.7:预测误差

如果我们想要进行多个预测,即处理多元回归问题,那么唯一的变化就是:在最后的全连接层中,不是一个单元,而是根据要预测的变量个数来决定单元的数量。例如,假设我们想构建一个模型,该模型考虑学生的 SAT 分数、出勤率和一些家庭参数,并预测四年本科学*的 GPA 分数;那么输出层就会有四个单元。现在你已经熟悉了回归,我们将开始讨论分类任务。

分类任务与决策边界

到目前为止,本章的重点是回归。在本节中,我们将讨论另一个重要任务:分类任务。首先让我们了解回归(有时也称为预测)和分类之间的区别:

  • 在分类中,数据被分组为不同的类别,而在回归中,目标是为给定数据预测一个连续的数值。例如,识别手写数字是一个分类任务;所有的手写数字都会属于 0 到 9 之间的某个数字。预测房价的任务则是一个回归任务,依据的是不同的输入变量。

  • 在分类任务中,模型会找到决策边界,将一个类别与另一个类别分开。而在回归任务中,模型会*似一个函数,拟合输入输出关系。

  • 分类是回归的一个子集;在分类中,我们是预测类别。回归则更为通用。

图 2.8 显示了分类与回归任务的区别。在分类中,我们需要找到一条线(或者在多维空间中是平面或超平面)来分隔不同的类别。而在回归中,目标是找到一条线(或平面或超平面),使其尽可能适合给定的输入点:

图 2.8:分类与回归

在接下来的章节中,我们将解释逻辑回归,这是一种非常常见且有用的分类技术。

逻辑回归

逻辑回归用于确定事件发生的概率。通常,事件被表示为一个类别型的因变量。事件的概率通过 sigmoid(或“logit”)函数表示:

现在的目标是估计权重 和偏置项 b。在逻辑回归中,系数是通过最大似然估计或随机梯度下降法来估计的。如果 p 是输入数据点的总数,则损失通常定义为交叉熵项,公式为:

逻辑回归用于分类问题。例如,在查看医疗数据时,我们可以使用逻辑回归来分类一个人是否患有癌症。如果输出的类别变量有两个或更多级别,我们可以使用多项式逻辑回归。另一个常用的技术是“一个对所有”的方法,适用于两个或更多输出变量。

对于多类逻辑回归,交叉熵损失函数被修改为:

其中 K 是类别的总数。你可以在 en.wikipedia.org/wiki/Logistic_regression 上阅读更多关于逻辑回归的内容。

现在你对逻辑回归有了一些了解,让我们看看如何将它应用于任何数据集。

在 MNIST 数据集上进行逻辑回归

接下来,我们将使用 TensorFlow Keras 通过逻辑回归对手写数字进行分类。我们将使用 MNIST修改后的国家标准与技术研究院)数据集。对于从事深度学*领域的人来说,MNIST 并不陌生,它就像机器学*的 ABC。它包含手写数字的图像,并为每张图像提供标签,指示它是哪个数字。标签的值介于 0 到 9 之间,取决于手写数字。因此,这是一个多类分类问题。

为了实现逻辑回归,我们将构建一个只有一个密集层的模型。每个类别将在输出中由一个单元表示,因此由于我们有 10 个类别,输出中的单元数将是 10。逻辑回归中使用的概率函数类似于 Sigmoid 激活函数;因此,我们使用 Sigmoid 激活。

让我们构建我们的模型:

  1. 第一阶段,和往常一样,导入所需的模块。请注意,在这里我们使用了 Keras API 中另一个非常有用的层——Flatten 层。Flatten 层帮助我们将 MNIST 数据集中的 28 x 28 的二维输入图像调整为一个 784 的展平数组:

    import tensorflow as tf
    import numpy as np
    import matplotlib.pyplot as plt
    import pandas as pd
    import tensorflow.keras as K
    from tensorflow.keras.layers import Dense, Flatten 
    
  2. 我们从 tensorflow.keras 数据集中获取 MNIST 输入数据:

    ((train_data, train_labels),(test_data, test_labels)) = tf.keras.datasets.mnist.load_data() 
    
  3. 接下来,我们对数据进行预处理。我们对图像进行归一化;MNIST 数据集中的图像是黑白图像,每个像素的强度值介于 0 到 255 之间。我们将其除以 255,使得现在的值介于 0 到 1 之间:

    train_data = train_data/np.float32(255)
    train_labels = train_labels.astype(np.int32)  
    test_data = test_data/np.float32(255)
    test_labels = test_labels.astype(np.int32) 
    
  4. 现在,我们定义了一个非常简单的模型;它只有一个 Dense 层,具有 10 个单元,输入的大小是 784。你可以从模型摘要的输出中看到,只有 Dense 层具有可训练的参数:

    model = K.Sequential([
        Flatten(input_shape=(28, 28)),
        Dense(10, activation='sigmoid')
    ])
    model.summary() 
    
    Model: "sequential"
    ____________________________________________________________
     Layer (type)           Output Shape              Param #   
    ============================================================
     flatten (Flatten)      (None, 784)               0         
    
     dense (Dense)          (None, 10)                7850      
    
    ============================================================
    Total params: 7,850
    Trainable params: 7,850
    Non-trainable params: 0
    ____________________________________________________________ 
    
  5. 由于测试标签是整数值,我们将使用 SparseCategoricalCrossentropy 损失,并将 logits 设置为 True。选择的优化器是 Adam。此外,我们还定义了准确度作为训练过程中要记录的指标。我们将训练模型 50 个 epoch,训练-验证拆分比例为 80:20:

    model.compile(optimizer='adam', loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True), metrics=['accuracy'])
    history = model.fit(x=train_data,y=train_labels, epochs=50, verbose=1, validation_split=0.2) 
    
  6. 让我们通过绘制损失图来看看我们简单模型的表现。你可以看到,由于验证损失和训练损失在分歧,训练损失在减少,而验证损失在增加,因此模型出现了过拟合。你可以通过添加隐藏层来提高模型性能:

    plt.plot(history.history['loss'], label='loss')
    plt.plot(history.history['val_loss'], label='val_loss')
    plt.xlabel('Epoch')
    plt.ylabel('Loss')
    plt.legend()
    plt.grid(True) 
    

Chart, line chart  Description automatically generated

图 2.9:损失图

  1. 为了更好地理解结果,我们构建了两个实用函数;这些函数帮助我们可视化手写数字及输出中 10 个单元的概率:

    def plot_image(i, predictions_array, true_label, img):
        true_label, img = true_label[i], img[i]
        plt.grid(False)
        plt.xticks([])
        plt.yticks([])
        plt.imshow(img, cmap=plt.cm.binary)
        predicted_label = np.argmax(predictions_array)
        if predicted_label == true_label:
          color ='blue'
        else:
          color ='red'
        plt.xlabel("Pred {} Conf: {:2.0f}% True ({})".format(predicted_label,
                                      100*np.max(predictions_array),
                                      true_label),
                                      color=color)
    def plot_value_array(i, predictions_array, true_label):
        true_label = true_label[i]
        plt.grid(False)
        plt.xticks(range(10))
        plt.yticks([])
        thisplot = plt.bar(range(10), predictions_array,
        color"#777777")
        plt.ylim([0, 1])
        predicted_label = np.argmax(predictions_array)
        thisplot[predicted_label].set_color('red')
        thisplot[true_label].set_color('blue') 
    
  2. 使用这些实用函数,我们绘制了预测结果:

    predictions = model.predict(test_data)
    i = 56
    plt.figure(figsize=(10,5))
    plt.subplot(1,2,1)
    plot_image(i, predictions[i], test_labels, test_data)
    plt.subplot(1,2,2)
    plot_value_array(i, predictions[i],  test_labels)
    plt.show() 
    
  3. 左侧的图像是手写数字的图像,包含预测标签、预测的置信度以及真实标签。右侧的图像显示了 10 个单元的概率(逻辑斯蒂回归)输出;我们可以看到代表数字 4 的单元具有最高的概率:

A picture containing logo  Description automatically generated

图 2.10:预测的数字及预测的置信度值

  1. 在这段代码中,为了保持逻辑回归的特点,我们使用了 Sigmoid 激活函数和仅有的一个 Dense 层。为了获得更好的性能,添加更多的密集层并使用 Softmax 作为最终激活函数将会更有帮助。例如,以下模型在验证数据集上达到了 97% 的准确率:

    better_model = K.Sequential([
        Flatten(input_shape=(28, 28)),
        Dense(128,  activation='relu'),
        Dense(10, activation='softmax')
    ])
    better_model.summary() 
    

你可以通过添加更多层,或者改变每层的神经元数量,甚至更改优化器来进行实验。这将帮助你更好地理解这些参数如何影响模型性能。

总结

本章介绍了不同类型的回归算法。我们从线性回归开始,并用它来预测简单单输入变量案例中的房价。我们使用 TensorFlow Keras API 构建了简单和多重线性回归模型。接下来,本章讨论了逻辑回归,这是一种非常重要且实用的分类任务技术。章节解释了 TensorFlow Keras API,并用它实现了线性回归和逻辑回归在一些经典数据集上的应用。下一章将向你介绍卷积神经网络,这是最成功的商业神经网络模型之一,专门用于图像数据。

参考文献

如果你有兴趣了解本章所涵盖的概念,以下是一些不错的资源:

  1. TensorFlow 官网:www.tensorflow.org/

  2. 探索双变量数值数据www.khanacademy.org/math/statistics-probability/describing-relationships-quantitative-data

  3. Murphy, K. P. (2022). 概率机器学*:导论,MIT 出版社。

  4. Blake, C., & Merz, C. (1998). UCI 机器学*数据库库: www.ics.uci.edu/~mlearn/MLRepository.xhtml

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,与志同道合的人相遇,并与 2000 多名成员一起学*: packt.link/keras

第三章:卷积神经网络

第一章使用 TF 的神经网络基础中,我们讨论了密集网络,其中每一层都与相邻的层完全连接。我们探讨了这些密集网络在分类 MNIST 手写字符数据集中的应用。在那个情境中,输入图像中的每个像素都分配给一个神经元,总共有 784 个输入神经元(28 x 28 像素)。然而,这种策略并没有利用图像之间的空间结构和关系。特别是,这段代码是一个密集网络,它将表示每个手写数字的位图转换为一个平坦的向量,移除了局部空间结构。移除空间结构是一个问题,因为重要的信息会丢失:

#X_train is 60000 rows of 28x28 values --> reshaped in 60000 x 784
X_train = X_train.reshape(60000, 784)
X_test = X_test.reshape(10000, 784) 

卷积神经网络利用空间信息,因此它们非常适合用于图像分类。这些网络采用了一种灵活的架构,灵感来自于生物学数据,这些数据来自于在视觉皮层上进行的生理实验。生物学研究表明,我们的视觉是基于多个皮层层次的,每一层都识别更复杂的信息。首先,我们看到的是单个像素,然后从这些像素中我们能识别出简单的几何形状,接着是更复杂的元素,如物体、面孔、人类身体、动物等。

卷积神经网络是一个令人着迷的课题。在短短的时间内,它们已经展示出了颠覆性的技术突破,打破了多个领域的性能记录,从文本到视频再到语音,远远超出了它们最初用于图像处理的领域。在本章中,我们将介绍卷积神经网络(也称为 CNN、DCNN 和 ConvNets)的概念,这是一种对深度学*具有重要意义的神经网络类型。

本章涵盖以下主题:

  • 深度卷积神经网络

  • 深度卷积神经网络的示例

  • 使用深度学*识别 CIFAR-10 图像

  • 用于大规模图像识别的非常深的卷积网络

  • 用于迁移学*的深度 Inception V3 网络

  • 其他 CNN 架构

  • 风格迁移

本章的所有代码文件可以在packt.link/dltfchp3找到。

让我们从深度卷积神经网络开始。

深度卷积神经网络

深度卷积神经网络DCNN)由多个神经网络层组成。通常,卷积层和池化层(即下采样)交替排列。每个滤波器的深度从网络的左到右逐渐增加。最后一阶段通常由一个或多个全连接层组成。

Typical_cnn.png

图 3.1:DCNN 的示例

卷积神经网络(ConvNets)有三个关键的基本概念:局部感受野、共享权重和池化。让我们一起回顾它们。

局部感受野

如果我们希望保留图像或其他形式数据的空间信息,则将每个图像用像素矩阵表示是很方便的。在这种情况下,编码局部结构的简单方法是将相邻输入神经元的子矩阵连接成下一层的单个隐藏神经元。那个单个隐藏神经元代表一个局部感受野。请注意,这个操作被称为卷积,这也是这种类型网络的名称来源。您可以将卷积理解为一个矩阵对另一个矩阵的处理,后者被称为核。

当然,我们可以通过具有重叠子矩阵来编码更多信息。例如,假设每个单个子矩阵的大小为 5 x 5,并且这些子矩阵与 28 x 28 像素的 MNIST 图像一起使用。然后我们将能够在隐藏层生成 24 x 24 的局部感受野神经元。实际上,在触及图像边界之前,可以仅将子矩阵滑动 23 个位置。在 TensorFlow 中,沿核的一个边的像素数是核大小,而步幅长度是卷积中每步移动核的像素数。

让我们定义从一层到另一层的特征图。当然,我们可以有多个特征图,它们可以独立地从每个隐藏层学*。例如,我们可以从处理 MNIST 图像的 28 x 28 输入神经元开始,然后在下一个隐藏层中定义大小为 24 x 24 的 k 个特征图(形状再次为 5 x 5)。

共享权重和偏置

假设我们希望摆脱原始图像中的像素表示,通过获取能够在输入图像中的任何位置独立检测相同特征的能力。一个简单的方法是在所有隐藏层的神经元中使用相同的权重和偏置。这样一来,每一层将学*从图像中导出的一组位置无关的潜在特征,需要记住,每一层由并行的一组核组成,每个核只学*一个特征。

一个数学示例

理解卷积的一个简单方法是将其视为应用于矩阵的滑动窗口函数。在以下示例中,给定输入矩阵 I 和核 K,我们得到卷积输出。3 x 3 的核 K(有时称为过滤器或特征检测器)与输入矩阵逐元素相乘,以获得输出矩阵中的一个单元。通过在 I 上滑动窗口获得其他所有单元:

| J

| 1 | 1 | 1 | 0 | 0 |

| 0 | 1 | 1 | 1 | 0 |

| 0 | 0 | 1 | 1 | 1 |

| 0 | 0 | 1 | 1 | 0 |

| 0 | 1 | 1 | 0 | 0 |

| K

| 1 | 0 | 1 |

| 0 | 1 | 0 |

| 1 | 0 | 1 |

| Convolved

| 4 | 3 | 4 |

| 2 | 4 | 3 |

| 2 | 3 | 4 |

|

在这个例子中,我们决定一旦滑动窗口碰到 I 的边界就停止滑动(因此输出是 3x3)。或者,我们也可以选择用零填充输入(这样输出就是 5x5)。这个决策与采用的填充选择有关。请注意,卷积核深度等于输入深度(通道)。

另一种选择是我们每次滑动窗口时滑动的距离,这被称为步幅(stride),可以是 1 或更大。较大的步幅会产生较少的核应用并且输出尺寸较小,而较小的步幅会产生更多的输出并保留更多信息。

滤波器的大小、步幅和填充类型是超参数,可以在训练过程中进行微调。

TensorFlow 中的 ConvNets

在 TensorFlow 中,如果我们想添加一个具有 32 个并行特征和 3x3 滤波器的卷积层,我们写:

import tensorflow as tf
from tensorflow.keras import datasets, layers, models
model = models.Sequential()
model.add(layers.Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1))) 

这意味着我们对 28x28 的图像应用 3x3 卷积,输入通道为 1(或输入滤波器),输出通道为 32(或输出滤波器)。

卷积的示例见 图 3.2

Screen Shot 2016-12-04 at 8.10.51 PM.png

图 3.2:卷积示例

池化层

假设我们希望总结特征图的输出。我们可以再次利用单个特征图产生的输出的空间连续性,将子矩阵的值聚合为一个单一的输出值,从而合成地描述该物理区域相关的“含义”。

最大池化

一个简单且常见的选择是所谓的最大池化运算符,它仅输出在该区域内观察到的最大激活值。在 Keras 中,如果我们想定义一个 2x2 的最大池化层,我们写:

model.add(layers.MaxPooling2D((2, 2))) 

最大池化操作的示例见 图 3.3

Screen Shot 2016-12-04 at 7.49.01 PM.png

图 3.3:最大池化示例

平均池化

另一种选择是平均池化,它简单地将一个区域聚合为该区域观察到的激活值的平均值。

请注意,Keras 实现了大量的池化层,完整的池化层列表可以在线查看(见keras.io/layers/pooling/)。简而言之,所有池化操作不过是对给定区域的汇总操作。

ConvNets 总结

到目前为止,我们已经描述了卷积神经网络(ConvNets)的基本概念。CNN 在一维上对音频和文本数据沿时间维度应用卷积和池化操作,在二维上对图像沿(高度 x 宽度)维度应用,在三维上对视频沿(高度 x 宽度 x 时间)维度应用。对于图像,将滤波器滑动到输入体积上会产生一个映射,提供每个空间位置上滤波器的响应。

换句话说,ConvNet 有多个滤波器堆叠在一起,能够独立于图像中的位置学*识别特定的视觉特征。这些视觉特征在网络的初始层是简单的,而在更深层的网络中变得越来越复杂。CNN 的训练需要识别每个滤波器的正确值,这样当输入通过多个层时,会激活最后一层的某些神经元,从而预测正确的值。

DCNN 的一个例子:LeNet

获得图灵奖的 Yann LeCun 提出了[1]一类名为 LeNet 的卷积神经网络(ConvNets),该网络用于识别 MNIST 手写字符,并具有对简单几何变换和畸变的鲁棒性。LeNet 的核心思想是让较低层交替进行卷积操作和最大池化操作。卷积操作基于精心选择的局部感受野,并为多个特征图共享权重。然后,高层通过传统的多层感知器(MLP)进行全连接,隐藏层使用 softmax 作为输出层。

LeNet 代码示例(TF)

要在代码中定义 LeNet,我们使用一个 2D 卷积模块(请注意,tf.keras.layers.Conv2Dtf.keras.layers.Convolution2D的别名,因此这两者可以互换使用——详见www.tensorflow.org/api_docs/python/tf/keras/layers/Conv2D):

layers.Convolution2D(20, (5, 5), activation='relu', input_shape=input_shape) 

其中,第一个参数是卷积中输出滤波器的数量,接下来的元组是每个滤波器的扩展。一个有趣的可选参数是 padding。有两个选项:padding='valid'表示卷积只在输入和滤波器完全重叠的地方计算,因此输出会小于输入,而padding='same'表示我们得到的输出与输入的大小相同,并且输入周围的区域会用零填充。

此外,我们还使用了MaxPooling2D模块:

layers.MaxPooling2D(pool_size=(2, 2), strides=(2, 2)) 

其中,pool_size=(2, 2)是一个包含 2 个整数的元组,表示图像在垂直和水平方向下缩小的因子。所以(2, 2)将在每个维度上将图像缩小一半,strides=(2, 2)是用于处理的步幅。

现在,让我们回顾一下代码。首先,我们导入一些模块:

import tensorflow as tf
from tensorflow.keras import datasets, layers, models, optimizers
# network and training
EPOCHS = 5
BATCH_SIZE = 128
VERBOSE = 1
OPTIMIZER = tf.keras.optimizers.Adam()
VALIDATION_SPLIT=0.90
IMG_ROWS, IMG_COLS = 28, 28 # input image dimensions
INPUT_SHAPE = (IMG_ROWS, IMG_COLS, 1)
NB_CLASSES = 10  # number of outputs = number of digits 

然后我们定义 LeNet 网络:

#define the convnet 
def build(input_shape, classes):
    model = models.Sequential() 

我们有一个第一个卷积阶段,使用 ReLU 激活函数,接着是最大池化。我们的网络将学* 20 个卷积滤波器,每个滤波器的大小为 5x5。输出维度与输入形状相同,因此将是 28 x 28。请注意,由于Convolutional2D是我们管道的第一个阶段,我们还需要定义其input_shape

最大池化操作实现了一个滑动窗口,窗口在层上滑动,并在每个区域中取最大值,步长为 2 个像素,垂直和水平方向都适用:

# CONV => RELU => POOL
model.add(layers.Convolution2D(20, (5, 5), activation='relu',
            input_shape=input_shape))
model.add(layers.MaxPooling2D(pool_size=(2, 2), strides=(2, 2))) 

接着是第二个卷积阶段,使用 ReLU 激活函数,之后再次是最大池化层。在这种情况下,我们将学*到的卷积滤波器数量从之前的 20 增加到 50。增加深层滤波器数量是深度学*中的常见技术:

# CONV => RELU => POOL
model.add(layers.Convolution2D(50, (5, 5), activation='relu'))
model.add(layers.MaxPooling2D(pool_size=(2, 2), strides=(2, 2))) 

然后我们进行一个标准的展平操作,接着是一个包含 500 个神经元的全连接网络,再接一个具有 10 类的 softmax 分类器:

# Flatten => RELU layers
model.add(layers.Flatten())
model.add(layers.Dense(500, activation='relu'))
# a softmax classifier
model.add(layers.Dense(classes, activation="softmax"))
return model 

恭喜,你刚刚定义了你的第一个深度卷积学*网络!让我们看看它的视觉效果:

2016-12-04 8.51.07 PM 的屏幕截图.png

图 3.4:LeNet 可视化

现在我们需要一些额外的代码来训练网络,但这与我们在第一章《使用 TF 的神经网络基础》中描述的非常相似。这次我们还展示了打印损失的代码:

# data: shuffled and split between train and test sets
(X_train, y_train), (X_test, y_test) = datasets.mnist.load_data()
# reshape
X_train = X_train.reshape((60000, 28, 28, 1))
X_test = X_test.reshape((10000, 28, 28, 1))
# normalize
X_train, X_test = X_train / 255.0, X_test / 255.0
# cast
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
# convert class vectors to binary class matrices
y_train = tf.keras.utils.to_categorical(y_train, NB_CLASSES)
y_test = tf.keras.utils.to_categorical(y_test, NB_CLASSES)
# initialize the optimizer and model
model = LeNet.build(input_shape=INPUT_SHAPE, classes=NB_CLASSES)
model.compile(loss="categorical_crossentropy", optimizer=OPTIMIZER,
    metrics=["accuracy"])
model.summary()
# use TensorBoard, princess Aurora!
callbacks = [
  # Write TensorBoard logs to './logs' directory
  tf.keras.callbacks.TensorBoard(log_dir='./logs')
]
# fit 
history = model.fit(X_train, y_train, 
        batch_size=BATCH_SIZE, epochs=EPOCHS, 
        verbose=VERBOSE, validation_split=VALIDATION_SPLIT,
        callbacks=callbacks)
score = model.evaluate(X_test, y_test, verbose=VERBOSE)
print("\nTest score:", score[0])
print('Test accuracy:', score[1]) 

现在让我们运行代码。如图 3.5所示,训练时间显著增加,每次迭代在我们的深度网络中现在需要约 28 秒,而在第一章《使用 TF 的神经网络基础》中定义的网络则只需约 1-2 秒。然而,准确率在训练集上达到了 99.991%的新高,验证集上为 99.91%,测试集上为 99.15%!

图表,折线图 说明自动生成

图 3.5:LeNet 准确率

让我们看看完整运行 20 个周期的执行情况:

Model: "sequential_1"
_____________________________________________________________________
Layer (type)                    Output Shape              Param #    
=====================================================================
conv2d_2 (Conv2D)               (None, 24, 24, 20)        520        

max_pooling2d_2 (MaxPooling  2D) (None, 12, 12, 20)       0          

conv2d_3 (Conv2D)               (None, 8, 8, 50)          25050      

max_pooling2d_3 (MaxPooling  2D) (None, 4, 4, 50)         0          

flatten   (Flatten)             (None, 800)               0          

dense   (Dense)                 (None, 500)               400500     

dense_1 (Dense)                 (None, 10)                5010    

=====================================================================
Total params: 431,080
Trainable params: 431,080
Non-trainable params: 0
_________________________________________________________________
Train on 48000 samples, validate on 12000 samples
Epoch 1/20
[2019-04-04 14:18:28.546158: I tensorflow/core/profiler/lib/profiler_session.cc:164] Profile Session started.
48000/48000 [==============================] - 28s 594us/sample - loss: 0.2035 - accuracy: 0.9398 - val_loss: 0.0739 - val_accuracy: 0.9783
Epoch 2/20
48000/48000 [==============================] - 26s 534us/sample - loss: 0.0520 - accuracy: 0.9839 - val_loss: 0.0435 - val_accuracy: 0.9868
Epoch 3/20
48000/48000 [==============================] - 27s 564us/sample - loss: 0.0343 - accuracy: 0.9893 - val_loss: 0.0365 - val_accuracy: 0.9895
Epoch 4/20
48000/48000 [==============================] - 27s 562us/sample - loss: 0.0248 - accuracy: 0.9921 - val_loss: 0.0452 - val_accuracy: 0.9868
Epoch 5/20
48000/48000 [==============================] - 27s 562us/sample - loss: 0.0195 - accuracy: 0.9939 - val_loss: 0.0428 - val_accuracy: 0.9873
Epoch 6/20
48000/48000 [==============================] - 28s 548us/sample - loss: 0.0585 - accuracy: 0.9820 - val_loss: 0.1038 - val_accuracy: 0.9685
Epoch 7/20
48000/48000 [==============================] - 26s 537us/sample - loss: 0.0134 - accuracy: 0.9955 - val_loss: 0.0388 - val_accuracy: 0.9896
Epoch 8/20
48000/48000 [==============================] - 29s 589us/sample - loss: 0.0097 - accuracy: 0.9966 - val_loss: 0.0347 - val_accuracy: 0.9899
Epoch 9/20
48000/48000 [==============================] - 29s 607us/sample - loss: 0.0091 - accuracy: 0.9971 - val_loss: 0.0515 - val_accuracy: 0.9859
Epoch 10/20
48000/48000 [==============================] - 27s 565us/sample - loss: 0.0062 - accuracy: 0.9980 - val_loss: 0.0376 - val_accuracy: 0.9904
Epoch 11/20
48000/48000 [==============================] - 30s 627us/sample - loss: 0.0068 - accuracy: 0.9976 - val_loss: 0.0366 - val_accuracy: 0.9911
Epoch 12/20
48000/48000 [==============================] - 24s 505us/sample - loss: 0.0079 - accuracy: 0.9975 - val_loss: 0.0389 - val_accuracy: 0.9910
Epoch 13/20
48000/48000 [==============================] - 28s 584us/sample - loss: 0.0057 - accuracy: 0.9978 - val_loss: 0.0531 - val_accuracy: 0.9890
Epoch 14/20
48000/48000 [==============================] - 28s 580us/sample - loss: 0.0045 - accuracy: 0.9984 - val_loss: 0.0409 - val_accuracy: 0.9911
Epoch 15/20
48000/48000 [==============================] - 26s 537us/sample - loss: 0.0039 - accuracy: 0.9986 - val_loss: 0.0436 - val_accuracy: 0.9911
Epoch 16/20
48000/48000 [==============================] - 25s 513us/sample - loss: 0.0059 - accuracy: 0.9983 - val_loss: 0.0480 - val_accuracy: 0.9890
Epoch 17/20
48000/48000 [==============================] - 24s 499us/sample - loss: 0.0042 - accuracy: 0.9988 - val_loss: 0.0535 - val_accuracy: 0.9888
Epoch 18/20
48000/48000 [==============================] - 24s 505us/sample - loss: 0.0042 - accuracy: 0.9986 - val_loss: 0.0349 - val_accuracy: 0.9926
Epoch 19/20
48000/48000 [==============================] - 29s 599us/sample - loss: 0.0052 - accuracy: 0.9984 - val_loss: 0.0377 - val_accuracy: 0.9920
Epoch 20/20
48000/48000 [==============================] - 25s 524us/sample - loss: 0.0028 - accuracy: 0.9991 - val_loss: 0.0477 - val_accuracy: 0.9917
10000/10000 [==============================] - 2s 248us/sample - loss: 0.0383 - accuracy: 0.9915
Test score: 0.03832608199457617
Test accuracy: 0.9915 

让我们绘制模型的准确率和模型损失图,并了解到,我们只需训练 10 次迭代,就能达到类似的 99.1%准确率:

Train on 48000 samples, validate on 12000 samples
Epoch 1/10
[2019-04-04 15:57:17.848186: I tensorflow/core/profiler/lib/profiler_session.cc:164] Profile Session started.
48000/48000 [==============================] - 26s 544us/sample - loss: 0.2134 - accuracy: 0.9361 - val_loss: 0.0688 - val_accuracy: 0.9783
Epoch 2/10
48000/48000 [==============================] - 30s 631us/sample - loss: 0.0550 - accuracy: 0.9831 - val_loss: 0.0533 - val_accuracy: 0.9843
Epoch 3/10
48000/48000 [==============================] - 30s 621us/sample - loss: 0.0353 - accuracy: 0.9884 - val_loss: 0.0410 - val_accuracy: 0.9874
Epoch 4/10
48000/48000 [==============================] - 37s 767us/sample - loss: 0.0276 - accuracy: 0.9910 - val_loss: 0.0381 - val_accuracy: 0.9887
Epoch 5/10
48000/48000 [==============================] - 24s 509us/sample - loss: 0.0200 - accuracy: 0.9932 - val_loss: 0.0406 - val_accuracy: 0.9881
Epoch 6/10
48000/48000 [==============================] - 31s 641us/sample - loss: 0.0161 - accuracy: 0.9950 - val_loss: 0.0423 - val_accuracy: 0.9881
Epoch 7/10
48000/48000 [==============================] - 29s 613us/sample - loss: 0.0129 - accuracy: 0.9955 - val_loss: 0.0396 - val_accuracy: 0.9894
Epoch 8/10
48000/48000 [==============================] - 27s 554us/sample - loss: 0.0107 - accuracy: 0.9965 - val_loss: 0.0454 - val_accuracy: 0.9871
Epoch 9/10
48000/48000 [==============================] - 24s 510us/sample - loss: 0.0082 - accuracy: 0.9973 - val_loss: 0.0388 - val_accuracy: 0.9902
Epoch 10/10
48000/48000 [==============================] - 26s 542us/sample - loss: 0.0083 - accuracy: 0.9970 - val_loss: 0.0440 - val_accuracy: 0.99892
10000/10000 [==============================] - 2s 196us/sample - loss: 0.0327 - accuracy: 0.9910
Test score: 0.03265062951518773
Test accuracy: 0.991 

让我们看一些 MNIST 图像,以便理解 99.1%准确率的实际效果!例如,人的写 9 的方式有很多种,其中一种在图 3.6中展示。对于 3、7、4 和 5 也一样,图中的 1 号字符难度极高,即使是人类也可能难以识别:

2016-12-04 8.19.34 PM 的屏幕截图.png

图 3.6:MNIST 手写字符示例

我们可以用以下图表总结目前不同模型的所有进展。我们的简单网络起始准确率为 90.71%,这意味着每 100 个手写字符中大约有 9 个无法正确识别。然后,使用深度学*架构我们提高了 8%的准确率,达到了 99.2%,这意味着每 100 个手写字符中少于 1 个被误识别,正如图 3.7所示:

图表,折线图,散点图 说明自动生成

图 3.7:不同模型和优化器的准确率

理解深度学*的威力

另一个我们可以运行的测试是为了更好地理解深度学*和卷积网络的威力,我们可以减少训练集的大小并观察性能的下降。一个方法是将 50,000 个训练样本分成两组:

  • 用于训练我们模型的正确训练集将逐步减少大小:5,900、3,000、1,800、600 和 300 个示例。

  • 用于估计我们的模型训练效果的验证集将由剩余的示例组成。我们的测试集始终是固定的,包含 10,000 个示例。

在此设置下,我们将前面定义的深度学*卷积神经网络与第一章中定义的第一个示例神经网络进行比较,神经网络基础与 TF。正如我们在下面的图表中所看到的,当有更多数据可用时,我们的深度网络总是优于简单网络。在有 5,900 个训练示例时,深度学*网络的准确率为 97.23%,而简单网络的准确率为 94%。

一般来说,深度网络需要更多的训练数据才能充分展现其能力,如图 3.8所示:

Chart, line chart  Description automatically generated

图 3.8:不同数据量下的准确率

有关 MNIST 的最新结果(例如,当前可用的最高性能)可以在线查阅(参见 rodrigob.github.io/are_we_there_yet/build/classification_datasets_results.xhtml)。截至 2019 年 3 月,最佳结果的错误率为 0.21% [2]。

使用深度学*识别 CIFAR-10 图像

CIFAR-10 数据集包含 60,000 张 32 x 32 像素的彩色图像,分为三个通道,并划分为 10 个类别。每个类别包含 6,000 张图像。训练集包含 50,000 张图像,而测试集提供 10,000 张图像。以下图片来自 CIFAR 数据库(参见 www.cs.toronto.edu/~kriz/cifar.xhtml),展示了来自 10 个类别的一些随机示例:

A picture containing text  Description automatically generated

图 3.9:CIFAR-10 图像示例

本节中的图像来自 Learning Multiple Layers of Features from Tiny Images,Alex Krizhevsky,2009:www.cs.toronto.edu/~kriz/learning-features-2009-TR.pdf。它们是 CIFAR-10 数据集的一部分(toronto.edu):www.cs.toronto.edu/~kriz/cifar.xhtml

目标是识别之前未见过的图像,并将其分配到十个类别中的一个。让我们定义一个合适的深度网络。

首先,我们导入一些有用的模块,定义几个常量并加载数据集(包括加载操作的完整代码可在线获取):

import tensorflow as tf
from tensorflow.keras import datasets, layers, models, optimizers
# CIFAR_10 is a set of 60K images 32x32 pixels on 3 channels
IMG_CHANNELS = 3
IMG_ROWS = 32
IMG_COLS = 32
#constant
BATCH_SIZE = 128
EPOCHS = 20
CLASSES = 10
VERBOSE = 1
VALIDATION_SPLIT = 0.2
OPTIM = tf.keras.optimizers.RMSprop() 

我们的网络将学* 32 个卷积滤波器,每个滤波器的大小为 3 x 3。输出维度与输入形状相同,因此为 32 x 32,所使用的激活函数是 ReLU 函数,这是引入非线性的一种简单方法。之后,我们有一个 MaxPooling 操作,池大小为 2 x 2,并且丢弃率为 25%:

#define the convnet 
def build(input_shape, classes):
    model = models.Sequential() 
    model.add(layers.Convolution2D(32, (3, 3), activation='relu',
                        input_shape=input_shape))
    model.add(layers.MaxPooling2D(pool_size=(2, 2)))
    model.add(layers.Dropout(0.25)) 

深度管道中的下一个阶段是一个包含 512 个单元的密集网络,并使用 ReLU 激活,随后是 50%的 dropout,最后是一个带有 10 个类别输出的 softmax 层,每个类别对应一个类别:

 model.add(layers.Flatten())
    model.add(layers.Dense(512, activation='relu'))
    model.add(layers.Dropout(0.5))
    model.add(layers.Dense(classes, activation='softmax'))
    return model 

在定义了网络之后,我们可以训练模型。在这种情况下,我们将数据进行拆分,并计算一个验证集,除了训练集和测试集外。训练集用于构建我们的模型,验证集用于选择表现最佳的方式,而测试集用于检查我们最佳模型在新数据上的表现:

# use TensorBoard, princess Aurora!
callbacks = [
  # Write TensorBoard logs to './logs' directory
  tf.keras.callbacks.TensorBoard(log_dir='./logs')
]
# train
model.compile(loss='categorical_crossentropy', optimizer=OPTIM,
    metrics=['accuracy'])

model.fit(X_train, y_train, batch_size=BATCH_SIZE,
    epochs=EPOCHS, validation_split=VALIDATION_SPLIT, 
    verbose=VERBOSE, callbacks=callbacks) 
score = model.evaluate(X_test, y_test,
                     batch_size=BATCH_SIZE, verbose=VERBOSE)
print("\nTest score:", score[0])
print('Test accuracy:', score[1]) 

让我们运行代码。我们的网络在 20 次迭代后达到了 66.8%的测试准确率。我们还打印了准确率和损失图,并使用model.summary()输出了网络的总结:

Epoch 17/20
40000/40000 [==============================] - 112s 3ms/sample - loss: 0.6282 - accuracy: 0.7841 - val_loss: 1.0296 - val_accuracy: 0.6734
Epoch 18/20
40000/40000 [==============================] - 76s 2ms/sample - loss: 0.6140 - accuracy: 0.7879 - val_loss: 1.0789 - val_accuracy: 0.6489
Epoch 19/20
40000/40000 [==============================] - 74s 2ms/sample - loss: 0.5931 - accuracy: 0.7958 - val_loss: 1.0461 - val_accuracy: 0.6811
Epoch 20/20
40000/40000 [==============================] - 71s 2ms/sample - loss: 0.5724 - accuracy: 0.8042 - val_loss: 0.1.0527 - val_accuracy: 0.6773
10000/10000 [==============================] - 5s 472us/sample - loss: 1.0423 - accuracy: 0.6686
Test score: 1.0423416819572449
Test accuracy: 0.6686 

图 3.10 显示了准确率和损失图:

图表,折线图  自动生成描述

图 3.10:定义网络的准确率和损失

我们已经看到了如何提高准确率,以及 CIFAR-10 数据集的损失如何变化。下一节将讨论如何改善当前的结果。

通过更深的网络提高 CIFAR-10 性能

提高性能的一种方法是定义一个更深的网络,使用多个卷积操作。在以下示例中,我们有一系列模块:

第 1 个模块:(CONV+CONV+MaxPool+DropOut)

第 2 个模块:(CONV+CONV+MaxPool+DropOut)

第 3 个模块:(CONV+CONV+MaxPool+DropOut)

随后是一个标准的密集输出层。所有使用的激活函数都是 ReLU 函数。还有一个新层,我们在第一章中也讨论过,神经网络基础与 TFBatchNormalization(),用于在模块之间引入一种正则化形式:

def build_model(): 
    model = models.Sequential()

    #1st block
    model.add(layers.Conv2D(32, (3,3), padding='same', 
        input_shape=x_train.shape[1:], activation='relu'))
    model.add(layers.BatchNormalization())
    model.add(layers.Conv2D(32, (3,3), padding='same', activation='relu'))
    model.add(layers.BatchNormalization())
    model.add(layers.MaxPooling2D(pool_size=(2,2)))
    model.add(layers.Dropout(0.2))
    #2nd block
    model.add(layers.Conv2D(64, (3,3), padding='same', activation='relu'))
    model.add(layers.BatchNormalization())
    model.add(layers.Conv2D(64, (3,3), padding='same', activation='relu'))
    model.add(layers.BatchNormalization())
    model.add(layers.MaxPooling2D(pool_size=(2,2)))
    model.add(layers.Dropout(0.3))
    #3d block 
    model.add(layers.Conv2D(128, (3,3), padding='same', activation='relu'))
    model.add(layers.BatchNormalization())
    model.add(layers.Conv2D(128, (3,3), padding='same', activation='relu'))
    model.add(layers.BatchNormalization())
    model.add(layers.MaxPooling2D(pool_size=(2,2)))
    model.add(layers.Dropout(0.4))
    #dense  
    model.add(layers.Flatten())
    model.add(layers.Dense(NUM_CLASSES, activation='softmax'))
    return model
    model.summary() 

恭喜!你已经定义了一个更深的网络。让我们运行代码 40 次,达到 82%的准确率!为了完整性,我们加上剩余的代码部分。第一部分是加载和标准化数据:

import tensorflow as tf
from tensorflow.keras import datasets, layers, models, regularizers, optimizers
from tensorflow.keras.preprocessing.image import ImageDataGenerator
import numpy as np

EPOCHS=50
NUM_CLASSES = 10
def load_data():
    (x_train, y_train), (x_test, y_test) = datasets.cifar10.load_data()
    x_train = x_train.astype('float32')
    x_test = x_test.astype('float32')

    #normalize 
    mean = np.mean(x_train,axis=(0,1,2,3))
    std = np.std(x_train,axis=(0,1,2,3))
    x_train = (x_train-mean)/(std+1e-7)
    x_test = (x_test-mean)/(std+1e-7)

    y_train =  tf.keras.utils.to_categorical(y_train,NUM_CLASSES)
    y_test =  tf.keras.utils.to_categorical(y_test,NUM_CLASSES)
    return x_train, y_train, x_test, y_test 

然后我们需要有一部分代码来训练网络:

(x_train, y_train, x_test, y_test) = load_data()
model = build_model()
model.compile(loss='categorical_crossentropy', 
            optimizer='RMSprop', 
            metrics=['accuracy'])
#train
batch_size = 64
model.fit(x_train, y_train, batch_size=batch_size,
    epochs=EPOCHS, validation_data=(x_test,y_test)) 
score = model.evaluate(x_test, y_test,
                     batch_size=batch_size)
print("\nTest score:", score[0])
print('Test accuracy:', score[1]) 

因此,相较于先前的简单深度网络,我们提高了 15.14%的性能。

通过数据增强提高 CIFAR-10 性能

提高性能的另一种方法是为我们的训练生成更多的图像。这里的想法是,我们可以从标准的 CIFAR 训练集开始,通过多种转换类型来增强这个集合,包括旋转、重新缩放、水平或垂直翻转、缩放、通道偏移等。让我们看看在上一节中定义的相同网络上应用的代码:

from tensorflow.keras.preprocessing.image import ImageDataGenerator
#image augmentation
datagen = ImageDataGenerator(
    rotation_range=30,
    width_shift_range=0.2,
    height_shift_range=0.2,
    horizontal_flip=True,
    )
datagen.fit(x_train) 

rotation_range是一个度数值(0-180),用于随机旋转图片;width_shiftheight_shift是随机平移图片的垂直或水平方向的范围;zoom_range用于随机缩放图片;horizontal_flip用于随机水平翻转一半图像;fill_mode是用于填充旋转或平移后可能出现的新像素的策略。

通过数据增强,我们从标准 CIFAR-10 数据集生成了更多的训练图像,如图 3.11所示:

视频游戏截图,描述自动生成,信心较中等

图 3.11:图像增强的一个示例

现在我们可以直接应用这个直觉来进行训练。使用之前定义的相同 ConvNet,我们只需生成更多的增强图像,然后进行训练。为了提高效率,生成器与模型并行运行。这使得图像增强可以在 CPU 上进行,同时在 GPU 上并行训练。以下是代码:

#train
batch_size = 64
model.fit_generator(datagen.flow(x_train, y_train, batch_size=batch_size),
                    epochs=EPOCHS,
                    verbose=1,validation_data=(x_test,y_test))
#save to disk
model_json = model.to_json()
with open('model.json', 'w') as json_file:
    json_file.write(model_json)
model.save_weights('model.h5') 
#test
scores = model.evaluate(x_test, y_test, batch_size=128, verbose=1)
print('\nTest result: %.3f loss: %.3f' % (scores[1]*100,scores[0])) 

每次迭代现在变得更昂贵,因为我们拥有更多的训练数据。因此,我们只进行 50 次迭代。我们可以看到,通过这样做,我们达到了 85.91%的准确率:

Epoch 46/50
50000/50000 [==============================] - 36s 722us/sample - loss: 0.2440 - accuracy: 0.9183 - val_loss: 0.4918 - val_accuracy: 0.8546
Epoch 47/50
50000/50000 [==============================] - 34s 685us/sample - loss: 0.2338 - accuracy: 0.9208 - val_loss: 0.4884 - val_accuracy: 0.8574
Epoch 48/50
50000/50000 [==============================] - 32s 643us/sample - loss: 0.2383 - accuracy: 0.9189 - val_loss: 0.5106 - val_accuracy: 0.8556
Epoch 49/50
50000/50000 [==============================] - 37s 734us/sample - loss: 0.2285 - accuracy: 0.9212 - val_loss: 0.5017 - val_accuracy: 0.8581
Epoch 49/50
50000/50000 [==============================] - 36s 712us/sample - loss: 0.2263 - accuracy: 0.9228 - val_loss: 0.4911 - val_accuracy: 0.8591
10000/10000 [==============================] - 2s 160us/sample - loss: 0.4911 - accuracy: 0.8591
Test score: 0.4911323667049408
Test accuracy: 0.8591 

我们实验中获得的结果总结在下图中:

图表

图 3.12:不同网络在 CIFAR-10 上的准确率。在 x 轴上,我们有递增的迭代次数

CIFAR-10 的最新结果列表可以在网上找到(请见rodrigob.github.io/are_we_there_yet/build/classification_datasets_results.xhtml)。截至 2019 年 4 月,最佳结果的准确率为 96.53% [3]。

使用 CIFAR-10 进行预测

假设我们希望使用刚刚训练的 CIFAR-10 深度学*模型来批量评估图像。由于我们已经保存了模型和权重,因此每次不需要重新训练:

import numpy as np
import scipy.misc
from tensorflow.keras.models import model_from_json
from tensorflow.keras.optimizers import SGD
#load model
model_architecture = 'cifar10_architecture.json'
model_weights = 'cifar10_weights.h5'
model = model_from_json(open(model_architecture).read())
model.load_weights(model_weights)
#load images
img_names = ['cat-standing.jpg', 'dog.jpg']
imgs = [np.transpose(scipy.misc.imresize(scipy.misc.imread(img_name), (32, 32)),
                     (2, 0, 1)).astype('float32')
           for img_name in img_names]
imgs = np.array(imgs) / 255
# train
optim = SGD()
model.compile(loss='categorical_crossentropy', optimizer=optim,
    metrics=['accuracy'])
# predict 
predictions = model.predict_classes(imgs)
print(predictions) 

注意,我们使用 SciPy 的imread来加载图像,然后将其调整为 32 × 32 像素。生成的图像张量的维度为(32, 32, 3)。然而,我们希望颜色维度位于第一位而非最后,因此我们对其进行了转置。之后,图像张量的列表被合并为一个单一张量,并归一化至 0 到 1.0 之间。

现在让我们分别获取一只 猫站立.jpg 和一只 狗.jpg 的预测。我们得到的类别是 3(猫)和 5(狗),如预期一样。我们成功地创建了一个卷积神经网络(ConvNet)来分类 CIFAR-10 图像。接下来,我们将研究 VGG16:深度学*的突破。

用于大规模图像识别的非常深的卷积神经网络

2014 年,一篇名为Very Deep Convolutional Networks for Large-Scale Image Recognition的论文提出了对图像识别的有趣贡献,由 K. Simonyan 和 A. Zisserman [4]提出。论文表明,通过将深度推到 16-19 层,可以显著改进之前的配置。论文中的一个模型标记为 D 或 VGG16,有 16 层深度。Java Caffe(参见caffe.berkeleyvision.org/)用于在 ImageNet ILSVRC-2012 上训练模型(参见image-net.org/challenges/LSVRC/2012/),包括 1000 个类别的图像,分为三组:训练(130 万张图像)、验证(50K 张图像)和测试(100K 张图像)。每张图像为(224 x 224),3 通道。该模型在 ILSVRC-2012-val 上达到 7.5%的 top-5 错误率(前 5 个结果的错误率),在 ILSVRC-2012-test 上达到 7.4%的 top-5 错误率。

根据 ImageNet 网站:

本竞赛的目标是利用大量手工标记的 ImageNet 数据集子集(包含 1000 万张标记图像,涵盖 10000 多个物体类别)进行训练,用于检索和自动注释照片内容。测试图像将不带任何初始注释 —— 没有分割或标签 —— 算法必须生成标签,指定图像中存在的对象。

模型在 Caffe 中实现的权重已经直接转换为(gist.github.com/baraldilorenzo/07d7802847aaad0a35d3tf.Keras,可以通过预加载到下面实现的tf.Keras模型中使用,正如论文所述:

import tensorflow as tf
from tensorflow.keras import layers, models
# define a VGG16 network
def VGG_16(weights_path=None):
    model = models.Sequential()
    model.add(layers.ZeroPadding2D((1,1),input_shape=(224,224, 3)))
    model.add(layers.Convolution2D(64, (3, 3), activation='relu'))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(64, (3, 3), activation='relu'))
    model.add(layers.MaxPooling2D((2,2), strides=(2,2)))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(128, (3, 3), activation='relu'))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(128, (3, 3), activation='relu'))
    model.add(layers.MaxPooling2D((2,2), strides=(2,2)))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(256, (3, 3), activation='relu'))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(256, (3, 3), activation='relu'))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(256, (3, 3), activation='relu'))
    model.add(layers.MaxPooling2D((2,2), strides=(2,2)))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(512, (3, 3), activation='relu'))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(512, (3, 3), activation='relu'))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(512, (3, 3), activation='relu'))
    model.add(layers.MaxPooling2D((2,2), strides=(2,2)))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(512, (3, 3), activation='relu'))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(512, (3, 3), activation='relu'))
    model.add(layers.ZeroPadding2D((1,1)))
    model.add(layers.Convolution2D(512, (3, 3), activation='relu'))
    model.add(layers.MaxPooling2D((2,2), strides=(2,2)))
    model.add(layers.Flatten())
    #top layer of the VGG net
    model.add(layers.Dense(4096, activation='relu'))
    model.add(layers.Dropout(0.5))
    model.add(layers.Dense(4096, activation='relu'))
    model.add(layers.Dropout(0.5))
    model.add(layers.Dense(1000, activation='softmax'))
    if weights_path:
        model.load_weights(weights_path)
    return model 

我们已经实现了一个 VGG16 网络。请注意,我们也可以直接使用tf.keras.applications.vgg16获取模型及其权重。在这里,我想展示 VGG16 是如何内部工作的。接下来,我们将利用它。

通过 VGG16 网络识别猫

现在让我们测试一个cat.jpg的图像。

请注意,我们将使用预定义的权重:

import cv2
im = cv2.resize(cv2.imread('cat.jpg'), (224, 224)).astype(np.float32)
#im = im.transpose((2,0,1))
im = np.expand_dims(im, axis=0)
# Test pretrained model
model = VGG_16('/Users/antonio/.keras/models/vgg16_weights_tf_dim_ordering_tf_kernels.h5')
model.summary()
model.compile(optimizer='sgd', loss='categorical_crossentropy')
out = model.predict(im)
print(np.argmax(out)) 

当代码执行时,返回类别285,对应于“埃及猫”(参见gist.github.com/yrevar/942d3a0ac09ec9e5eb3a):

Total params: 138,357,544
Trainable params: 138,357,544
Non-trainable params: 0
---------------------------------------------------------------
285 

令人印象深刻,不是吗?我们的 VGG16 网络可以成功识别猫的图像!这是深度学*的重要一步。距离[4]中的论文仅过去七年,但那是一个改变游戏规则的时刻。

使用内置的 tf.Keras VGG16 网络模块

tf.Keras应用程序是预构建和预训练的深度学*模型。在实例化模型时,权重将自动下载并存储在~/.keras/models/中。使用内置代码非常简单:

import tensorflow as tf
from tensorflow.keras.applications.vgg16 import VGG16
import matplotlib.pyplot as plt
import numpy as np
import cv2
# pre built model with pre-trained weights on imagenet
model = VGG16(weights='imagenet', include_top=True)
model.compile(optimizer='sgd', loss='categorical_crossentropy')
# resize into VGG16 trained images' format
im = cv2.resize(cv2.imread('steam-locomotive.jpg'), (224, 224))
im = np.expand_dims(im, axis=0)
# predict
out = model.predict(im)
index = np.argmax(out)
print(index)
plt.plot(out.ravel())
plt.show()
#this should print 820 for steaming train 

现在,让我们考虑一辆火车,steam-locomotive.jpg。如果我们运行代码,结果是 820,这是“蒸汽机车”在 ImageNet 中的代码。同样重要的是,所有其他类别的支持度非常弱,如图 3.13所示:

Chart, histogram  Description automatically generated

图 3.13:蒸汽火车是最可能的结果

结束本节时,请注意 VGG16 只是 tf.Keras 中预构建模块之一。预训练模型的完整列表可在线获取(见 www.tensorflow.org/api_docs/python/tf/keras/applications)。

回收预构建的深度学*模型以提取特征

一个非常简单的思路是使用 VGG16,通常是 DCNN,用于特征提取。此代码通过从特定层提取特征来实现这一思路。

请注意,我们需要切换到函数式 API,因为顺序模型仅接受层:

import tensorflow as tf
from tensorflow.keras.applications.vgg16 import VGG16 
from tensorflow.keras import models
from tensorflow.keras.preprocessing import image
from tensorflow.keras.applications.vgg16 import preprocess_input
import numpy as np
import cv2
# prebuild model with pre-trained weights on imagenet
base_model = VGG16(weights='imagenet', include_top=True)
print (base_model)
for i, layer in enumerate(base_model.layers):
    print (i, layer.name, layer.output_shape)
# extract features from block4_pool block
model = models.Model(inputs=base_model.input, 
    outputs=base_model.get_layer('block4_pool').output)
img_path = 'cat.jpg'
img = image.load_img(img_path, target_size=(224, 224))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)
# get the features from this block
features = model.predict(x)
print(features) 

你可能会想知道为什么我们要从 DCNN 的中间层提取特征。其理由是,当网络学*将图像分类到不同类别时,每一层都会学*识别执行最终分类所需的特征。较低层识别较低阶的特征,如颜色和边缘,而较高层则将这些低阶特征组合成更高阶的特征,如形状或物体。因此,中间层有能力从图像中提取重要特征,这些特征更有可能帮助不同种类的分类。

这有多个优点。首先,我们可以依赖公开的大规模训练,并将这种学*转移到新的领域。其次,我们可以节省在昂贵训练上的时间。第三,即使我们没有大量的领域训练样本,我们也能提供合理的解决方案。我们还可以为当前任务获得一个良好的初始网络结构,而不是盲目猜测。

有了这些,我们将结束对 VGG16 CNN 的概述,这是本章定义的最后一个深度学*模型。

用于迁移学*的深度 Inception V3

迁移学*是一种非常强大的深度学*技术,应用于多个不同领域。迁移学*的基本思想非常简单,可以通过一个类比来解释。假设你想学一门新语言,比如西班牙语。那么,从你已经掌握的其他语言(比如英语)开始可能会很有帮助。

按照这个思路,计算机视觉研究人员现在通常使用预训练的 CNN 来为新任务生成表示[1],其中数据集可能不足以从头训练一个完整的 CNN。另一种常见策略是拿到预训练的 ImageNet 网络,然后微调整个网络以适应新的任务。例如,我们可以拿一个训练来识别 10 类音乐的网络,并微调它来识别 20 类电影。

Inception V3 是一个由 Google 开发的非常深的卷积神经网络[2]。tf.Keras实现了完整的网络,如图 3.14所示,并且它已经在 ImageNet 上进行了预训练。该模型的默认输入尺寸是 299x299,且有三个通道:

图 3.14:Inception V3 深度学*模型

这个骨架示例灵感来源于一个在线可用的方案(见keras.io/applications/)。假设我们有一个来自不同领域的训练数据集 D,与 ImageNet 不同。D 的输入有 1,024 个特征,输出有 200 个类别。让我们看一个代码片段:

import tensorflow as tf
from tensorflow.keras.applications.inception_v3 import InceptionV3
from tensorflow.keras.preprocessing import image
from tensorflow.keras import layers, models
# create the base pre-trained model
base_model = InceptionV3(weights='imagenet', include_top=False) 

我们使用一个经过训练的 Inception V3 模型:我们不包括全连接层——具有 1,024 个输入的稠密层——因为我们希望在 D 上进行微调。上述代码片段将会为我们下载预训练的权重:

Downloading data from https://github.com/fchollet/deep-learning-models/releases/download/v0.5/inception_v3_weights_tf_dim_ordering_tf_kernels_notop.h5
87916544/87910968 [===========================] – 26s 0us/step 

因此,如果你查看最后四层(include_top=True时),你会看到这些形状:

# layer.name, layer.input_shape, layer.output_shape
('mixed10', [(None, 8, 8, 320), (None, 8, 8, 768), (None, 8, 8, 768), (None, 8, 8, 192)], (None, 8, 8, 2048))
('avg_pool', (None, 8, 8, 2048), (None, 1, 1, 2048))
('flatten', (None, 1, 1, 2048), (None, 2048))
('predictions', (None, 2048), (None, 1000)) 

include_top=False时,你移除了最后三层并暴露了mixed_10层。GlobalAveragePooling2D层将(None, 8, 8, 2048)转换为(None, 2048),其中(None, 2048)张量中的每个元素是对应的(8,8)子张量在(None, 8, 8, 2048)张量中的平均值。None表示未指定的维度,这对于定义占位符非常有用:

x = base_model.output
# let's add a fully-connected layer as first layer
x = layers.Dense(1024, activation='relu')(x)
# and a logistic layer with 200 classes as last layer
predictions = layers.Dense(200, activation='softmax')(x)
# model to train
model = models.Model(inputs=base_model.input, outputs=predictions) 

所有卷积层都是预训练的,因此我们在训练完整模型时冻结这些层:

# i.e. freeze all convolutional InceptionV3 layers
for layer in base_model.layers:
    layer.trainable = False 

然后,模型会被编译并训练几个周期,以便训练顶层。为了简化,这里我们省略了训练代码本身:

# compile the model (should be done *after* setting layers to non-trainable)
model.compile(optimizer='rmsprop', loss='categorical_crossentropy')
# train the model on the new data for a few epochs
model.fit_generator(...) 

然后,我们冻结顶层的 Inception 层,并微调其他 Inception 层。在这个示例中,我们决定冻结前 172 层(这是一个可调的超参数):

# we chose to train the top 2 inception blocks, i.e. we will freeze
# the first 172 layers and unfreeze the rest:
for layer in model.layers[:172]:
   layer.trainable = False
for layer in model.layers[172:]:
   layer.trainable = True 

然后,模型会重新编译以进行微调优化:

# we need to recompile the model for these modifications to take effect
# we use SGD with a low learning rate
from tensorflow.keras.optimizers import SGD
model.compile(optimizer=SGD(lr=0.0001, momentum=0.9), loss='categorical_crossentropy')
# we train our model again (this time fine-tuning the top 2 inception blocks
# alongside the top Dense layers
model.fit_generator(...) 

现在我们有了一个新的深度网络,它重用了一个标准的 Inception V3 网络,但通过迁移学*训练于新领域 D。当然,针对实现良好精度有许多微调参数。然而,我们现在通过迁移学*重新使用了一个非常大的预训练网络作为起点。这样做的好处是我们可以避免在我们的机器上进行训练,而是重用tf.Keras中已经可用的内容。

其他 CNN 架构

在本节中,我们将讨论许多其他不同的 CNN 架构,包括 AlexNet、残差网络、highwayNets、DenseNets 和 Xception。

AlexNet

最早的卷积网络之一是 AlexNet [4],它只有八层;前五层是卷积层和最大池化层,最后三层是全连接层。AlexNet [4] 是一篇被引用超过 35,000 次的文章,它开启了深度学*(尤其是计算机视觉)的革命。此后,网络变得越来越深。最*,提出了一种新思路。

残差网络

残差网络基于一个有趣的思想,即允许早期层的输出直接输入到更深的层中。这就是所谓的跳跃连接(或快进连接)。其关键思想是最小化深度网络中梯度消失或爆炸的风险(见 第八章自编码器)。

ResNet 的构建块称为“残差块”或“恒等块”,包括前向连接和快进连接。在这个示例中(图 3.15),早期层的输出与后期层的输出相加,然后传递到 ReLU 激活函数中:

自动生成的图示描述

图 3.15:图像分割示例

HighwayNets 和 DenseNets

可能会使用额外的权重矩阵来学*跳跃权重,这些模型通常被称为 HighwayNets。相反,具有多个并行跳跃的模型被称为 DenseNets [5]。有研究指出,人类大脑可能有类似于残差网络的结构,因为大脑皮层 VI 层的神经元从 I 层获取输入,跳过了中间层。此外,残差网络比传统的 CNN 更快训练,因为每次迭代时传播的层数较少(由于跳跃连接,深层输入更早到达)。图 3.16 显示了一个 DenseNet 的示例(基于 arxiv.org/abs/1608.06993):

图 3.16:DenseNet 示例

Xception

Xception 网络使用两个基本模块:深度卷积和点卷积。深度卷积是通道级的 n x n 空间卷积。假设一张图像有三个通道,那么我们就有三个 n x n 的卷积。点卷积是 1 x 1 卷积。在 Xception 中,作为 Inception 模块的“极端”版本,首先使用 1 x 1 卷积来映射跨通道的相关性,然后分别映射每个输出通道的空间相关性,如 图 3.17 所示(来自 arxiv.org/pdf/1610.02357.pdf):

自动生成的图示描述

图 3.17:Inception 模块的极端形式示例

Xception极限 Inception)是一种深度卷积神经网络架构,灵感来自 Inception,其中 Inception 模块已被深度可分离卷积所替代。Xception 使用了多个跳跃连接,方式类似于 ResNet。最终的架构相当复杂,如图 3.18所示(来自 arxiv.org/pdf/1610.02357.pdf)。数据首先通过入口流,然后通过中间流,中间流重复八次,最后通过出口流:

图形用户界面 描述自动生成,信心较高

图 3.18:完整的 Xception 架构

残差网络、HyperNets、DenseNets、Inception 和 Xception 都可以作为预训练网络,在 tf.Keras.applicationtf.Hub 中使用。Keras 网站上有一个很好的总结,展示了在 ImageNet 数据集上的表现以及每个网络的深度。总结可以在 keras.io/applications/ 上找到:

表格 描述自动生成

图 3.19:不同的 CNN 和 top-1 及 top-5 准确率结果

top-1 和 top-5 准确率指的是模型在 ImageNet 验证数据集上的表现。

本节中,我们讨论了许多 CNN 架构。下一节将介绍风格迁移,这是一种用于训练神经网络创造艺术的深度学*技术。

风格迁移

风格迁移是一个有趣的神经网络应用,提供了许多关于神经网络强大能力的见解。那么它到底是什么呢?假设你看到一幅著名艺术家的画作。从原则上讲,你观察到的是两个元素:画作本身(例如一位女性的肖像,或者一幅风景画)以及更内在的东西——艺术家的“风格”。风格是什么?这很难定义,但人们知道毕加索有他的风格,马蒂斯有他的风格,每个艺术家都有他/她的独特风格。现在,假设你拿到了一幅马蒂斯的名画,交给神经网络,让神经网络以毕加索的风格重新绘制它。或者,假设你拿到一张自己的照片,交给神经网络,让神经网络将你的照片以马蒂斯或毕加索的风格,或者以任何你喜欢的艺术家的风格来绘制。这就是风格迁移的作用。

例如,访问 deepart.io/ 查看一个酷炫的演示,如下图所示,其中 deepart 应用了“梵高”风格,灵感来自《向日葵》画作(这是一张公有领域图像:“Sonnenblumen. Arles, 1888 油画,92.5 x 73 cm Vincent van Gogh” commons.wikimedia.org/wiki/Vincent_van_Gogh#/media/File:Vincent_Van_Gogh_0010.jpg),并将其应用于我女儿 Aurora 的照片:

一位微笑的人站在一幅画旁,描述自动生成,置信度较低

图 3.20:Deepart 示例

那么,我们如何更正式地定义风格迁移的过程呢?实际上,风格迁移是生成一张人工图像 x 的任务,这张图像具有源内容图像 p 的内容和源风格图像 a 的风格。所以,从直觉上讲,我们需要两个距离函数:一个距离函数衡量两张图像内容的差异,L[content],另一个距离函数衡量两张图像风格的差异,L[style]。然后,风格迁移可以看作是一个优化问题,在这个问题中,我们尝试最小化这两个度量值。正如 Leon A. Gatys、Alexander S. Ecker 和 Matthias Bethge 在 A Neural Algorithm of Artistic Style 中所述(arxiv.org/abs/1508.06576),我们使用预训练的网络来实现风格迁移。特别是,我们可以输入一个 VGG19(或任何合适的预训练网络)来提取有效表示图像的特征。现在我们将定义两个用于训练网络的函数:内容距离和风格距离。

内容距离

给定两张图像,p 内容图像和 x 输入图像,我们定义内容距离为通过 VGG19 网络的一个层 l 在特征空间中的距离,该网络接收两张图像作为输入。换句话说,这两张图像通过预训练的 VGG19 提取的特征来表示。这些特征将图像投射到一个特征“内容”空间,在这个空间中,可以方便地计算“内容”距离,如下所示:

为了生成漂亮的图像,我们需要确保生成图像的内容与输入图像的内容相似(即,距离较小)。因此,通过标准的反向传播算法来最小化该距离。代码非常简单:

#
#content distance
#
def get_content_loss(base_content, target):
  return tf.reduce_mean(tf.square(base_content - target)) 

风格距离

如前所述,VGG19 更高层次的特征用于作为内容表示。你可以把这些特征看作是滤波器的响应。为了表示风格,我们使用 Gram 矩阵 G(定义为向量 v 的矩阵 v^T v),我们考虑 作为 VGG19 网络第 l 层中映射 i 和映射 j 的内积矩阵。可以证明,Gram 矩阵表示不同滤波器响应之间的相关性矩阵。

每一层对总风格损失的贡献定义为:

其中,是输入图像x的 Gram 矩阵,是风格图像 a 的 Gram 矩阵,N[l]是特征图的数量,每个特征图的大小为。Gram 矩阵可以将图像投影到一个空间中,在该空间内,风格得到了考虑。此外,还使用了来自多个 VGG19 层的特征相关性,因为我们希望考虑多尺度信息和更强大的风格表示。跨层的总风格损失是加权和:

因此,关键思想是对内容图像执行梯度下降,使其风格与风格图像相似。代码很简单:

#style distance
#
def gram_matrix(input_tensor):
  # image channels first 
  channels = int(input_tensor.shape[-1])
  a = tf.reshape(input_tensor, [-1, channels])
  n = tf.shape(a)[0]
  gram = tf.matmul(a, a, transpose_a=True)
  return gram / tf.cast(n, tf.float32)

def get_style_loss(base_style, gram_target):
  # height, width, num filters of each layer
  height, width, channels = base_style.get_shape().as_list()
  gram_style = gram_matrix(base_style)

  return tf.reduce_mean(tf.square(gram_style - gram_target)) 

简而言之,风格迁移背后的概念很简单:首先,我们使用 VGG19 作为特征提取器,然后定义两个合适的函数距离,一个用于风格,另一个用于内容,并进行适当的最小化。如果你想亲自尝试,可以在线找到 TensorFlow 教程。教程可以在colab.research.google.com/github/tensorflow/models/blob/master/research/nst_blogpost/4_Neural_Style_Transfer_with_Eager_Execution.ipynb找到。如果你对这个技术的演示感兴趣,可以去 deepart.io 的免费站点,他们提供风格迁移服务。

总结

在本章中,我们学*了如何使用深度学*卷积神经网络(ConvNets)来高精度地识别 MNIST 手写字符。我们使用 CIFAR-10 数据集构建了一个包含 10 个类别的深度学*分类器,并使用 ImageNet 数据集构建了一个准确的包含 1000 个类别的分类器。此外,我们还探讨了如何使用大型深度学*网络,如 VGG16,以及非常深的网络,如 Inception V3。最后,我们讨论了迁移学*的应用。

在下一章中,我们将学*如何使用词嵌入,并探讨这些技术为何对深度学*至关重要。

参考文献

  1. LeCun, Y. 和 Bengio, Y. (1995). 用于图像、语音和时间序列的卷积神经网络。大脑理论与神经网络手册,第 3361 卷。

  2. Wan. L, Zeiler M., Zhang S., Cun, Y. L., 和 Fergus R. (2014). 使用 dropconnect 的神经网络正则化第 30 届国际机器学*会议论文集,第 1058-1066 页。

  3. Graham B. (2014). 分数最大池化。arXiv 预印本,arXiv: 1412.6071。

  4. Simonyan K. 和 Zisserman A. (2014 年 9 月). 用于大规模图像识别的非常深的卷积神经网络。arXiv 电子打印。

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,与志同道合的人一起学*,和超过 2000 名成员一起进步:packt.link/keras

第四章:词嵌入

在上一章,我们讨论了卷积网络,它们在图像数据中非常成功。在接下来的几章中,我们将切换方向,专注于处理文本数据的策略和网络。

在本章中,我们将首先了解词嵌入的概念,然后介绍两个最早的实现——Word2Vec 和 GloVe。我们将学*如何使用流行的库 Gensim 在我们自己的语料库上从头开始构建词嵌入,并探索我们创建的嵌入空间。

我们还将学*如何使用预训练的第三方嵌入作为我们自己自然语言处理任务(例如垃圾邮件检测)的起点,即学*如何自动检测未经请求和不需要的电子邮件。接着,我们将了解如何利用词嵌入的概念解决无关的任务,例如为推荐物品构建嵌入空间。

然后,我们将了解自 Word2Vec 以来,过去十年间对这些基础词嵌入技术的扩展——通过 fastText 添加句法相似性,通过像 ELMo 和 Google Universal Sentence Encoder 这样的神经网络加入上下文的影响,通过 InferSent 和 skip-thoughts 这样的句子编码,此外还引入了像 ULMFiT 和 BERT 这样的语言模型。

在本章中,我们将学*以下内容:

  • 词嵌入——起源与基本原理

  • 分布式表示

  • 静态嵌入

  • 使用 Gensim 创建你自己的嵌入

  • 使用 Gensim 探索嵌入空间

  • 使用词嵌入进行垃圾邮件检测

  • 神经嵌入——不仅仅是词嵌入

  • 字符和子词嵌入

  • 动态嵌入

  • 句子和段落嵌入

  • 基于语言的模型嵌入

本章的所有代码文件可以在packt.link/dltfchp4找到。

让我们开始吧!

词嵌入 ‒ 起源与基本原理

维基百科将词嵌入定义为自然语言处理(NLP)中一组语言建模和特征学*技术的统称,其中词汇表中的词或短语被映射为实数向量。

深度学*模型和其他机器学*模型一样,通常不会直接处理文本;文本需要转换为数字。将文本转换为数字的过程称为向量化。早期的词向量化技术是独热编码,你在第一章使用 TF 的神经网络基础中学*过。正如你会回忆的那样,独热编码的一个主要问题是,它将每个词视为与其他所有词完全独立,因为任何两个词之间的相似度(通过两个词向量的点积来衡量)总是零。

点积是对两个等长向量进行的代数运算!,其结果是一个数值。它也被称为内积或标量积:

为什么两个词的独热向量的点积总是 0?考虑两个词w[i]和w[j]。假设词汇表大小为V,它们对应的独热向量是一个秩为V的零向量,其中位置ij设为 1。当使用点积操作组合时,a[i]中的 1 将与 b[i]中的 0 相乘,b[j]中的 1 将与 a[j]中的 0 相乘,且两个向量中的其他所有元素都是 0,因此结果点积也为 0。

为了克服独热编码的局限性,NLP 社区借鉴了信息检索IR)技术,通过使用文档作为上下文来将文本向量化。值得注意的技术包括词频-逆文档频率TF-IDF)[35]、潜在语义分析LSA)[36]和主题建模[37]。这些表示方法试图捕捉基于文档的词语语义相似性。在这些方法中,独热编码和 TF-IDF 是相对稀疏的嵌入,因为词汇通常很大,且一个词在语料库中出现的文档数量通常较少。

词向量技术的开发始于 2000 年左右。这些技术与以前基于 IR 的技术不同,因为它们使用邻*的词作为上下文,从而在从人类理解的角度来看产生更自然的语义相似性。今天,词嵌入已成为所有类型的 NLP 任务的基础技术,如文本分类、文档聚类、词性标注、命名实体识别、情感分析等。词嵌入产生了密集的低维向量,并且与 LSA 和主题模型一起,可以将其视为该词的潜在特征向量。

词向量基于分布假设,分布假设认为在相似上下文中出现的词语往往具有相似的意义。因此,基于词嵌入的编码方法也被称为分布式表示,我们将在接下来的部分讨论这一点。

分布式表示

分布式表示通过考虑一个词与其上下文中其他词的关系,试图捕捉该词的意义。分布假设的思想可以通过语言学家J. R. Firth的一句话来表达,他首先提出了这一思想:

你可以通过一个词汇周围的词来了解它的含义。

这是如何工作的呢?举个例子,考虑以下一对句子:

巴黎是法国的首都

柏林是德国的首都

即使假设没有世界地理知识,这对句子对也暗示了巴黎、法国、柏林和德国之间某种关系,可以表示为:

"巴黎" 对应 "法国" 就像 "柏林" 对应 "德国"。

分布式表示基于这样一种观点:存在某种变换,如下所示:

巴黎 : 法国 :: 柏林 : 德国

换句话说,分布式嵌入空间是指在相似上下文中使用的词汇彼此靠*。因此,这个空间中的词向量相似度大致对应于词汇的语义相似度。

图 4.1 显示了 TensorBoard 对“重要”这个词在嵌入空间中的词汇嵌入的可视化。从中可以看出,词汇的邻居往往是紧密相关的,或与原词可互换。

例如,“crucial”几乎是一个同义词,很容易看出在某些情况下,“historical”或“valuable”可以互换使用:

图形用户界面  描述自动生成,置信度较低

图 4.1:词嵌入数据集中“重要”一词最*邻的可视化,来源于 TensorFlow 嵌入指南(https://www.tensorflow.org/guide/embedding)

在接下来的部分,我们将探讨各种类型的分布式表示(或词嵌入)。

静态嵌入

静态嵌入是最古老的词嵌入类型。这些嵌入是基于一个大型语料库生成的,尽管词汇量很大,但仍然是有限的。你可以把静态嵌入看作一个字典,词汇是键,它们对应的向量是值。如果你有一个词汇需要查找其嵌入,但该词汇不在原始语料库中,那么你就无法找到它。此外,无论一个词如何使用,静态嵌入始终是相同的,因此静态嵌入无法解决多义词的问题,即具有多个含义的词汇。我们将在本章后面讨论非静态嵌入时进一步探讨这个问题。

Word2Vec

被称为 Word2Vec 的模型最早由 Google 的研究团队于 2013 年创建,团队由Tomas Mikolov领导[1, 2, 3]。这些模型是自监督的,也就是说,它们是依赖自然语言结构来提供标注训练数据的监督模型。

Word2Vec 的两种架构如下:

  • 连续词袋模型 (CBOW)

  • Skip-gram

图 4.2:CBOW 和 Skip-gram Word2Vec 模型的架构

在 CBOW 架构中,模型根据周围词汇的窗口预测当前词汇。上下文词汇的顺序不会影响预测(即词袋假设,因此得名)。在 Skip-gram 架构中,模型根据上下文词汇预测周围的词汇。根据 Word2Vec 网站,CBOW 速度较快,但 Skip-gram 在预测不常见词汇时表现更好。

图 4.2 总结了 CBOW 和 Skip-gram 架构。为了理解输入和输出,考虑以下示例句子:

地球每年绕太阳公转一次。

假设窗口大小为 5,也就是内容词左右各有两个上下文词,得到的上下文窗口如下所示。加粗的词是正在考虑的词,其他词是窗口中的上下文词:

[_, _, , 地球, 旅行]

[_, 这, 地球, 旅行, 围绕]

[这, 地球, 旅行, 围绕, 太阳]

[地球, 旅行, 围绕, 太阳]

[旅行, 围绕, , 太阳, 一次]

[围绕, 太阳, , 一次, 每]

[太阳, 一次, 每年]

[太阳, 一次, 每年, _]

[一次, 每年, _, _]

对于 CBOW 模型,前三个上下文窗口的输入和标签元组如下所示。在第一个示例中,CBOW 模型将学*在给定词组(“地球”,“旅行”)的情况下预测词“这”,依此类推。更准确地说,是“地球”和“旅行”这两个词的稀疏向量作为输入。模型将学*预测一个稠密向量,其最大值或概率对应于词“这”:

([地球, 旅行], )

([这, 旅行, 围绕], 地球)

([这, 地球, 围绕, 太阳], 旅行)

对于跳字模型,前三个上下文窗口对应以下输入和标签元组。我们可以简化跳字模型的目标:给定一个目标词,预测一个上下文词,基本上就是预测一对词是否在语境上相关。语境相关意味着一对词在上下文窗口中以某种方式相关。也就是说,跳字模型的输入是上下文词“这”和“地球”的稀疏向量,输出是值 1:

([, 地球], 1)

([, 旅行], 1)

([地球, 这], 1)

([地球, 旅行], 1)

([地球, 围绕], 1)

([旅行, 这], 1)

([旅行, 地球], 1)

([旅行, 围绕], 1)

([旅行, 这], 1)

我们还需要负样本来正确训练模型,因此我们通过将每个输入词与词汇表中的某个随机词配对来生成额外的负样本。这一过程称为负采样,可能会产生以下额外的输入:

([地球, 土豚], 0)

([地球, 斑马], 0)

使用所有这些输入训练的模型称为带有负采样的跳字模型SGNS)模型。

重要的是要理解,我们并不关心这些模型的分类能力;相反,我们关心的是训练的副作用——学*到的权重。这些学*到的权重就是我们所说的嵌入(embedding)。

尽管自己实现这些模型作为一种学术练*可能会很有启发,但此时 Word2Vec 已经变得如此商品化,你不太可能再需要自己动手实现。对于好奇者,你将在本章附带的源代码中的tf2_cbow_model.pytf2_cbow_skipgram.py文件中找到实现 CBOW 和跳字模型的代码。

Word2Vec 模型是由 Google 以自监督的方式训练的,使用了约 1000 亿个来自 Google 新闻数据集的单词,并包含了 300 万个单词的词汇表。然后,Google 发布了预训练模型,供任何人下载和使用。预训练的 Word2Vec 模型可以在这里下载(drive.google.com/file/d/0B7XkCwpI5KDYNlNUTTlSS21pQmM/edit)。输出向量的维度是 300。它以 BIN 文件格式提供,并且可以使用 Gensim 通过gensim.models.Word2Vec.load_word2vec_format()或使用gensim()数据下载器来打开。

另一种早期的词嵌入实现是 GloVe,我们接下来将讨论它。

GloVe

全局词向量表示GloVe)嵌入是由Jeffrey PenningtonRichard SocherChristopher Manning [4] 创建的。作者将 GloVe 描述为一种无监督学*算法,用于获取词的向量表示。训练是在从语料库中聚合的全局词-词共现统计数据上进行的,结果的表示显示了类似词之间的聚类行为,类似于 Word2Vec。

GloVe 与 Word2Vec 的区别在于,Word2Vec 是一个预测模型,而 GloVe 是一个基于计数的模型。第一步是构建一个大的(词,上下文)对的矩阵,这些对在训练语料中共现。行对应于单词,列对应于上下文,通常是一个或多个单词的序列。矩阵中的每个元素表示单词在上下文中共现的频率。

GloVe 过程将这个共现矩阵分解为一对(词,特征)和(特征,上下文)矩阵。这个过程被称为矩阵分解,使用随机梯度下降SGD)这一迭代数值方法进行。例如,假设我们要将矩阵R分解为其因子PQ

SGD 过程将从包含随机值的PQ开始,并尝试通过将它们相乘来重建矩阵R’。矩阵RR’之间的差异表示损失,通常通过计算两个矩阵之间的均方误差来得到。损失决定了PQ的值需要改变多少,以便使R’更接*R,从而最小化重建损失。这个过程会重复多次,直到损失在可接受的阈值内为止。此时,(词,特征)矩阵P就是 GloVe 嵌入。

GloVe 过程比 Word2Vec 更具资源消耗性。这是因为 Word2Vec 通过在词向量批次上训练来学*嵌入,而 GloVe 则是一次性对整个共现矩阵进行分解。为了使这个过程具有可扩展性,通常会采用 SGD 并行模式,如 HOGWILD!论文中所述 [5]。

Levy 和 Goldberg 在他们的论文 [6] 中也指出了 Word2Vec 和 GloVe 方法之间的等价性,表明 Word2Vec SGNS 模型隐式地因子分解了一个词-上下文矩阵。

与 Word2Vec 类似,你不太可能需要自己生成 GloVe 嵌入,更有可能使用预先生成的针对大型语料库的嵌入并提供下载。如果你感兴趣,你可以在附带本章节源代码下载的地方找到实现矩阵因子分解的代码 tf2_matrix_factorization.py

在各种大型语料库(标记数从 60 亿到 840 亿,词汇量从 40 万到 220 万)上训练的 GloVe 向量以及各种维度(50、100、200、300)都可以从 GloVe 项目下载页面 (nlp.stanford.edu/projects/glove/) 获得。它可以直接从该网站下载,或者使用 Gensim 或 spaCy 数据下载器下载。

使用 Gensim 创建您自己的嵌入

我们将使用 Gensim 和一个名为 text8 的小型文本语料库创建一个嵌入。

Gensim 是一个开源的 Python 库,旨在从文本文档中提取语义意义。其特点之一是优秀的 Word2Vec 算法实现,具有易于使用的 API,允许您训练和查询自己的 Word2Vec 模型。要了解更多关于 Gensim 的信息,请参阅 radimrehurek.com/gensim/index.xhtml。要安装 Gensim,请按照 radimrehurek.com/gensim/install.xhtml 上的说明进行操作。

text8 数据集是大型文本压缩基准的前 10⁸ 字节,其中包括英文维基百科的前 10⁹ 字节 [7]。text8 数据集可以作为 Gensim API 中的一个可迭代的 token 集合访问,基本上是一个标记化句子的列表。要下载 text8 语料库,创建一个 Word2Vec 模型并保存以供以后使用,请运行以下几行代码(在本章节的源代码中的 create_embedding_with_text8.py 中可用):

import gensim.downloader as api
from gensim.models import Word2Vec
dataset = api.load("text8")
model = Word2Vec(dataset)
model.save("data/text8-word2vec.bin") 

这将在 text8 数据集上训练一个 Word2Vec 模型并将其保存为二进制文件。Word2Vec 模型有许多参数,但我们将使用默认值。在这种情况下,它使用 CBOW 模型 (sg=0),窗口大小为 5 (window=5),并生成 100 维的嵌入 (size=100)。详细的参数设置请参阅 Word2Vec 文档页面 [8]。要运行此代码,请在命令行中执行以下命令:

$ mkdir data
$ python create_embedding_with_text8.py 

代码应该运行 5-10 分钟,之后将在 data 文件夹中写入一个训练好的模型。我们将在下一节中检查这个训练好的模型。

词向量在文本处理中的作用至关重要;然而,在本书写作时,TensorFlow 中并没有类似的 API 允许你以相同的抽象层次处理嵌入。因此,在本章中我们使用了 Gensim 来处理 Word2Vec 模型。在线 TensorFlow 教程包含了如何从头开始训练 Word2Vec 模型的示例(www.tensorflow.org/tutorials/text/word2vec),但这不是我们关注的重点。

使用 Gensim 探索嵌入空间

让我们重新加载刚刚构建的 Word2Vec 模型,并使用 Gensim API 进行探索。实际的词向量可以通过模型的wv属性作为自定义的 Gensim 类进行访问:

from gensim.models import KeyedVectors
model = KeyedVectors.load("data/text8-word2vec.bin")
word_vectors = model.wv 

我们可以查看词汇表中的前几个词,并检查是否可以找到特定的词:

words = word_vectors.vocab.keys()
print([x for i, x in enumerate(words) if i < 10])
assert("king" in words) 

上面的代码片段产生了以下输出:

['anarchism', 'originated', 'as', 'a', 'term', 'of', 'abuse', 'first', 'used', 'against'] 

我们可以查找与给定词(“king”)相似的词,如下所示:

def print_most_similar(word_conf_pairs, k):
   for i, (word, conf) in enumerate(word_conf_pairs):
       print("{:.3f} {:s}".format(conf, word))
       if i >= k-1:
           break
   if k < len(word_conf_pairs):
       print("...")
print_most_similar(word_vectors.most_similar("king"), 5) 

使用单个参数的most_similar()方法产生了以下输出。在这里,浮点数评分是相似度的衡量标准,较高的值优于较低的值。正如你所看到的,相似词汇看起来大多是准确的:

0.760 prince
0.701 queen
0.700 kings
0.698 emperor
0.688 throne
... 

你还可以像我们之前描述的国家-首都示例一样进行向量运算。我们的目标是验证“巴黎:法国”::“柏林:德国”是否成立。这等同于说巴黎和法国之间的嵌入空间距离应该与柏林和德国之间的距离相同。换句话说,法国 - 巴黎 + 柏林应该给出德国。在代码中,这将转化为:

print_most_similar(word_vectors.most_similar(
   positive=["france", "berlin"], negative=["paris"]), 1
) 

这将返回以下结果,正如预期的那样:

0.803 germany 

前面报告的相似度值是余弦相似度,但LevyGoldberg [9]提出了一种更好的相似度度量方法,该方法也在 Gensim API 中实现。这个度量方法本质上是计算对数尺度上的距离,从而放大较短距离之间的差异,减小较长距离之间的差异。

print_most_similar(word_vectors.most_similar_cosmul(
   positive=["france", "berlin"], negative=["paris"]), 1
) 

这也得出了预期的结果,但相似度更高:

0.984 germany 

Gensim 还提供了一个doesnt_match()函数,可以用来从一组词中检测出不同的那个词:

print(word_vectors.doesnt_match(["hindus", "parsis", "singapore", "christians"])) 

这给我们带来了singapore,正如预期的那样,因为它是识别宗教的一组词中唯一的一个国家。

我们还可以计算两个词之间的相似度。在这里,我们演示了相关词之间的距离小于不相关词之间的距离:

for word in ["woman", "dog", "whale", "tree"]:
   print("similarity({:s}, {:s}) = {:.3f}".format(
       "man", word,
       word_vectors.similarity("man", word)
   )) 

这给出了以下有趣的结果:

similarity(man, woman) = 0.759
similarity(man, dog) = 0.474
similarity(man, whale) = 0.290
similarity(man, tree) = 0.260 

similar_by_word()函数在功能上与similar()等价,唯一的区别是后者默认在比较之前会对向量进行归一化处理。还有一个相关的similar_by_vector()函数,它允许你通过指定一个向量作为输入来查找相似的词汇。这里我们尝试查找与“singapore”相似的词:

print(print_most_similar(
   word_vectors.similar_by_word("singapore"), 5)
) 

我们得到了以下输出,从地理角度来看,似乎大部分是正确的:

0.882 malaysia
0.837 indonesia
0.826 philippines
0.825 uganda
0.822 thailand
... 

我们还可以使用distance()函数计算嵌入空间中两个单词之间的距离。这个实际上就是1 - similarity()

print("distance(singapore, malaysia) = {:.3f}".format(
   word_vectors.distance("singapore", "malaysia")
)) 

我们还可以直接从word_vectors对象中查找词汇表单词的向量,或者使用以下所示的word_vec()包装器来查找:

vec_song = word_vectors["song"]
vec_song_2 = word_vectors.word_vec("song", use_norm=True) 

根据你的使用场景,可能还会有其他一些函数你会觉得有用。KeyedVectors 的文档页面列出了所有可用的函数[10]。

这里显示的代码可以在本书随附的代码中的explore_text8_embedding.py文件中找到。

使用词嵌入进行垃圾邮件检测

由于各种强大嵌入的广泛可用性,这些嵌入是从大型语料库中生成的,因此使用这些嵌入之一将文本输入转化为机器学*模型的输入变得非常普遍。文本被视为一个令牌序列。嵌入为每个令牌提供一个密集的固定维度向量。每个令牌都会被其向量替换,这样就将文本序列转换为一个示例矩阵,每个示例都有一个固定数量的特征,对应于嵌入的维度。

这个示例矩阵可以直接作为标准(非神经网络基于)机器学*程序的输入,但由于本书是关于深度学*和 TensorFlow 的,我们将展示如何使用你在第三章《卷积神经网络》中学到的卷积神经网络CNN)的一个一维版本。我们的示例是一个垃圾邮件检测器,它将短消息服务SMS)或文本消息分类为“ham”或“spam”。这个示例与我们将在第二十章《高级卷积神经网络》中介绍的情感分析示例非常相似,该示例也使用一维 CNN,但我们这里的重点将放在嵌入层上。

具体来说,我们将看到程序如何从头开始学*一个嵌入,该嵌入针对垃圾邮件检测任务进行了定制。接下来,我们将看到如何使用像本章中我们学*的外部第三方嵌入,这一过程类似于计算机视觉中的迁移学*。最后,我们将学*如何将这两种方法结合起来,从第三方嵌入开始,让网络将其作为自定义嵌入的起点,这一过程类似于计算机视觉中的微调。

像往常一样,我们将从导入开始:

import argparse
import gensim.downloader as api
import numpy as np
import os
import shutil
import tensorflow as tf
from sklearn.metrics import accuracy_score, confusion_matrix 

Scikit-learn 是一个开源的 Python 机器学*工具包,包含许多高效且易于使用的数据挖掘和数据分析工具。在本章中,我们使用了其中的两个预定义度量accuracy_scoreconfusion_matrix,来评估模型训练后的表现。

你可以在scikit-learn.org/stable/了解更多关于 scikit-learn 的信息。

获取数据

我们模型的数据是公开的,来自 UCI 机器学*库中的 SMS 垃圾短信数据集[11]。以下代码将下载该文件并解析它,生成 SMS 消息及其相应标签的列表:

def download_and_read(url):
   local_file = url.split('/')[-1]
   p = tf.keras.utils.get_file(local_file, url,
       extract=True, cache_dir=".")
   labels, texts = [], []
   local_file = os.path.join("datasets", "SMSSpamCollection")
   with open(local_file, "r") as fin:
       for line in fin:
           label, text = line.strip().split('\t')
           labels.append(1 if label == "spam" else 0)
           texts.append(text)
   return texts, labels
DATASET_URL = "https://archive.ics.uci.edu/ml/machine-learning-databases/00228/smsspamcollection.zip"
texts, labels = download_and_read(DATASET_URL) 

数据集包含 5,574 条 SMS 记录,其中 747 条标记为“垃圾短信”(spam),其余 4,827 条标记为“正常短信”(ham)。SMS 记录的文本保存在变量texts中,相应的数字标签(0 = 正常短信,1 = 垃圾短信)保存在变量labels中。

准备数据以供使用

下一步是处理数据,使其可以被网络使用。SMS 文本需要作为整数序列输入网络,其中每个单词由其在词汇表中的相应 ID 表示。我们将使用 Keras 的分词器将每条 SMS 文本转换为单词序列,然后使用fit_on_texts()方法在分词器上创建词汇表。

然后,我们使用texts_to_sequences()将 SMS 消息转换为整数序列。最后,由于网络只能处理固定长度的整数序列,我们调用pad_sequences()函数,用零填充较短的 SMS 消息。

我们数据集中最长的 SMS 消息有 189 个标记(单词)。在许多应用中,可能会有一些极长的离群序列,我们可以通过设置maxlen标志来限制长度为较小的数字。这样,超过maxlen个标记的句子将被截断,少于maxlen个标记的句子将被填充:

# tokenize and pad text
tokenizer = tf.keras.preprocessing.text.Tokenizer()
tokenizer.fit_on_texts(texts)
text_sequences = tokenizer.texts_to_sequences(texts)
text_sequences = tf.keras.preprocessing.sequence.pad_sequences(
    text_sequences)
num_records = len(text_sequences)
max_seqlen = len(text_sequences[0])
print("{:d} sentences, max length: {:d}".format(
    num_records, max_seqlen)) 

我们还将把标签转换为分类格式或独热编码格式,因为我们希望选择的损失函数(分类交叉熵)要求标签采用这种格式:

# labels
NUM_CLASSES = 2
cat_labels = tf.keras.utils.to_categorical(
    labels, num_classes=NUM_CLASSES) 

分词器允许访问通过word_index属性创建的词汇表,该属性基本上是一个词汇单词及其在词汇表中索引位置的字典。我们还构建了反向索引,使我们能够从索引位置找到相应的单词。此外,我们为PAD字符创建了条目:

# vocabulary
word2idx = tokenizer.word_index
idx2word = {v:k for k, v in word2idx.items()}
word2idx["PAD"] = 0
idx2word[0] = "PAD"
vocab_size = len(word2idx)
print("vocab size: {:d}".format(vocab_size)) 

最后,我们创建了网络将使用的dataset对象。dataset对象允许我们声明性地设置一些属性,比如批处理大小。在这里,我们从填充后的整数序列和分类标签中构建数据集,打乱数据,并将其拆分为训练集、验证集和测试集。最后,我们为这三个数据集设置了批处理大小:

# dataset
dataset = tf.data.Dataset.from_tensor_slices(
    (text_sequences, cat_labels))
dataset = dataset.shuffle(10000)
test_size = num_records // 4
val_size = (num_records - test_size) // 10
test_dataset = dataset.take(test_size)
val_dataset = dataset.skip(test_size).take(val_size)
train_dataset = dataset.skip(test_size + val_size)
BATCH_SIZE = 128
test_dataset = test_dataset.batch(BATCH_SIZE, drop_remainder=True)
val_dataset = val_dataset.batch(BATCH_SIZE, drop_remainder=True)
train_dataset = train_dataset.batch(BATCH_SIZE, drop_remainder=True) 

构建嵌入矩阵

Gensim 工具包提供了对各种训练好的嵌入模型的访问,您可以通过在 Python 提示符下运行以下命令来查看:

>>> import gensim.downloader as api
>>> api.info("models").keys() 

这将返回(在本书撰写时)以下训练好的词嵌入:

  • Word2Vec:有两种版本,一个是基于 Google 新闻训练的(包含 300 万个词向量,基于 30 亿个标记),另一个是基于俄语语料库训练的(word2vec-ruscorpora-300,word2vec-google-news-300)。

  • GloVe:有两种版本,一种在 Gigawords 语料库上训练(基于 60 亿标记的 40 万个词向量),提供 50d、100d、200d 和 300d 向量,另一种在 Twitter 上训练(基于 270 亿标记的 120 万个词向量),提供 25d、50d、100d 和 200d 向量(glove-wiki-gigaword-50,glove-wiki-gigaword-100,glove-wiki-gigaword-200,glove-wiki-gigaword-300,glove-twitter-25,glove-twitter-50,glove-twitter-100,glove-twitter-200)。较小的嵌入尺寸会导致输入的更大压缩,从而产生更大的*似度。

  • fastText:使用子词信息在 2017 年维基百科、UMBC 网络语料库和 statmt.org 新闻数据集(16B 个标记)上训练的一百万个词向量(fastText-wiki-news-subwords-300)。

  • ConceptNet Numberbatch:一种集成嵌入,使用 ConceptNet 语义网络、释义数据库PPDB)、Word2Vec 和 GloVe 作为输入。生成 600d 向量[12, 13]。

对于我们的示例,我们选择了基于 Gigaword 语料库训练的 300d GloVe 嵌入。

为了保持我们的模型小,我们只考虑词汇表中存在的词的嵌入。这是通过以下代码完成的,该代码为词汇表中的每个词创建一个较小的嵌入矩阵。矩阵中的每一行对应一个词,行本身就是对应该词的嵌入向量:

def build_embedding_matrix(sequences, word2idx, embedding_dim,
       embedding_file):
   if os.path.exists(embedding_file):
       E = np.load(embedding_file)
   else:
       vocab_size = len(word2idx)
       E = np.zeros((vocab_size, embedding_dim))
       word_vectors = api.load(EMBEDDING_MODEL)
       for word, idx in word2idx.items():
           try:
               E[idx] = word_vectors.word_vec(word)
           except KeyError:   # word not in embedding
               pass
       np.save(embedding_file, E)
   return E
EMBEDDING_DIM = 300
DATA_DIR = "data"
EMBEDDING_NUMPY_FILE = os.path.join(DATA_DIR, "E.npy")
EMBEDDING_MODEL = "glove-wiki-gigaword-300"
E = build_embedding_matrix(text_sequences, word2idx, 
   EMBEDDING_DIM,
   EMBEDDING_NUMPY_FILE)
print("Embedding matrix:", E.shape) 

嵌入矩阵的输出形状是(9010,300),对应词汇表中的 9,010 个标记,以及第三方 GloVe 嵌入中的 300 个特征。

定义垃圾邮件分类器

我们现在可以定义我们的分类器了。我们将使用一维卷积神经网络或 ConvNet1D CNN),这与您在第三章卷积神经网络中已经看到的网络类似。

输入是一个整数序列。第一层是一个嵌入层,将每个输入整数转换为大小为(embedding_dim)的向量。根据运行模式(即是否从零开始学*嵌入、进行迁移学*或微调),网络中的嵌入层会略有不同。当网络以随机初始化的嵌入权重(run_mode == "scratch")开始并在训练过程中学*权重时,我们将trainable参数设置为True。在迁移学*的情况下(run_mode == "vectorizer"),我们从嵌入矩阵E中设置权重,但将trainable参数设置为False,这样它就不会训练。在微调的情况下(run_mode == "finetuning"),我们从外部矩阵E设置嵌入权重,并将该层设置为可训练。

嵌入层的输出被输入到一个卷积层中。这里,固定大小为 3 个标记宽度的 1D 窗口(kernel_size=3),也称为时间步,被卷积运算与 256 个随机滤波器(num_filters=256)进行运算,从而为每个时间步生成大小为 256 的向量。因此,输出向量的形状是(batch_sizetime_stepsnum_filters)。

卷积层的输出被送入一个 1D 空间丢弃层。空间丢弃将随机丢弃卷积层输出的整个特征图。这是一种正则化技术,用于防止过拟合。然后,它会经过一个全局最大池化层,该层从每个时间步的每个滤波器中提取最大值,生成形状为(batch_sizenum_filters)的向量。

丢弃层的输出被输入到池化层进行扁平化,然后进入一个全连接层,该层将形状为(batch_sizenum_filters)的向量转换为(batch_sizenum_classes)。Softmax 激活函数将(垃圾短信、正常短信)的每个分数转换为概率分布,表示输入的短信是垃圾短信或正常短信的概率:

class SpamClassifierModel(tf.keras.Model):
   def __init__(self, vocab_sz, embed_sz, input_length,
           num_filters, kernel_sz, output_sz,
           run_mode, embedding_weights,
           **kwargs):
       super(SpamClassifierModel, self).__init__(**kwargs)
       if run_mode == "scratch":
           self.embedding = tf.keras.layers.Embedding(vocab_sz,
               embed_sz,
               input_length=input_length,
               trainable=True)
       elif run_mode == "vectorizer":
           self.embedding = tf.keras.layers.Embedding(vocab_sz,
               embed_sz,
               input_length=input_length,
               weights=[embedding_weights],
               trainable=False)
       else:
           self.embedding = tf.keras.layers.Embedding(vocab_sz,
               embed_sz,
               input_length=input_length,
               weights=[embedding_weights],
               trainable=True)
       self.conv = tf.keras.layers.Conv1D(filters=num_filters,
           kernel_size=kernel_sz,
           activation="relu")
       self.dropout = tf.keras.layers.SpatialDropout1D(0.2)
       self.pool = tf.keras.layers.GlobalMaxPooling1D()
       self.dense = tf.keras.layers.Dense(output_sz,
           activation="softmax")
   def call(self, x):
       x = self.embedding(x)
       x = self.conv(x)
       x = self.dropout(x)
       x = self.pool(x)
       x = self.dense(x)
       return x
# model definition
conv_num_filters = 256
conv_kernel_size = 3
model = SpamClassifierModel(
   vocab_size, EMBEDDING_DIM, max_seqlen,
   conv_num_filters, conv_kernel_size, NUM_CLASSES,
   run_mode, E)
model.build(input_shape=(None, max_seqlen)) 

最后,我们使用分类交叉熵损失函数和 Adam 优化器来编译模型:

# compile
model.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"]) 

训练和评估模型

需要注意的一点是,数据集在某种程度上是不平衡的;垃圾短信只有 747 个实例,而正常短信有 4,827 个实例。网络仅通过总是预测多数类即可实现接* 87%的准确率。为了解决这个问题,我们设置了类别权重,表示垃圾短信的错误代价是正常短信错误的八倍。这由CLASS_WEIGHTS变量表示,并作为额外参数传递给model.fit()调用。

训练 3 个周期后,我们在测试集上评估模型,并报告模型在测试集上的准确率和混淆矩阵。然而,对于不平衡数据,即使使用了类别权重,模型也可能会学*到始终预测多数类。因此,通常建议按类别报告准确率,以确保模型能够有效地区分每个类别。这可以通过使用混淆矩阵来轻松完成,方法是将每行的对角元素除以该行所有元素的和,其中每行对应一个标记类别:

NUM_EPOCHS = 3
# data distribution is 4827 ham and 747 spam (total 5574), which
# works out to approx 87% ham and 13% spam, so we take reciprocals
# and this works out to being each spam (1) item as being 
# approximately 8 times as important as each ham (0) message.
CLASS_WEIGHTS = { 0: 1, 1: 8 }
# train model
model.fit(train_dataset, epochs=NUM_EPOCHS,
   validation_data=val_dataset,
   class_weight=CLASS_WEIGHTS)
# evaluate against test set
labels, predictions = [], []
for Xtest, Ytest in test_dataset:
   Ytest_ = model.predict_on_batch(Xtest)
   ytest = np.argmax(Ytest, axis=1)
   ytest_ = np.argmax(Ytest_, axis=1)
   labels.extend(ytest.tolist())
   predictions.extend(ytest.tolist())
print("test accuracy: {:.3f}".format(accuracy_score(labels, predictions)))
print("confusion matrix")
print(confusion_matrix(labels, predictions)) 

运行垃圾短信检测器

我们要查看的三个场景是:

  • 让网络为任务学*嵌入。

  • 从固定的外部第三方嵌入开始,其中嵌入矩阵被视为一个向量化器,用于将整数序列转换为向量序列。

  • 从外部第三方嵌入开始,在训练过程中进一步微调到任务中。

每种场景可以通过设置mode参数的值来评估,具体如以下命令所示:

$ python spam_classifier --mode [scratch|vectorizer|finetune] 

数据集较小,模型也比较简单。我们通过仅进行少量训练(3 轮),就能取得非常好的结果(验证集准确率接* 100%,测试集准确率完美)。在这三种情况下,网络都取得了完美的成绩,准确预测了 1,111 条正常消息,以及 169 条垃圾邮件。

图 4.3 中显示的验证准确率变化,展示了三种方法之间的差异:

图 4.3:不同嵌入技术在训练周期中的验证准确率对比

在从零开始学*的情况下,第一轮结束时,验证准确率为 0.93,但在接下来的两个轮次中,它上升到 0.98。在向量化器的情况下,网络从第三方嵌入中获得了一定的起步优势,并在第一轮结束时达到了接* 0.95 的验证准确率。然而,由于嵌入权重不允许改变,因此无法将嵌入自定义为垃圾邮件检测任务,第三轮结束时的验证准确率是三者中最低的。微调的情况和向量化器一样,也获得了起步优势,但能够根据任务定制嵌入,因此能够以三者中最快的速度进行学*。微调的情况在第一轮结束时具有最高的验证准确率,并且在第二轮结束时达到了从零开始学*的情况在第三轮结束时所达到的相同验证准确率。

在接下来的章节中,我们将看到分布式相似性不仅限于单词嵌入,它也适用于其他场景。

神经嵌入 – 不仅仅是针对单词

自 Word2Vec 和 GloVe 以来,词嵌入技术在多个方向上得到了发展。其中一个方向是将词嵌入应用于非单词场景,也就是我们所说的神经嵌入。正如你回忆的那样,词嵌入利用了分布假设,即在相似语境中出现的单词通常具有相似的含义,其中语境通常是围绕目标单词的固定大小(按单词数量计算)窗口。

神经嵌入的理念非常相似;即在相似语境中出现的实体往往彼此密切相关。这些语境的构建方式通常取决于具体情况。我们将在这里描述两种技术,这些技术是基础且足够通用的,可以轻松应用于各种用例。

Item2Vec

Item2Vec 嵌入模型最初由 Barkan 和 Koenigstein [14] 提出,适用于协同过滤用例,即基于其他具有相似购买历史的用户购买的商品来向用户推荐商品。它将网上商店中的商品作为“单词”,将商品集(即用户随时间购买的商品序列)作为“句子”,从中提取“单词语境”。

例如,考虑一个向超市购物者推荐商品的问题。假设我们的超市销售 5000 种商品,因此每个商品可以表示为一个大小为 5000 的稀疏独热编码向量。每个用户由他们的购物车表示,购物车是这样的一系列向量。应用类似我们在 Word2Vec 部分看到的上下文窗口,我们可以训练一个 skip-gram 模型来预测可能的商品对。学*到的嵌入模型将商品映射到一个稠密的低维空间,在这个空间中,类似的商品会聚集在一起,这可以用于进行相似商品推荐。

node2vec

node2vec 嵌入模型由 Grover 和 Leskovec [15] 提出,作为一种可扩展的方式,用于学*图中节点的特征。它通过在图上执行大量固定长度的随机游走来学*图结构的嵌入。节点是“单词”,随机游走是从中派生出“单词上下文”的“句子”。

Something2Vec 页面 [40] 提供了一个全面的列表,列出了研究人员如何尝试将分布假设应用于除了单词以外的其他实体。希望这个列表能激发你为自己的“Something2Vec”表示法的创意。

为了说明创建你自己的神经嵌入是多么简单,我们将生成一个类似 node2vec 的模型,或者更准确地说,是一个前身基于图的嵌入,叫做 DeepWalk,提出者为 Perozzi 等人 [42],用于 1987-2015 年间在 NeurIPS 会议上发表的论文,通过利用它们之间的词共现关系。

数据集是一个 11,463 × 5,812 的词频矩阵,其中行表示单词,列表示会议论文。我们将使用此数据构建论文的图,其中两篇论文之间的边表示它们都出现的单词。node2vec 和 DeepWalk 假设图是无向且无权的。我们的图是无向的,因为两篇论文之间的关系是双向的。然而,我们的边可以根据两篇文档之间的词共现次数来加权。对于我们的示例,我们将任何超过 0 的共现次数视为有效的无权边。

像往常一样,我们将从声明我们的导入开始:

import gensim
import logging
import numpy as np
import os
import shutil
import tensorflow as tf
from scipy.sparse import csr_matrix
from sklearn.metrics.pairwise import cosine_similarity
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO) 

下一步是从 UCI 仓库下载数据,并将其转换为稀疏的词项-文档矩阵(TD),然后通过将词项-文档矩阵的转置与其自身相乘来构建文档-文档矩阵 E。我们的图是通过文档-文档矩阵表示为邻接矩阵或边矩阵。由于每个元素表示两个文档之间的相似度,我们将通过将任何非零元素设置为 1 来二值化矩阵 E

DATA_DIR = "./data"
UCI_DATA_URL = "https://archive.ics.uci.edu/ml/machine-learning-databases/00371/NIPS_1987-2015.csv"
def download_and_read(url):
   local_file = url.split('/')[-1]
   p = tf.keras.utils.get_file(local_file, url, cache_dir=".")
   row_ids, col_ids, data = [], [], []
   rid = 0
   f = open(p, "r")
   for line in f:
       line = line.strip()
       if line.startswith("\"\","):
           # header
           continue
       # compute non-zero elements for current row
       counts = np.array([int(x) for x in line.split(',')[1:]])
       nz_col_ids = np.nonzero(counts)[0]
       nz_data = counts[nz_col_ids]
       nz_row_ids = np.repeat(rid, len(nz_col_ids))
       rid += 1
       # add data to big lists
       row_ids.extend(nz_row_ids.tolist())
       col_ids.extend(nz_col_ids.tolist())
       data.extend(nz_data.tolist())
   f.close()
   TD = csr_matrix((
       np.array(data), (
           np.array(row_ids), np.array(col_ids)
           )
       ),
       shape=(rid, counts.shape[0]))
   return TD
# read data and convert to Term-Document matrix
TD = download_and_read(UCI_DATA_URL)
# compute undirected, unweighted edge matrix
E = TD.T * TD
# binarize
E[E > 0] = 1 

一旦我们拥有了稀疏二值化邻接矩阵E,我们就可以从每个顶点生成随机游走。从每个节点开始,我们构造了 32 个最大长度为 40 个节点的随机游走。每次游走有 0.15 的随机重启概率,这意味着对于任何节点,特定的随机游走有 15%的概率会结束。以下代码将构造随机游走,并将它们写入由RANDOM_WALKS_FILE指定的文件。为了给出输入的示例,我们提供了该文件前 10 行的快照,展示了从节点 0 开始的随机游走:

0 1405 4845 754 4391 3524 4282 2357 3922 1667
0 1341 456 495 1647 4200 5379 473 2311
0 3422 3455 118 4527 2304 772 3659 2852 4515 5135 3439 1273
0 906 3498 2286 4755 2567 2632
0 5769 638 3574 79 2825 3532 2363 360 1443 4789 229 4515 3014 3683 2967 5206 2288 1615 1166
0 2469 1353 5596 2207 4065 3100
0 2236 1464 1596 2554 4021
0 4688 864 3684 4542 3647 2859
0 4884 4590 5386 621 4947 2784 1309 4958 3314
0 5546 200 3964 1817 845 

请注意,这是一个非常缓慢的过程。输出的副本随本章的源代码一起提供,以防你希望跳过随机游走生成过程:

NUM_WALKS_PER_VERTEX = 32
MAX_PATH_LENGTH = 40
RESTART_PROB = 0.15
RANDOM_WALKS_FILE = os.path.join(DATA_DIR, "random-walks.txt")
def construct_random_walks(E, n, alpha, l, ofile):
   if os.path.exists(ofile):
       print("random walks generated already, skipping")
       return
   f = open(ofile, "w")
   for i in range(E.shape[0]):  # for each vertex
       if i % 100 == 0:
           print("{:d} random walks generated from {:d} vertices"
               .format(n * i, i))
       for j in range(n):       # construct n random walks
           curr = i
           walk = [curr]
           target_nodes = np.nonzero(E[curr])[1]
           for k in range(l):   # each of max length l
               # should we restart?
               if np.random.random() < alpha and len(walk) > 5:
                   break
               # choose one outgoing edge and append to walk
               try:
                   curr = np.random.choice(target_nodes)
                   walk.append(curr)
                   target_nodes = np.nonzero(E[curr])[1]
               except ValueError:
                   continue
           f.write("{:s}\n".format(" ".join([str(x) for x in walk])))
   print("{:d} random walks generated from {:d} vertices, COMPLETE"
       .format(n * i, i))
   f.close()
# construct random walks (caution: very long process!)
construct_random_walks(E, NUM_WALKS_PER_VERTEX, RESTART_PROB, MAX_PATH_LENGTH, RANDOM_WALKS_FILE) 

以下是RANDOM_WALKS_FILE中的几行内容。你可以想象,这些看起来像是一种语言中的句子,其中词汇表是我们图中的所有节点 ID。我们已经了解到,词嵌入利用语言的结构来生成词的分布式表示。像 DeepWalk 和 node2vec 这样的图嵌入方案也做了相同的事情,它们利用从随机游走中生成的“句子”。这些嵌入可以捕捉图中节点之间的相似性,超越了直接邻居的关系,正如我们将看到的那样:

0 1405 4845 754 4391 3524 4282 2357 3922 1667
0 1341 456 495 1647 4200 5379 473 2311
0 3422 3455 118 4527 2304 772 3659 2852 4515 5135 3439 1273
0 906 3498 2286 4755 2567 2632
0 5769 638 3574 79 2825 3532 2363 360 1443 4789 229 4515 3014 3683 2967 5206 2288 1615 1166
0 2469 1353 5596 2207 4065 3100
0 2236 1464 1596 2554 4021
0 4688 864 3684 4542 3647 2859
0 4884 4590 5386 621 4947 2784 1309 4958 3314
0 5546 200 3964 1817 845 

我们现在准备创建我们的词嵌入模型。Gensim 包提供了一个简单的 API,允许我们声明性地创建和训练一个 Word2Vec 模型,使用以下代码。训练后的模型将被序列化到由W2V_MODEL_FILE指定的文件中。Documents类允许我们流式传输大输入文件,以便训练 Word2Vec 模型而不会遇到内存问题。我们将在 skip-gram 模式下训练 Word2Vec 模型,窗口大小为 10,这意味着我们训练它以预测给定一个中心节点时,最多预测五个邻*节点。每个节点的结果嵌入是一个大小为 128 的稠密向量:

W2V_MODEL_FILE = os.path.join(DATA_DIR, "w2v-neurips-papers.model")
class Documents(object):
   def __init__(self, input_file):
       self.input_file = input_file
   def __iter__(self):
       with open(self.input_file, "r") as f:
           for i, line in enumerate(f):
               if i % 1000 == 0:
                   logging.info("{:d} random walks extracted".format(i))
               yield line.strip().split()
def train_word2vec_model(random_walks_file, model_file):
   if os.path.exists(model_file):
       print("Model file {:s} already present, skipping training"
           .format(model_file))
       return
   docs = Documents(random_walks_file)
   model = gensim.models.Word2Vec(
       docs,
       size=128,    # size of embedding vector
       window=10,   # window size
       sg=1,        # skip-gram model
       min_count=2,
       workers=4
   )
   model.train(
       docs,
       total_examples=model.corpus_count,
       epochs=50)
   model.save(model_file)
# train model
train_word2vec_model(RANDOM_WALKS_FILE, W2V_MODEL_FILE) 

我们得到的 DeepWalk 模型实际上就是一个 Word2Vec 模型,因此在处理单词的上下文中,您可以用 Word2Vec 做的任何事情,也可以在顶点的上下文中使用这个模型来做。让我们使用这个模型来发现文档之间的相似性:

def evaluate_model(td_matrix, model_file, source_id):
   model = gensim.models.Word2Vec.load(model_file).wv
   most_similar = model.most_similar(str(source_id))
   scores = [x[1] for x in most_similar]
   target_ids = [x[0] for x in most_similar]
   # compare top 10 scores with cosine similarity 
   # between source and each target
   X = np.repeat(td_matrix[source_id].todense(), 10, axis=0)
   Y = td_matrix[target_ids].todense()
   cosims = [cosine_similarity(X[i], Y[i])[0, 0] for i in range(10)]
   for i in range(10):
       print("{:d} {:s} {:.3f} {:.3f}".format(
           source_id, target_ids[i], cosims[i], scores[i]))
source_id = np.random.choice(E.shape[0])
evaluate_model(TD, W2V_MODEL_FILE, source_id) 

以下是输出结果。第一列和第二列是源节点和目标节点的 ID。第三列是源文档和目标文档对应的词向量之间的余弦相似度,第四列是 Word2Vec 模型报告的相似度分数。正如你所看到的,余弦相似度只报告了 10 对文档中的 2 对之间的相似性,但 Word2Vec 模型能够在嵌入空间中检测到潜在的相似性。这与我们注意到的一热编码和稠密嵌入之间的行为类似:

src_id dst_id cosine_sim w2v_score
1971   5443        0.000     0.348
1971   1377        0.000     0.348
1971   3682        0.017     0.328
1971   51          0.022     0.322
1971   857         0.000     0.318
1971   1161        0.000     0.313
1971   4971        0.000     0.313
1971   5168        0.000     0.312
1971   3099        0.000     0.311
1971   462         0.000     0.310 

这种嵌入策略的代码可以在本章配套的源代码文件夹中的neurips_papers_node2vec.py找到。接下来,我们将继续研究字符和子词嵌入。

字符和子词嵌入

基本词嵌入策略的另一种演变是改为使用字符和子词嵌入,而不是词嵌入。字符级嵌入最早由 XiangLeCun [17] 提出,相较于词嵌入,它们有一些关键的优势。

首先,字符词汇表是有限且小的——例如,英语的词汇表大约包含 70 个字符(26 个字母、10 个数字,以及其他特殊字符),这导致字符模型也小而紧凑。其次,不像词嵌入提供一个大而有限的词汇集合的向量,字符嵌入没有“超出词汇表”这一概念,因为任何单词都可以通过词汇表来表示。第三,字符嵌入对于稀有词和拼写错误的单词通常表现更好,因为与词输入相比,字符输入的失衡程度要小得多。

字符嵌入更适合需要语法相似性而非语义相似性的应用。然而,不同于词嵌入,字符嵌入通常是针对特定任务的,通常是在网络内联生成以支持该任务。因此,第三方字符嵌入通常不可用。

子词嵌入结合了字符和词嵌入的思想,通过将单词视为字符 n-gram 的集合,即由 n 个连续字符组成的序列。它们最早由 Bojanowski 等人 [18] 提出,基于 Facebook AI ResearchFAIR)的研究,随后作为 fastText 嵌入发布。fastText 嵌入可用于 157 种语言,包括英语。相关论文报告了在多个 NLP 任务中,特别是在词类比和语言任务方面,对于形态丰富语言的状态-of-the-art(最先进)性能。

fastText 计算字符 n-gram 的嵌入,其中 n 介于 3 到 6 个字符之间(默认设置可以更改),同时也计算单词本身的嵌入。例如,单词“green”的 n=3 时的字符 n-gram 会是“<gr”、 “gre”、 “ree”、 “een”和“en>”。单词的开始和结束分别用“<”和“>”字符标记,以区分短词和它们的 n-gram,如“”与“cat”。

在查找过程中,如果单词存在于 fastText 嵌入中,你可以通过单词作为键来查找相应的向量。然而,与传统的词嵌入不同,即使单词不在嵌入中,你仍然可以为该单词构建一个 fastText 向量。这是通过将单词分解为其组成的三元子词(trigram)来实现的,如前面的示例所示,然后查找这些子词的向量,并取这些子词向量的平均值。fastText Python API [19] 会自动执行此操作,但如果你使用其他 API 来访问 fastText 词嵌入,如 Gensim 或 NumPy,你需要手动执行此操作。

接下来,我们将讨论动态嵌入。

动态嵌入

到目前为止,我们所考虑的所有嵌入都是静态的;也就是说,它们被部署为一个字典,字典中包含映射到固定维度向量的单词(和子词)。在这些嵌入中,对应某个单词的向量无论它在句子中是作为名词还是动词使用,都将是相同的。例如,单词“ensure”(作为名词时是保健品的名字,作为动词时是“确保”)。它还为多义词或具有多个含义的单词提供相同的向量,例如“bank”(这个词的意思可以根据它与“money”或“river”共现时的上下文不同而不同)。在这两种情况下,单词的意思会根据上下文中的线索发生变化。动态嵌入试图利用这些信号,根据上下文为单词提供不同的向量。

动态嵌入被部署为训练好的网络,它通过查看整个序列(而不仅仅是单个单词),将输入(通常是一个 one-hot 向量序列)转换为一个低维密集的固定大小嵌入。你可以将输入预处理为该密集嵌入,然后将其用作任务特定网络的输入,或者将网络封装起来,将其视为类似于 tf.keras.layers.Embedding 层的静态嵌入。以这种方式使用动态嵌入网络通常比提前生成(第一种选择)或使用传统嵌入要昂贵得多。

最早的动态嵌入是由 McCann 等人 [20] 提出的,称为上下文化向量CoVe)。该方法涉及从机器翻译网络的编码器-解码器对中获取编码器的输出,并将其与相同单词的词向量连接起来。

你将在下一章中了解更多关于 seq2seq 网络的内容。研究人员发现,这种策略提高了各种自然语言处理(NLP)任务的性能。

Peters 等人 [21] 提出的另一种动态嵌入是来自语言模型的嵌入ELMo)。ELMo 使用基于字符的单词表示和双向长短期记忆LSTM)来计算上下文化的单词表示。你将在下一章中了解更多关于 LSTM 的内容。与此同时,可以从 TensorFlow 的模型库 TensorFlow Hub 获取训练好的 ELMo 网络。你可以访问它并按如下方式使用它来生成 ELMo 嵌入。

TensorFlow Hub 上可用的所有与 TensorFlow 2.0 兼容的模型集合可以在 TensorFlow 2.0 的 [16] 网站上找到。在这里,我使用了一系列句子,模型将通过其默认的空格分词策略来确定标记:

import tensorflow as tf
import tensorflow_hub as hub

elmo = hub.load("https://tfhub.dev/google/elmo/3")
embeddings = elmo.signatures"default")["elmo"]
print(embeddings.shape) 

输出是 (2, 7, 1024)。第一个索引告诉我们输入包含了 2 个句子。第二个索引指的是所有句子中的最大单词数,在这个例子中是 7。模型会自动将输出填充到最长的句子。第三个索引给出了由 ELMo 创建的上下文单词嵌入的大小;每个单词被转换为一个大小为 (1024) 的向量。

你还可以通过将 ELMo 嵌入层包装在 tf.keras.KerasLayer 适配器中,将其集成到你的 TF2 模型中。在这个简单的模型中,模型将返回整个字符串的嵌入:

embed = hub.KerasLayer("https://tfhub.dev/google/elmo/3",input_shape=[], dtype=tf.string)
model = tf.keras.Sequential([embed])
embeddings = model.predict([
    "i i like green eggs and ham",
    "would you eat them in a box"
])
print(embeddings.shape) 

动态嵌入,如 ELMo,能够在不同的上下文中为相同的单词提供不同的嵌入,相比于 Word2Vec 或 GloVe 等静态嵌入,具有更好的表现。一个合乎逻辑的下一步是生成表示更大文本单元(如句子和段落)的嵌入。这也是我们在下一部分要探讨的内容。

句子和段落嵌入

一个简单却出奇有效的生成有用的句子和段落嵌入的解决方案是将其组成单词的单词向量进行平均。尽管我们在本节中会描述一些流行的句子和段落嵌入,但通常建议始终先尝试将单词向量进行平均,作为一个基准。

句子(和段落)嵌入也可以通过将其视为单词序列并使用标准的单词向量表示每个单词,以任务优化的方式创建。单词向量序列作为输入训练网络来执行某个特定任务。从网络的某个后期层(在分类层之前)提取的向量通常能够很好地表示序列。然而,这些向量往往是非常任务特定的,作为通用的向量表示其使用价值有限。

Kiros 等人提出了一种生成通用句子向量表示的思路,这些向量可以跨任务使用[22]。他们建议利用来自书籍的文本连续性来构建一个编码器-解码器模型,该模型经过训练能够根据一个句子预测周围的句子。由编码器-解码器网络构建的单词序列的向量表示通常被称为“思想向量”。此外,提出的模型与 skip-gram 非常相似,我们试图根据一个单词预测其周围的单词。正因如此,这些句子向量被称为 skip-thought 向量。该项目发布了一个基于 Theano 的模型,可以用来生成句子的嵌入。后来,Google 研究团队使用 TensorFlow 重新实现了这个模型[23]。Skip-Thoughts 模型为每个句子输出大小为 (2048) 的向量。使用该模型并不是非常简单,但如果你想使用它,仓库中的 README.md 文件[23]提供了详细的使用说明。

一个更方便的句子嵌入来源是 Google 的 Universal Sentence Encoder,它可以在 TensorFlow Hub 上获得。该编码器有两种实现方式。第一种实现速度较快,但准确性较差,基于 Iyer 等人提出的深度平均网络DAN)[24],该网络将单词和二元组的嵌入合并,并通过一个全连接网络进行处理。第二种实现更为准确,但速度较慢,基于 Vaswani 等人提出的 Transformer 网络的编码器部分[25]。我们将在第六章《Transformers》中更详细地讨论 Transformer 网络。

与 ELMo 一样,Google Universal Sentence Encoder 也可以从 TensorFlow Hub 加载到你的 TF2 代码中。下面是一些调用它的代码,使用了我们两个示例句子:

embed = hub.load("https://tfhub.dev/google/universal-sentence-encoder-large/4")
embeddings = embed([
"i like green eggs and ham",
"would you eat them in a box"
])["outputs"]
print(embeddings.shape) 

输出是(2512);也就是说,每个句子由一个大小为(512)的向量表示。值得注意的是,Google Universal Sentence Encoder 可以处理任意长度的单词序列——因此,你可以合法地用它来获取一端的单词嵌入以及另一端的段落嵌入。然而,随着序列长度的增加,嵌入的质量往往会变得“稀释”。

更早的相关工作是在提出 Word2Vec 后不久,由 Le 和 Mikolov [26] 提出的,旨在为长序列(如段落和文档)生成嵌入。这个方法现在通常被称为 Doc2Vec 或 Paragraph2Vec。Doc2Vec 算法是 Word2Vec 的扩展,它利用周围的单词来预测一个单词。在 Doc2Vec 的情况下,训练时提供一个额外的参数——段落 ID。在训练结束时,Doc2Vec 网络会学*每个单词和每个段落的嵌入。在推理阶段,网络接收到一个有缺失单词的段落。网络利用已知部分的段落生成一个段落嵌入,然后使用该段落嵌入和单词嵌入来推断段落中缺失的单词。Doc2Vec 算法有两种实现方式——段落向量 - 分布式记忆PV-DM)和段落向量 - 分布式词袋模型PV-DBOW),大致相当于 Word2Vec 中的 CBOW 和 skip-gram。除了提到 Gensim 工具包提供了可以用你自己的语料库进行训练的预构建实现之外,我们在本书中不会进一步讨论 Doc2Vec。

在看过不同形式的静态和动态嵌入之后,我们现在稍微换个方向,来看看基于语言模型的嵌入。

基于语言模型的嵌入

基于语言模型的嵌入代表了词嵌入演化的下一步。语言模型是一个单词序列的概率分布。一旦我们有了模型,就可以让它预测给定特定单词序列后最可能出现的下一个单词。与传统的词嵌入类似,无论是静态还是动态,它们都被训练以预测给定语料库中的部分句子后(或前)出现的下一个单词(如果语言模型是双向的,也包括前一个单词)。训练过程中不涉及主动标注,因为它利用了大量文本的自然语法结构,因此在某种意义上,这是一种自监督学*过程。

作为词嵌入的语言模型与传统嵌入的主要区别在于,传统嵌入作为数据的单次初始转换应用,然后根据特定任务进行微调。而语言模型则是在大型外部语料库上进行训练,表示特定语言的模型,比如英语。这个步骤被称为预训练。预训练这些语言模型的计算成本通常相对较高;然而,进行预训练的人通常会将模型公开供他人使用,所以我们通常不需要担心这个步骤。下一步是根据你特定的应用领域对这些通用语言模型进行微调。例如,如果你在旅游或医疗行业工作,你会使用自己领域的文本来微调语言模型。微调涉及用你自己的文本重新训练最后几层。微调完成后,你可以将此模型用于该领域的多个任务。与预训练步骤相比,微调步骤通常成本较低。

一旦你完成了微调语言模型,你就可以去除语言模型的最后一层,替换成一个一到两层的全连接网络,将语言模型嵌入的输入转换为你的任务所需的最终分类或回归输出。这个想法与转移学*相同,你在第三章卷积神经网络中已经学*过,唯一的区别是这里你是在文本上进行转移学*,而不是在图像上。与图像的转移学*一样,这些基于语言模型的嵌入允许我们在几乎没有标注数据的情况下获得惊人的结果。不出所料,语言模型嵌入已经被称为自然语言处理领域的“ImageNet 时刻”。

基于语言模型的嵌入思想起源于 ELMo [21] 网络,你在本章中已经见过这个模型。ELMo 通过在大量文本语料库上进行训练,学*预测给定一系列单词时下一个和前一个单词的内容,从而了解语言。ELMo 基于双向 LSTM,你将在第八章自编码器中进一步学*有关它的内容。

第一个可行的语言模型嵌入是由 Howard 和 Ruder [27] 提出的,他们通过 通用语言模型微调ULMFiT)模型进行训练,该模型使用了包含 28,595 篇维基百科文章和 1.03 亿个单词的 wikitext-103 数据集。ULMFiT 提供了与图像任务的迁移学*相同的好处——在相对较少的标注数据情况下,通过监督学*任务获得更好的结果。

与此同时,变换器架构已成为机器翻译任务的首选网络,取代了 LSTM 网络,因为它允许并行操作,并更好地处理长期依赖。我们将在 第六章变换器 中学*更多关于变换器架构的内容。OpenAI 团队的 Radford 等人 [29] 提出了使用标准变换器网络的解码器堆栈,取代了 ULMFiT 中使用的 LSTM 网络。利用这一点,他们构建了一个名为 生成预训练GPT)的语言模型嵌入,在许多语言处理任务中取得了最先进的结果。该论文提出了几种配置,用于涉及单句和多句任务的监督任务,如分类、蕴含、相似度和多选问答。

OpenAI 团队随后构建了更大的语言模型,分别称为 GPT-2 和 GPT-3。由于担心恶意操作员滥用该技术,GPT-2 最初没有发布 [30]。

OpenAI 变换器架构的一个问题是它是单向的,而其前身 ELMo 和 ULMFiT 是双向的。由 Google AI 团队 [28] 提出的 双向编码器表示变换器BERT)使用了变换器架构的编码器堆栈,并通过对其输入进行最多 15% 的掩码,要求模型进行预测,从而安全地实现了双向性。

与 OpenAI 的论文一样,BERT 提出了几种配置,用于执行多种监督学*任务,如单句和多句分类、问答和标注。

BERT 模型有两种主要版本——BERT-base 和 BERT-large。BERT-base 有 12 层编码器,768 个隐藏单元和 12 个注意力头,总参数量为 1.1 亿。BERT-large 有 24 层编码器,1,024 个隐藏单元和 16 个注意力头,总参数量为 3.4 亿。更多细节可以在 BERT GitHub 仓库 [33] 中找到。

BERT 预训练是一个昂贵的过程,目前只能通过 张量处理单元TPUs)或大型分布式 图形处理单元GPUs)集群实现。TPUs 仅通过 Google 的 Colab 网络 [31] 或 Google Cloud Platform [32] 提供。不过,使用自定义数据集对 BERT-base 进行微调通常可以在 GPU 实例上实现。

一旦 BERT 模型被微调到你的领域,通常来自最后四个隐藏层的嵌入会对下游任务产生良好的结果。具体使用哪个嵌入,或者哪些嵌入的组合(通过求和、平均、最大池化或拼接)通常取决于任务类型。

在接下来的部分,我们将探讨如何从 BERT 语言模型中提取嵌入。

使用 BERT 作为特征提取器

BERT 项目 [33] 提供了一套 Python 脚本,可以通过命令行运行来对 BERT 进行微调:

$ git clone https://github.com/google-research/bert.git
$ cd bert 

然后我们下载我们想要微调的适当 BERT 模型。如前所述,BERT 有两种尺寸——BERT-base 和 BERT-large。此外,每个模型都有带大小写和不带大小写的版本。带大小写的版本区分大写和小写单词,而不带大小写的版本则不区分。对于我们的例子,我们将使用 BERT-base-uncased 预训练模型。你可以在 README.md 页面下方找到该模型和其他模型的下载链接:

$ mkdir data
$ cd data
$ wget \ 
https://storage.googleapis.com/bert_models/2018_10_18/uncased_L-12_H-768_A-12.zip
$ unzip -a uncased_L-12_H-768_A-12.zip 

这将在你本地 BERT 项目的 data 目录下创建以下文件夹。bert_config.json 文件是用来创建原始预训练模型的配置文件,vocab.txt 是模型使用的词汇表,包含 30,522 个单词和词片段:

uncased_L-12_H-768_A-12/
 ├── bert_config.json
 ├── bert_model.ckpt.data-00000-of-00001
 ├── bert_model.ckpt.index
 ├── bert_model.ckpt.meta
 └── vocab.txt 

预训练语言模型可以直接作为文本特征提取器,应用于简单的机器学*管道。这在你只想将文本输入向量化时非常有用,利用嵌入的分布特性,获取比一热编码更密集、更丰富的表示。

这里的输入仅是一个每行一个句子的文件。我们称其为 sentences.txt,并将其放入 ${CLASSIFIER_DATA} 文件夹中。你可以通过将它们标识为 -1(最后一个隐藏层)、-2(前一个隐藏层)等,来生成来自最后隐藏层的嵌入。提取输入句子的 BERT 嵌入的命令如下:

$ export BERT_BASE_DIR=./data/uncased_L-12_H-768_A-12
$ export CLASSIFIER_DATA=./data/my_data
$ export TRAINED_CLASSIFIER=./data/my_classifier
$ python extract_features.py \
    --input_file=${CLASSIFIER_DATA}/sentences.txt \
    --output_file=${CLASSIFIER_DATA}/embeddings.jsonl \
    --vocab_file=${BERT_BASE_DIR}/vocab.txt \
    --bert_config_file=${BERT_BASE_DIR}/bert_config.json \
    --init_checkpoint=${BERT_BASE_DIR}/bert_model.ckpt \
    --layers=-1,-2,-3,-4 \
    --max_seq_length=128 \
    --batch_size=8 

该命令将从模型的最后四个隐藏层提取 BERT 嵌入,并将其写入与输入文件同一目录下名为 embeddings.jsonl 的按行排列的 JSON 文件中。这些嵌入随后可以作为下游模型的输入,这些模型专注于某些特定任务,如情感分析。因为 BERT 是在大量英语文本上预训练的,它学到了许多语言的细微差别,这些知识对下游任务非常有用。下游模型不一定是神经网络,也可以是支持向量机(SVM)或 XGBoost 等非神经网络模型。

你可以用 BERT 做更多的事情。之前的用例对应于计算机视觉中的迁移学*。像计算机视觉一样,也可以对 BERT(以及其他变换器模型)进行微调,以适应特定任务,将适当的“头部”网络附加到 BERT 上,并将组合网络针对特定任务进行微调。你将在第六章变换器中学*更多关于这些技术的内容。

总结

在本章中,我们学*了词的分布表示背后的概念及其各种实现,从静态的词嵌入如 Word2Vec 和 GloVe 开始。

然后我们看到了对基本概念的改进,例如子词嵌入、能够捕捉词在句子中上下文的句子嵌入,以及使用整个语言模型来生成嵌入。虽然基于语言模型的嵌入方法如今已经取得了最先进的成果,但仍然有很多应用场景中,传统方法能获得非常好的结果,因此了解所有方法并理解它们的权衡是非常重要的。

我们还简要探讨了词嵌入在自然语言领域之外的其他有趣应用,利用其他类型序列的分布特性,在信息检索和推荐系统等领域做出预测。

现在你已经可以使用嵌入技术,不仅仅是针对文本基础的神经网络,我们将在下一章深入探讨这一点,还可以应用于机器学*的其他领域。

参考文献

  1. Mikolov, T., 等. (2013 年 9 月 7 日)。高效估计词向量空间中的词表示。arXiv:1301.3781v3 [cs.CL]。

  2. Mikolov, T., 等. (2013 年 9 月 17 日)。利用语言之间的相似性进行机器翻译。arXiv:1309.4168v1 [cs.CL]。

  3. Mikolov, T., 等. (2013)。词和短语的分布式表示及其组合性。神经信息处理系统进展 26 (NIPS 2013)。

  4. Pennington, J., Socher, R., Manning, C. (2014). GloVe:全局词向量表示。D14-1162,2014 年自然语言处理中的实证方法会议(EMNLP)论文集。

  5. Niu, F., 等. (2011 年 11 月 11 日)。HOGWILD!一种无锁并行化随机梯度下降的方法。arXiv:1106.5730v2 [math.OC]。

  6. Levy, O., Goldberg, Y. (2014). 神经词嵌入作为隐式矩阵分解。神经信息处理系统进展 27 (NIPS 2014)。

  7. Mahoney, M. (2011 年 9 月 1 日)。text8 数据集:mattmahoney.net/dc/textdata.xhtml

  8. Rehurek, R. (2019 年 4 月 10 日)。Word2Vec 模型的 gensim 文档:radimrehurek.com/gensim/models/word2vec.xhtml

  9. Levy, O., Goldberg, Y. (2014 年 6 月 26-27 日). 稀疏和显式词表示中的语言规律性. 第十八届计算语言学*会议论文集,页 171-180(ACL 2014)。

  10. Rehurek, R. (2019 年 4 月 10 日). gensim 文档,KeyedVectors 模块:radimrehurek.com/gensim/models/keyedvectors.xhtml

  11. Almeida, T. A., Gamez Hidalgo, J. M., 和 Yamakami, A. (2011). 短信垃圾信息过滤研究的贡献:新数据集与结果。2011 年 ACM 文档工程研讨会论文集(DOCENG):www.dt.fee.unicamp.br/~tiago/smsspamcollection/doceng11.pdf?ref=https://githubhelp.com

  12. Speer, R., Chin, J. (2016 年 4 月 6 日). 一种生成高质量词嵌入的集成方法. arXiv:1604.01692v1 [cs.CL].

  13. Speer, R. (2016 年 5 月 25 日). ConceptNet Numberbatch:一个新名称,代表你可以下载的最佳词嵌入blog.conceptnet.io/posts/2016/conceptnet-numberbatch-a-new-name-for-the-best-word-embeddings-you-can-download/

  14. Barkan, O., Koenigstein, N. (2016 年 9 月 13-16 日). Item2Vec:用于协同过滤的神经网络项嵌入. IEEE 第 26 届国际机器学*与信号处理研讨会(MLSP 2016)。

  15. Grover, A., Leskovec, J. (2016 年 8 月 13-17 日). node2vec:用于网络的可扩展特征学*. 第 22 届 ACM SIGKDD 国际知识发现与数据挖掘会议论文集(KDD 2016)。

  16. TensorFlow 2.0 模型在 TensorFlow Hub 上:tfhub.dev/s?q=tf2-preview

  17. Zhang, X., LeCun, Y. (2016 年 4 月 4 日). 从零开始的文本理解. arXiv 1502.01710v5 [cs.LG].

  18. Bojanowski, P., 等人. (2017 年 6 月 19 日). 通过子词信息丰富词向量. arXiv: 1607.04606v2 [cs.CL].

  19. Facebook AI Research, fastText (2017). GitHub 仓库:github.com/facebookresearch/fastText

  20. McCann, B., Bradbury, J., Xiong, C., Socher, R. (2017). 通过翻译学*:上下文化词向量. 神经信息处理系统,2017。

  21. Peters, M., 等人. (2018 年 3 月 22 日). 深度上下文化词表示. arXiv: 1802.05365v2 [cs.CL].

  22. Kiros, R., 等人. (2015 年 6 月 22 日). Skip-Thought 向量. arXiv: 1506.06727v1 [cs.CL].

  23. Kiros, R, 等人. (2017). GitHub 仓库:github.com/ryankiros/skip-thoughts

  24. Iyer, M., Manjunatha, V., Boyd-Graber, J., Daume, H. (2015 年 7 月 26-31 日). 深度无序组合在文本分类中超越语法方法. 第 53 届计算语言学协会年会暨第七届国际自然语言处理联合会议(ACL 2015)论文集。

  25. Vaswani, A., et al. (2017 年 12 月 6 日)。注意力即全部需求。arXiv: 1706.03762v5 [cs.CL]。

  26. Le, Q., Mikolov, T. (2014) 句子和文档的分布式表示。arXiv: 1405.4053v2 [cs.CL]。

  27. Howard, J., Ruder, S. (2018 年 5 月 23 日)。用于文本分类的通用语言模型微调。arXiv: 1801.06146v5 [cs.CL]。

  28. Devlin, J., Chang, M., Lee, K., Toutanova, K. (2018 年 10 月 11 日)。BERT:用于语言理解的深度双向变换器预训练。arXiv: 1810.04805v1 [cs.CL]: arxiv.org/pdf/1810.04805.pdf

  29. Radford, A., Narasimhan, K., Salimans, T., Sutskever, I. (2018)。通过无监督学*提高语言理解openai.com/blog/language-unsupervised/

  30. Radford, A., et al. (2019). 语言模型是无监督的多任务学*者。OpenAI 博客 2019: www.persagen.com/files/misc/radford2019language.pdf

  31. Google Collaboratory: colab.research.google.com

  32. Google Cloud Platform. cloud.google.com/

  33. Google Research, BERT (2019)。GitHub 仓库:github.com/google-research/bert

  34. Nemeth (2019)。使用 Tensorflow 2.0 的简单 BERT。Towards Data Science 博客towardsdatascience.com/simple-bert-using-tensorflow-2-0-132cb19e9b22

  35. TF-IDF。维基百科。2019 年 5 月检索:en.wikipedia.org/wiki/Tf%E2%80%93idf

  36. 潜在语义分析。维基百科。2019 年 5 月检索:en.wikipedia.org/wiki/Latent_semantic_analysis

  37. 主题模型。维基百科。2019 年 5 月检索:en.wikipedia.org/wiki/Topic_model

  38. Warstadt, A., Singh, A., and Bowman, S. (2018). 神经网络可接受性判断。arXiv 1805:12471 [cs.CL]: nyu-mll.github.io/CoLA/

  39. Microsoft Research Paraphrase Corpus. (2018): www.microsoft.com/en-us/download/details.aspx?id=52398

  40. Nozawa, K. (2019)。Something2Vec 论文: gist.github.com/nzw0301/333afc00bd508501268fa7bf40cafe4e

  41. Perrone, V., et al. (2016)。用于动态特征模型的泊松随机场archive.ics.uci.edu/ml/datasets/NIPS+Conference+Papers+1987-2015

  42. Perozzi, B., Al-Rfou, R., 和 Skiena, S. (2014)。DeepWalk:社交表示的在线学*。arXiv 1403.6652v2 [cs.SI]。

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,结识志同道合的人,与超过 2000 名成员一起学*,网址:packt.link/keras

第五章:循环神经网络

第三章中,我们学*了卷积神经网络CNNs),并了解了它们如何利用输入的空间几何特性。例如,CNNs 在图像处理中,最初对图像的小块进行卷积操作,然后通过池化操作逐步扩大到图像的更大区域。图像的卷积和池化操作是二维的:宽度和高度。而对于音频和文本流,则沿时间维度应用一维卷积和池化操作;对于视频流,这些操作是在三个维度上进行的:高度、宽度和时间维度。

本章将重点介绍循环神经网络RNNs),这是一类广泛应用于文本输入的神经网络。RNN 非常灵活,已被用于解决语音识别、语言建模、机器翻译、情感分析、图像描述等问题。RNN 充分利用了输入的顺序特性。顺序输入可以是文本、语音、时间序列,或者任何其他元素的出现依赖于前一个元素的序列。在本章中,我们将看到各种 RNN 的示例,并学*如何使用 TensorFlow 实现它们。

我们将首先了解基本的 RNN 单元及其如何处理输入中的这些顺序依赖关系。我们还将学*基本 RNN 单元(在 Keras 中实现为 SimpleRNN)的一些局限性,并查看两个流行的 SimpleRNN 变体——长短期记忆LSTM)和门控循环单元GRU)——是如何克服这些局限性的。

然后,我们将放大一个层次,考虑 RNN 层本身,它就是将 RNN 单元应用于每个时间步。RNN 可以被视为一个由 RNN 单元组成的图,每个单元在序列的连续元素上执行相同的操作。我们将描述一些简单的修改以提高性能,例如使 RNN 双向和/或有状态。

最后,我们来看一些标准的 RNN 拓扑结构及其可以解决的应用问题。通过重新排列图中的单元,RNN 可以适应不同类型的应用。我们将看到这些配置的一些示例,并了解它们如何用来解决特定问题。我们还将讨论序列到序列(或 seq2seq)架构,该架构在机器翻译和其他多个领域取得了巨大的成功。然后,我们将探讨什么是注意力机制,以及如何利用它提高序列到序列架构的性能。

在本章中,我们将介绍以下主题:

  • 基本 RNN 单元

  • RNN 单元变体

  • RNN 变体

  • RNN 拓扑结构

  • 编码器-解码器架构 – seq2seq

  • 注意力机制

本章的所有代码文件可以在packt.link/dltfchp5找到。

常说千里之行始于足下,因此,为了体现这一精神,让我们从考虑 RNN 单元开始,来启动我们对 RNN 的学*。

基本的 RNN 单元

传统的多层感知器神经网络假设所有输入之间是相互独立的。这一假设对于许多类型的序列数据来说并不成立。例如,句子中的单词、音乐作品中的音符、股票价格随时间变化,甚至化合物中的分子,都是典型的序列数据,其中一个元素会依赖于前面的元素。

RNN 单元通过具有隐藏状态或记忆来实现这一依赖关系,隐藏状态保存了到目前为止所看到的本质信息。任意时刻隐藏状态的值是前一时刻隐藏状态的值和当前时刻输入值的函数,即:

在这里,h[t] 和 h[t][-1] 分别是时刻 tt-1 的隐藏状态值,x[t] 是时刻 t 的输入值。注意,这个方程是递归的,即 h[t][-1] 可以用 h[t][-2] 和 x[t-1] 表示,依此类推,直到序列的开始。这就是 RNN 如何编码和融合来自任意长序列的信息。

我们还可以像 图 5.1(a) 中那样图示化地表示 RNN 单元。在时刻 t,该单元有一个输入 x(t) 和输出 y(t)。部分输出 y(t)(由隐藏状态 h[t] 表示)会被反馈到单元中,以便在后续的时间步 t+1 使用。

就像传统的神经网络中,学*到的参数存储为权重矩阵一样,RNN 的参数由三个权重矩阵 UVW 定义,分别对应输入、输出和隐藏状态的权重:

图表,示意图 描述自动生成

图 5.1: (a) RNN 单元的示意图;(b) 展开视图中的 RNN 单元

图 5.1(b) 显示了一个“展开视图”的相同 RNN。展开只是意味着我们将网络绘制出来,覆盖整个序列。这里显示的网络有三个时间步,适用于处理包含三个元素的序列。请注意,我们之前提到的权重矩阵 UVW 在每个时间步之间是共享的。这是因为我们在每个时间步对不同的输入应用相同的操作。能够在所有时间步之间共享这些权重大大减少了 RNN 需要学*的参数数量。

我们还可以通过方程来描述 RNN 作为计算图。RNN 在时间 t 时的内部状态由隐藏向量 h(t) 的值表示,h(t) 是权重矩阵 W 和时间 t-1 时刻的隐藏状态 h[t][-1] 的和,以及时间 t 时刻的输入 x[t] 与权重矩阵 U 的乘积,再经过 tanh 激活函数。选择 tanh 而非其他激活函数(如 sigmoid)是因为它在实际学*中更为高效,并且有助于解决梯度消失问题,后者我们将在本章后面学*到。

为了简便起见,在本章中描述不同类型的 RNN 架构的所有方程中,我们省略了对偏置项的明确引用,而是将其并入矩阵中。考虑以下一个 n 维空间中的直线方程。这里,w[1] 到 w[n] 是每个维度中直线的系数,偏置 b 是每个维度上的 y 截距:

我们可以将方程改写为矩阵表示如下:

这里,W 是一个形状为 (m, n) 的矩阵,b 是一个形状为 (m, 1) 的向量,其中 m 是对应于我们数据集中记录的行数,n 是每条记录对应的特征列数。等效地,我们可以通过将 b 向量作为 W 的“单位”特征列,将其折叠到 W 矩阵中,从而去掉向量 b。因此:

这里,W’ 是一个形状为 (m, n+1) 的矩阵,最后一列包含偏置 b 的值。

结果是,这种符号表示方式更为紧凑,并且(我们认为)更容易理解和记忆。

在时间t时刻,输出向量 y[t] 是权重矩阵 V 和隐藏状态 h[t] 的乘积,经过 softmax 激活函数处理后,得到的向量是一个输出概率集合:

Keras 提供了 SimpleRNN 循环层,它包含了我们到目前为止看到的所有逻辑,以及更高级的变种,如 LSTM 和 GRU,我们将在本章后面学*到。严格来说,理解它们的工作原理并不是构建它们的必要条件。

然而,当你需要构建自己的专用 RNN 单元来解决特定问题时,理解结构和方程是非常有帮助的。

现在我们已经理解了数据在 RNN 单元中的前向流动,即它如何将输入和隐藏状态结合起来,生成输出和下一个隐藏状态,我们现在来分析梯度在反向传播中的流动。这一过程称为时间反向传播BPTT)。

时间反向传播(BPTT)

与传统的神经网络类似,训练 RNN 同样涉及梯度的反向传播。不同之处在于,由于权重在所有时间步之间共享,每个输出的梯度不仅依赖于当前时间步,还依赖于之前的时间步。这一过程称为通过时间的反向传播(BPTT)[11]。因为在 RNN 中,权重UVW在不同时间步之间是共享的,所以我们需要将不同时间步的梯度进行累加。这是传统反向传播与 BPTT 之间的关键区别。

考虑图 5.2 所示的具有五个时间步的 RNN。在前向传播过程中,网络在时间t生成预测值ŷ[t],并与标签y[t]进行比较,以计算损失L[t]。在反向传播过程中(由虚线表示),损失对权重UVW的梯度在每个时间步计算出来,并通过梯度的累加更新参数:

图示  描述自动生成

图 5.2:通过时间反向传播

下式展示了损失对W的梯度。我们关注这个权重,因为它是导致所谓的消失梯度和爆炸梯度问题的原因。

这个问题表现为损失的梯度接*零或无穷大,从而使网络难以训练。为了理解为什么会发生这种情况,考虑我们之前看到的简单 RNN 的方程;隐藏状态h[t]依赖于h[t][-1],而h[t][-1]又依赖于h[t][-2],依此类推:

现在我们来看一下在时间步t=3时,梯度会发生什么变化。根据链式法则,损失对W的梯度可以分解为三个子梯度的乘积。隐藏状态h[2]相对于W的梯度可以进一步分解为每个隐藏状态相对于前一个状态的梯度之和。最终,每个隐藏状态相对于前一个状态的梯度可以进一步分解为当前隐藏状态与前一个隐藏状态梯度的乘积:

类似的计算也用于计算其他损失 L[0] 到 L[4] 对 W 的梯度,并将它们加总成 W 的梯度更新。我们在本书中不会进一步探讨这些数学推导,但这篇 WildML 博客文章[12]对 BPTT 提供了非常好的解释,包括该过程背后的数学推导。

消失梯度和爆炸梯度

BPTT 特别容易受到消失梯度和爆炸梯度问题的影响,原因在于表达式中代表损失对W的梯度最终公式的乘积部分。考虑一下,隐藏状态相对于前一状态的个别梯度小于 1 的情况。

当我们在多个时间步上进行反向传播时,梯度的乘积会变得越来越小,最终导致梯度消失问题。类似地,如果梯度大于 1,乘积会变得越来越大,最终导致梯度爆炸问题。

在这两种情况中,梯度爆炸更容易被检测到。梯度会变得非常大,最终变为 非数字 (NaN),导致训练过程崩溃。梯度爆炸可以通过将其裁剪到预定义的阈值来控制 [13]。TensorFlow 2.0 允许在优化器构建期间通过 clipvalueclipnorm 参数裁剪梯度,或者通过 tf.clip_by_value 显式裁剪梯度。

梯度消失的影响是,来自远距离时间步的梯度对学*过程没有任何贡献,因此 RNN 最终无法学*任何长期依赖关系。虽然有一些方法可以最小化这个问题,例如适当初始化 W 矩阵、更加激进的正则化、使用 ReLU 替代 tanh 激活函数、以及使用无监督方法对层进行预训练,但最流行的解决方案是使用 LSTM 或 GRU 架构,这两者将在后续讲解。这些架构被设计用来处理梯度消失问题,并更有效地学*长期依赖关系。

RNN 单元变种

本节中,我们将探讨一些 RNN 的单元变种。我们将首先看看 SimpleRNN 单元的一个变种:LSTM RNN。

长短期记忆(LSTM)

LSTM 是一种 SimpleRNN 单元的变种,能够学*长期依赖关系。LSTM 最早由 Hochreiter 和 SchmidHuber [14] 提出,并经过许多其他研究者的改进。它们在多种问题上表现良好,是最广泛使用的 RNN 变种。

我们已经看到,SimpleRNN 如何通过一个 tanh 层将前一时间步的隐藏状态与当前输入结合来实现递归。LSTM 也以类似的方式实现递归,但它们并非使用单一的 tanh 层,而是有四个层以非常特定的方式相互作用。图 5.3 展示了在时间步 t 时,隐藏状态所应用的变换。

图示看起来比较复杂,但让我们一个一个地来看。图表顶部的线是单元的细胞状态 c,表示单元的内部记忆。

图表底部的线是隐藏状态 h,而 ifog 门是 LSTM 解决梯度消失问题的机制。在训练过程中,LSTM 会学*这些门的参数:

图 5.3:LSTM 单元

另一种理解 LSTM 单元中这些门如何工作的方式是,考虑该单元的方程式。这些方程描述了如何从先前时间步长的隐藏状态 h[t-1] 计算出时间 t 时的隐藏状态 h[t]。一般来说,基于方程的描述通常更清晰、更简洁,也是学术论文中展示新单元设计时常用的方式。提供的图示可能与之前看到的有所不同,因此,学*如何阅读方程式并想象单元设计通常更为合理。为此,本书中将仅通过方程式描述其他单元变体。

代表 LSTM 的一组方程式如下所示:

这里,ifo 是输入门、遗忘门和输出门。它们是通过相同的方程式计算的,只是参数矩阵不同,分别为 W[i]、U[i]、W[f]、U[f] 和 W[o]、U[o]。Sigmoid 函数将这些门的输出值调节到 0 和 1 之间,因此,产生的输出向量可以逐元素与另一个向量相乘,以定义第二个向量可以通过第一个向量的程度。

遗忘门定义了你希望允许多少先前状态 h[t][-1] 通过。输入门定义了你希望多少当前输入 x[t] 新计算的状态可以通过,而输出门则定义了你希望多少内部状态暴露给下一层。内部隐藏状态 g 是基于当前输入 x[t] 和先前隐藏状态 h[t][-1] 计算出来的。注意,g 的方程式与 SimpleRNN 中的相同,只是这次我们将通过输入向量 i 的输出来调节输出。

给定 ifog,我们现在可以计算在时间 t 时的细胞状态 c[t],它等于在时间 (t-1) 时的细胞状态 c[t][-1],乘以遗忘门 g 的值,再加上状态 g 乘以输入门 i 的值。这基本上是一种结合旧记忆和新输入的方法——将遗忘门设置为 0 会忽略旧记忆,而将输入门设置为 0 则会忽略新计算的状态。最后,时间 t 时的隐藏状态 h[t] 是通过时间 t 的记忆 c[t] 和输出门 o 计算得出的。

需要了解的一点是,LSTM 可以作为 SimpleRNN 单元的直接替代品;唯一的区别是 LSTM 能够防止梯度消失问题。你可以在网络中用 LSTM 替换 RNN 单元,而无需担心任何副作用。通常你会看到更好的结果,尽管训练时间会更长。

TensorFlow 2.0 还提供了基于 Shi 等人 [18] 论文的 ConvLSTM2D 实现,其中矩阵乘法被卷积运算符取代。

如果您想了解更多关于 LSTM 的内容,请查看 WildML 的 RNN 教程 [15] 和 Christopher Olah 的博客文章 [16]。第一篇教程详细介绍了 LSTM,第二篇教程则以非常直观的方式一步一步带您了解计算过程。

现在我们已经介绍了 LSTM,我们将介绍另一种流行的 RNN 单元架构——GRU。

门控循环单元(GRU)

GRU 是 LSTM 的变体,由 Cho 等人 [17] 提出。它保留了 LSTM 对消失梯度问题的抗性,但其内部结构更简单,因此训练速度更快,因为更新隐藏状态所需的计算量较少。

与 LSTM 单元中的输入 (i)、遗忘 (f) 和输出 (o) 门不同,GRU 单元有两个门,一个是更新门 z,另一个是重置门 r。更新门定义了保留多少先前的记忆,重置门定义了如何将新输入与先前的记忆结合。与 LSTM 中的隐藏状态不同,GRU 中没有持久的单元状态。

GRU 单元定义了通过以下一组方程,从前一时间步的隐藏状态 h[t][-1] 计算当前时间 t 的隐藏状态 h[t]:

更新门 z 和重置门 r 的输出都是通过前一隐藏状态 h[t][-1] 和当前输入 x[t] 的组合来计算的。Sigmoid 函数调节这些函数的输出值在 0 到 1 之间。单元状态 c 是作为重置门 r 和输入 x[t] 输出的函数来计算的。最后,时间 t 的隐藏状态 h[t] 是作为单元状态 c 和前一隐藏状态 h[t][-1] 的函数来计算的。参数 W[z]、U[z]、W[r]、U[r]、W[c]、U[c] 会在训练过程中进行学*。

类似于 LSTM,TensorFlow 2.0(tf.keras)也提供了基本 GRU 层的实现,它是 RNN 单元的替代品。

Peephole LSTM

Peephole LSTM 是一种 LSTM 变体,最早由 Gers 和 Schmidhuber 提出 [19]。它向输入、遗忘和输出门添加了“窥视孔”,因此它们可以看到之前的单元状态 c[t][-1]。计算在 peephole LSTM 中,从前一时间步的隐藏状态 h[t][-1] 到当前时间 t 的隐藏状态 h[t] 的方程式如下所示。

请注意,与 LSTM 方程式的唯一区别是额外的 c[t][-1] 项,用于计算输入 (i)、遗忘 (f) 和输出 (o) 门的输出:

TensorFlow 2.0 提供了一个实验性的 peephole LSTM 单元实现。要在自己的 RNN 层中使用此功能,您需要将该单元(或单元列表)包装在 RNN 包装器中,如下所示的代码片段所示:

hidden_dim = 256
peephole_cell = tf.keras.experimental.PeepholeLSTMCell(hidden_dim)
rnn_layer = tf.keras.layers.RNN(peephole_cell) 

在前一节中,我们介绍了一些为了针对基本 RNN 单元的特定不足而开发的 RNN 单元变体。在下一节中,我们将探讨 RNN 网络架构本身的变体,这些变体是为了应对特定的使用场景而构建的。

RNN 变体

在这一节中,我们将介绍一些基本 RNN 架构的变体,这些变体在某些特定情况下可以提供性能改进。请注意,这些策略可以应用于不同种类的 RNN 单元,以及不同的 RNN 拓扑结构,我们将在后续学*中了解这些拓扑结构。

双向 RNN

我们已经看到,在任何给定的时间步 t,RNN 的输出依赖于所有之前时间步的输出。然而,完全有可能输出也依赖于未来的输出。这对于自然语言处理等应用尤为重要,在这些应用中,我们试图预测的单词或短语的属性可能依赖于整个句子所给出的上下文,而不仅仅是前面的单词。

这个问题可以通过使用双向 LSTM 来解决(见 图 5.4),它也被称为 biLSTM,实际上是两个 RNN 堆叠在一起,一个从左到右读取输入,另一个从右到左读取输入。

每个时间步的输出将基于两个 RNN 的隐藏状态。双向 RNN 允许网络对序列的开始和结束部分给予同等的关注,通常会导致性能的提升:

Diagram  Description automatically generated

图 5.4:双向 LSTM

TensorFlow 2.0 通过一个双向包装层支持双向 RNN。为了使 RNN 层变成双向,只需要用这个包装层将其包裹起来,具体如下所示。由于 biLSTM 中左右 LSTM 的每一对单元的输出是连接在一起的(见 图 5.4),因此需要返回每个单元的输出。因此,我们将 return_sequences 设置为 True(默认值是 False,意味着只返回 LSTM 中最后一个单元的输出):

self.lstm = tf.keras.layers.Bidirectional(
    tf.keras.layers.LSTM(10, return_sequences=True, 
        input_shape=(5, 10))
) 

我们将要介绍的下一个主要 RNN 变体是有状态的 RNN。

有状态的 RNN

RNN 可以是有状态的,这意味着它们可以在训练期间跨批次保持状态。也就是说,为一批训练数据计算的隐藏状态将作为下一批训练数据的初始隐藏状态。然而,这需要显式设置,因为 TensorFlow 2.0(tf.keras)中的 RNN 默认是无状态的,并且在每个批次之后会重置状态。将 RNN 设置为有状态意味着它可以在训练序列中构建状态,甚至在进行预测时保持该状态。

使用有状态 RNN 的好处是网络规模更小和/或训练时间更短。缺点是我们现在需要使用一个反映数据周期性的批量大小来训练网络,并在每个训练周期后重置状态。此外,在训练网络时,数据不应被打乱,因为数据的呈现顺序对有状态网络来说是相关的。

要将 RNN 层设置为有状态,请将命名变量 stateful 设置为True。在我们关于学*如何生成文本的单对多拓扑示例中,我们提供了一个使用有状态 RNN 的示例。在这里,我们使用由连续文本切片组成的数据进行训练,因此将 LSTM 设置为有状态意味着从前一个文本片段生成的隐藏状态会被重用于当前的文本片段。

在下一节关于 RNN 拓扑结构中,我们将探讨如何为不同的使用案例设置 RNN 网络。

RNN 拓扑结构

我们已经看过如何将 MLP 和 CNN 架构组合成更复杂的网络。RNN 提供了另一个自由度,它允许序列的输入和输出。这意味着 RNN 单元可以以不同的方式排列,构建出适应于解决不同类型问题的网络。图 5.5展示了输入、隐藏层和输出的五种不同配置。

其中,第一个(单对单)从序列处理的角度来看并不有趣,因为它可以通过一个简单的密集网络实现,只有一个输入和一个输出。

单对多的案例有一个输入并输出一个序列。这样一个网络的示例可能是从图像中生成文本标签的网络[6],这些标签包含图像不同方面的简短文本描述。这样的网络将使用图像输入和表示图像标签的标注文本序列进行训练:

图表 描述自动生成

图 5.5:常见的 RNN 拓扑结构

多对一的案例则相反;它接受一个序列的张量作为输入,但输出一个单一的张量。这样的网络示例可能是情感分析网络[7],它以一段文本(如电影评论)为输入,并输出一个单一的情感值。

多对多的使用案例有两种变体。第一种更为流行,更广为人知的是 seq2seq 模型。在这个模型中,一个序列被读取并生成一个上下文向量,代表输入序列,用于生成输出序列。

该拓扑结构在机器翻译领域取得了巨大成功,也适用于可以重新框架为机器翻译问题的问题。前者的现实生活示例可以在[8, 9]中找到,后者的示例描述在[10]中。

第二种多对多类型的网络中,每个输入单元都有一个对应的输出单元。这种网络适用于输入和输出之间有 1:1 对应关系的用例,例如时间序列。这个模型与 seq2seq 模型的主要区别在于,在解码过程开始之前,输入不必完全编码。

在接下来的三个部分中,我们提供一个学*生成文本的一对多网络示例,一个进行情感分析的多对一网络示例,以及第二类型的多对多网络示例,该网络预测句子中单词的词性。由于 seq2seq 网络的普及,我们稍后会在本章节中更详细地介绍它。

示例 ‒ 一对多 – 学*生成文本

RNN 在自然语言处理(NLP)社区被广泛用于各种应用中。其中一种应用是构建语言模型。语言模型允许我们预测给定先前单词的文本中下一个单词的概率。语言模型对于各种高级任务如机器翻译、拼写校正等非常重要。

语言模型预测序列中下一个单词的能力使其成为一个生成模型,通过从词汇表中不同单词的输出概率中进行采样,我们可以生成文本。训练数据是单词序列,标签是在序列的下一个时间步出现的单词。

作为例子,我们将在 Lewis Carroll 的儿童故事《爱丽丝梦游仙境》及其续集《爱丽丝镜中奇遇记》的文本上训练一个基于字符的 RNN。我们选择建立一个基于字符的模型,因为它具有较小的词汇量并且训练速度更快。其思想与训练和使用基于单词的语言模型相同,只是我们将使用字符而不是单词。训练完成后,该模型可以用于以相同风格生成一些文本。

我们示例中的数据将来自 Project Gutenberg 网站[36]上两部小说的纯文本。网络的输入是 100 个字符的序列,对应的输出是输入序列后移一个位置的另一个 100 个字符的序列。

换句话说,如果输入是序列[c[1], c[2], …, c[n]],输出将是[c[2], c[3], …, c[n+1]]。我们将训练网络 50 个 epochs,在每个 10 个 epochs 的末尾,我们将生成一个以标准前缀开始的固定大小字符序列。在以下示例中,我们使用了前缀“爱丽丝”,这是我们小说中主人公的名字。

和往常一样,我们将首先导入必要的库并设置一些常量。这里,DATA_DIR指向您下载本章源代码所在位置下的数据文件夹。CHECKPOINT_DIR是该数据文件夹下的一个存储每 10 个 epochs 结束时模型权重的检查点文件夹:

import os
import numpy as np
import re
import shutil
import tensorflow as tf
DATA_DIR = "./data"
CHECKPOINT_DIR = os.path.join(DATA_DIR, "checkpoints") 

接下来,我们将下载并准备网络所需的数据。这两本书的文本可以从 Project Gutenberg 网站上公开获取。tf.keras.utils.get_file() 函数会检查文件是否已经下载到本地磁盘,如果没有,它将下载到代码所在位置的 datasets 文件夹下。我们还会稍微预处理一下输入,去除文本中的换行符和字节顺序标记字符。此步骤将创建 texts 变量,它是这两本书的一个字符扁平化列表:

def download_and_read(urls):
    texts = []
    for i, url in enumerate(urls):
        p = tf.keras.utils.get_file("ex1-{:d}.txt".format(i), url,
            cache_dir=".")
        text = open(p, "r").read()
        # remove byte order mark
        text = text.replace("\ufeff", "")
        # remove newlines
        text = text.replace('\n', ' ')
        text = re.sub(r'\s+', " ", text)
        # add it to the list
        texts.extend(text)
    return texts
texts = download_and_read([
    "http://www.gutenberg.org/cache/epub/28885/pg28885.txt",
    "https://www.gutenberg.org/files/12/12-0.txt"
]) 

接下来,我们将创建我们的词汇表。在我们的案例中,词汇表包含 90 个独特的字符,由大小写字母、数字和特殊字符组成。我们还创建了一些映射字典,将每个词汇字符转换为唯一整数,并且反之亦然。正如前面所提到的,网络的输入和输出是字符序列。

然而,网络的实际输入和输出是整数序列,我们将使用这些映射字典来处理这种转换:

# create the vocabulary
vocab = sorted(set(texts))
print("vocab size: {:d}".format(len(vocab)))
# create mapping from vocab chars to ints
char2idx = {c:i for i, c in enumerate(vocab)}
idx2char = {i:c for c, i in char2idx.items()} 

下一步是使用这些映射字典将我们的字符序列输入转换为整数序列,然后转换为 TensorFlow 数据集。每个序列将包含 100 个字符,输出将比输入偏移 1 个字符位置。我们首先将数据集批量化为 101 个字符的切片,然后对数据集的每个元素应用 split_train_labels() 函数,以创建我们的序列数据集,该数据集由包含两个元素的元组组成,每个元素是一个大小为 100、类型为 tf.int64 的向量。然后我们对这些序列进行洗牌,并为每个输入到网络的序列创建 64 个元组的批次。现在,数据集的每个元素都是一个由两个矩阵组成的元组,每个矩阵的大小为 (64, 100),类型为 tf.int64

# numericize the texts
texts_as_ints = np.array([char2idx[c] for c in texts])
data = tf.data.Dataset.from_tensor_slices(texts_as_ints)
# number of characters to show before asking for prediction
# sequences: [None, 100]
seq_length = 100
sequences = data.batch(seq_length + 1, drop_remainder=True)
def split_train_labels(sequence):
    input_seq = sequence[0:-1]
    output_seq = sequence[1:]
    return input_seq, output_seq
sequences = sequences.map(split_train_labels)
# set up for training
# batches: [None, 64, 100]
batch_size = 64
steps_per_epoch = len(texts) // seq_length // batch_size
dataset = sequences.shuffle(10000).batch(
    batch_size, drop_remainder=True) 

我们现在准备好定义我们的网络。像之前一样,我们将网络定义为 tf.keras.Model 的子类,如下所示。网络结构相对简单;它以大小为 100(num_timesteps)的整数序列作为输入,并通过一个嵌入层将每个整数转换为大小为 256(embedding_dim)的向量。因此,假设批次大小为 64,对于大小为 (64, 100) 的输入序列,嵌入层的输出将是一个形状为 (64, 100, 256) 的矩阵。

下一层是一个具有 100 个时间步的 RNN 层。选择的 RNN 实现是 GRU。该 GRU 层将在每个时间步接收一个大小为 (256,) 的向量,并输出一个形状为 (1024,) 的向量(rnn_output_dim)。还需要注意的是,RNN 是有状态的,这意味着从前一训练周期输出的隐藏状态将作为当前周期的输入。return_sequences=True 标志还表示 RNN 会在每个时间步输出,而不是仅在最后一个时间步输出聚合结果。

最后,每个时间步都会发出一个形状为(1024,)的向量,进入一个密集层,该层输出一个形状为(90,)的向量(vocab_size)。该层的输出将是一个形状为(64, 100, 90)的张量。输出向量中的每个位置对应于我们词汇表中的一个字符,值对应于该字符在该输出位置出现的概率:

class CharGenModel(tf.keras.Model):
    def __init__(self, vocab_size, num_timesteps,
            embedding_dim, **kwargs):
        super(CharGenModel, self).__init__(**kwargs)
        self.embedding_layer = tf.keras.layers.Embedding(
            vocab_size,
            embedding_dim
        )
        self.rnn_layer = tf.keras.layers.GRU(
            num_timesteps,
            recurrent_initializer="glorot_uniform",
            recurrent_activation="sigmoid",
            stateful=True,
            return_sequences=True)
        self.dense_layer = tf.keras.layers.Dense(vocab_size)
    def call(self, x):
        x = self.embedding_layer(x)
        x = self.rnn_layer(x)
        x = self.dense_layer(x)
        return x
vocab_size = len(vocab)
embedding_dim = 256
model = CharGenModel(vocab_size, seq_length, embedding_dim)
model.build(input_shape=(batch_size, seq_length)) 

接下来,我们定义一个损失函数并编译我们的模型。我们将使用稀疏类别交叉熵作为损失函数,因为当输入和输出是整数序列时,这是标准的损失函数。对于优化器,我们将选择 Adam 优化器:

def loss(labels, predictions):
    return tf.losses.sparse_categorical_crossentropy(
        labels,
        predictions,
        from_logits=True
    )
model.compile(optimizer=tf.optimizers.Adam(), loss=loss) 

通常,输出中每个位置的字符是通过计算该位置向量的 argmax 来找到的,也就是说,找到与最大概率值对应的字符。这被称为贪婪搜索。在语言模型中,其中一个时间步的输出作为下一个时间步的输入,这可能会导致输出重复。克服这个问题的两种常见方法是要么随机抽样输出,要么使用束搜索,在每个时间步从最可能的k个值中进行抽样。在这里,我们将使用tf.random.categorical()函数来随机抽样输出。以下函数接受一个字符串作为前缀,并利用它生成一个长度由num_chars_to_generate指定的字符串。温度参数用于控制预测的质量。较低的值会生成更可预测的输出。

逻辑遵循一个可预测的模式。我们将prefix_string中的字符序列转换为整数序列,然后通过expand_dims添加一个批次维度,以便将输入传递到我们的模型中。接着我们重置模型的状态。这是必要的,因为我们的模型是有状态的,我们不希望预测过程中的第一个时间步的隐藏状态被训练时计算出的状态所继承。然后,我们将输入传递通过模型并得到预测结果。这是一个形状为(90,)的向量,表示词汇表中每个字符在下一时间步出现的概率。接下来,我们通过去除批次维度并除以温度参数来重塑预测结果,然后从向量中随机抽样。我们将预测结果作为下一时间步的输入。我们重复这一过程,直到生成所需数量的字符,并将每次预测转换回字符形式,积累到一个列表中,最后在循环结束时返回该列表:

def generate_text(model, prefix_string, char2idx, idx2char,
        num_chars_to_generate=1000, temperature=1.0):
    input = [char2idx[s] for s in prefix_string]
    input = tf.expand_dims(input, 0)
    text_generated = []
    model.reset_states()
    for i in range(num_chars_to_generate):
        preds = model(input)
        preds = tf.squeeze(preds, 0) / temperature
        # predict char returned by model
        pred_id = tf.random.categorical(
            preds, num_samples=1)[-1, 0].numpy()
        text_generated.append(idx2char[pred_id])
        # pass the prediction as the next input to the model
        input = tf.expand_dims([pred_id], 0)
    return prefix_string + "".join(text_generated) 

最后,我们准备好运行训练和评估循环。如前所述,我们将训练网络 50 个周期,每隔 10 个周期,我们将尝试用迄今为止训练的模型生成一些文本。每个阶段的前缀是字符串 "Alice "。请注意,为了适应单个字符串前缀,我们会在每隔 10 个周期保存一次权重,并使用这些权重构建一个单独的生成模型,但输入形状的批量大小为 1。以下是执行此操作的代码:

num_epochs = 50
for i in range(num_epochs // 10):
    model.fit(
        dataset.repeat(),
        epochs=10,
        steps_per_epoch=steps_per_epoch
        # callbacks=[checkpoint_callback, tensorboard_callback]
    )
    checkpoint_file = os.path.join(
        CHECKPOINT_DIR, "model_epoch_{:d}".format(i+1))
    model.save_weights(checkpoint_file)
    # create generative model using the trained model so far
    gen_model = CharGenModel(vocab_size, seq_length, embedding_dim)
    gen_model.load_weights(checkpoint_file)
    gen_model.build(input_shape=(1, seq_length))
    print("after epoch: {:d}".format(i+1)*10)
    print(generate_text(gen_model, "Alice ", char2idx, idx2char))
    print("---") 

训练的第一次周期后的输出包含一些完全无法解读的单词:

Alice nIPJtce otaishein r. henipt il nn tu t hen mlPde hc efa hdtioDDeteeybeaewI teu"t e9B ce nd ageiw  eai rdoCr ohrSI ey Pmtte:vh ndte taudhor0-gu s5'ria,tr gn inoo luwomg Omke dee sdoohdn ggtdhiAoyaphotd t- kta e c t- taLurtn   hiisd tl'lpei od y' tpacoe dnlhr oG mGhod ut hlhoy .i, sseodli., ekngnhe idlue'aa'  ndti-rla nt d'eiAier adwe ai'otteniAidee hy-ouasq"plhgs tuutandhptiw  oohe.Rastnint:e,o odwsir"omGoeuall1*g taetphhitoge ds wr li,raa,  h$jeuorsu  h cidmdg't ku..n,HnbMAsn nsaathaa,' ase woe  ehf re ig"hTr ddloese eod,aed toe rh k. nalf bte seyr udG n,ug lei hn icuimty"onw Qee ivtsae zdrye g eut rthrer n sd,Zhqehd' sr caseruhel are fd yse e  kgeiiday odW-ldmkhNw endeM[harlhroa h Wydrygslsh EnilDnt e "lue "en wHeslhglidrth"ylds rln n iiato taue flitl nnyg ittlno re 'el yOkao itswnadoli'.dnd Akib-ehn hftwinh yd ee tosetf tonne.;egren t wf, ota nfsr, t&he desnre e" oo fnrvnse aid na tesd is ioneetIf ·itrn tttpakihc s nih'bheY ilenf yoh etdrwdplloU ooaeedo,,dre snno'ofh o epst. lahehrw 

然而,在大约训练了 30 个周期后,我们开始看到一些看起来熟悉的单词:

Alice Red Queen. He best I had defores it,' glily do flose time it makes the talking of find a hand mansed in she loweven to the rund not bright prough: the and she a chill be the sand using that whever sullusn--the dear of asker as 'IS now-- Chich the hood." "Oh!"' '_I'm num about--again was wele after a WAG LoANDE BITTER OF HSE!0 UUL EXMENN 1*.t, this wouldn't teese to Dumark THEVER Project Gutenberg-tmy of himid out flowal woulld: 'Nis song, Eftrin in pully be besoniokinote. "Com, contimemustion--of could you knowfum to hard, she can't the with talking to alfoeys distrint, for spacemark!' 'You gake to be would prescladleding readieve other togrore what it mughturied ford of it was sen!" You squs, _It I hap: But it was minute to the Kind she notion and teem what?" said Alice, make there some that in at the shills distringulf out to the Froge, and very mind to it were it?' the King was set telm, what's the old all reads talking a minuse. "Where ream put find growned his so," _you 'Fust to t 

经过 50 个周期的训练,模型仍然很难表达连贯的思想,但已经学会了合理地拼写单词。令人惊讶的是,尽管模型是基于字符的,并且不了解单词,但它学会了拼写看起来可能来源于原始文本的单词:

Alice Vex her," he prope of the very managed by this thill deceed. I will ear she a much daid. "I sha?' Nets: "Woll, I should shutpelf, and now and then, cried, How them yetains, a tround her about in a shy time, I pashng round the sandle, droug" shrees went on what he seting that," said Alice. "Was this will resant again. Alice stook of in a faid.' 'It's ale. So they wentle shall kneeltie-and which herfer--the about the heald in pum little each the UKECE P@TTRUST GITE Ever been my hever pertanced to becristrdphariok, and your pringing that why the King as I to the King remark, but very only all Project Grizly: thentiused about doment,' Alice with go ould, are wayings for handsn't replied as mave about to LISTE!' (If the UULE 'TARY-HAVE BUY DIMADEANGNE'G THING NOOT,' be this plam round an any bar here! No, you're alard to be a good aftered of the sam--I canon't?" said Alice. 'It's one eye of the olleations. Which saw do it just opened hardly deat, we hastowe. 'Of coum, is tried try slowing 

生成文本中的下一个字符或单词并不是你可以使用这种模型做的唯一事情。类似的模型已被构建用来预测股票价格 [3] 或生成古典音乐 [4]。Andrej Karpathy 在他的博客文章 [5] 中介绍了一些有趣的例子,比如生成假维基百科页面、代数几何证明和 Linux 源代码。

这个例子的完整代码可以在本章的源代码文件夹中的 alice_text_generator.py 找到。可以通过以下命令在命令行中运行:

$ python alice_text_generator.py 

我们的下一个例子将展示一个用于情感分析的多对一网络的实现。

示例 ‒ 多对一 – 情感分析

在这个例子中,我们将使用一个多对一的网络,它以一个句子为输入,并预测其情感是积极的还是消极的。我们的数据集是 UCI 机器学*库中的情感标注句子数据集 [20],它包含来自亚马逊、IMDb 和 Yelp 的 3,000 个评论句子,每个句子根据其情感标注为 0(表示消极情感)或 1(表示积极情感)。

和往常一样,我们首先进行导入:

import numpy as np
import os
import shutil
import tensorflow as tf
from sklearn.metrics import accuracy_score, confusion_matrix 

数据集以压缩文件的形式提供,解压后是一个包含三个人物标注句子文件的文件夹,每行一个句子和标签,句子与标签之间由制表符分隔。我们首先下载压缩文件,然后将文件解析为 (sentence, label) 对的列表:

def download_and_read(url):
    local_file = url.split('/')[-1]
    local_file = local_file.replace("%20", " ")
    p = tf.keras.utils.get_file(local_file, url,
        extract=True, cache_dir=".")
    local_folder = os.path.join("datasets", local_file.split('.')[0])
    labeled_sentences = []
    for labeled_filename in os.listdir(local_folder):
        if labeled_filename.endswith("_labelled.txt"):
            with open(os.path.join(
                    local_folder, labeled_filename), "r") as f:
                for line in f:
                    sentence, label = line.strip().split('\t')
                    labeled_sentences.append((sentence, label))
    return labeled_sentences
labeled_sentences = download_and_read(      
    "https://archive.ics.uci.edu/ml/machine-learning-databases/" + 
    "00331/sentiment%20labelled%20sentences.zip")
sentences = [s for (s, l) in labeled_sentences]
labels = [int(l) for (s, l) in labeled_sentences] 

我们的目标是训练模型,使其能够根据输入的句子,学*预测标签中提供的相应情感。每个句子是一个单词的序列。然而,为了将其输入到模型中,我们必须将其转换为整数序列。

序列中的每个整数都将指向一个单词。我们语料库中整数到单词的映射称为词汇表。因此,我们需要对句子进行分词并生成一个词汇表。这是通过以下代码完成的:

tokenizer = tf.keras.preprocessing.text.Tokenizer()
tokenizer.fit_on_texts(sentences)
vocab_size = len(tokenizer.word_counts)
print("vocabulary size: {:d}".format(vocab_size))
word2idx = tokenizer.word_index
idx2word = {v:k for (k, v) in word2idx.items()} 

我们的词汇表包含了 5,271 个独特的单词。通过丢弃出现次数少于某个阈值的单词,我们可以将词汇表的大小缩小。这个阈值可以通过检查tokenizer.word_counts字典来找到。在这种情况下,我们需要将词汇大小加 1,以便为 UNK(未知)条目预留空间,该条目将用于替代词汇表中找不到的单词。

我们还构建了查找字典,用于从单词到单词索引的转换以及反向转换。第一个字典在训练期间非常有用,用于构造整数序列以供网络输入。第二个字典则在预测代码中用于将单词索引转换回单词。

每个句子的单词数可能不同。我们的模型要求我们为每个句子提供相同长度的整数序列。为了支持这一要求,通常会选择一个足够大的最大序列长度,以容纳大多数训练集中的句子。任何较短的句子将会被零填充,较长的句子将会被截断。选择最大序列长度的一个简单方法是查看不同百分位位置的句子长度(例如,单词数量):

seq_lengths = np.array([len(s.split()) for s in sentences])
print([(p, np.percentile(seq_lengths, p)) for p
    in [75, 80, 90, 95, 99, 100]]) 

这将给我们以下输出:

[(75, 16.0), (80, 18.0), (90, 22.0), (95, 26.0), (99, 36.0), (100, 71.0)] 

如可以看到,最大句子长度为 71 个单词,但 99%的句子都在 36 个单词以内。例如,如果我们选择 64 作为值,我们应该能够避免大多数句子的截断。

之前的代码块可以多次交互运行,以分别选择合适的词汇大小和最大序列长度。在我们的示例中,我们选择保留所有单词(因此vocab_size = 5271),并将max_seqlen设置为 64。

我们的下一步是创建一个模型可以使用的数据集。我们首先使用训练好的分词器,将每个句子从一系列单词(sentences)转换为一系列整数(sentences_as_ints),其中每个整数对应的是该单词在tokenizer.word_index中的索引。然后,它会被截断并用零进行填充。

标签也被转换为 NumPy 数组labels_as_ints,最后,我们将张量sentences_as_intslabels_as_ints结合,形成一个 TensorFlow 数据集:

max_seqlen = 64
# create dataset
sentences_as_ints = tokenizer.texts_to_sequences(sentences)
sentences_as_ints = tf.keras.preprocessing.sequence.pad_sequences(
    sentences_as_ints, maxlen=max_seqlen)
labels_as_ints = np.array(labels)
dataset = tf.data.Dataset.from_tensor_slices(
    (sentences_as_ints, labels_as_ints)) 

我们希望将数据集的 1/3 部分留作评估数据。剩余的数据中,我们将 10%作为内联验证数据集,模型将在训练过程中使用它来评估自身进度,其余部分作为训练数据集。最后,我们为每个数据集创建 64 个句子的批次:

dataset = dataset.shuffle(10000)
test_size = len(sentences) // 3
val_size = (len(sentences) - test_size) // 10
test_dataset = dataset.take(test_size)
val_dataset = dataset.skip(test_size).take(val_size)
train_dataset = dataset.skip(test_size + val_size)
batch_size = 64
train_dataset = train_dataset.batch(batch_size)
val_dataset = val_dataset.batch(batch_size)
test_dataset = test_dataset.batch(batch_size) 

接下来,我们定义我们的模型。如你所见,该模型相当简单,每个输入句子都是一个大小为max_seqlen(64)的整数序列。这些输入会传入一个嵌入层,将每个单词转换为一个向量,向量的大小为词汇表大小+1。额外的一个单词是为了考虑在上面的pad_sequences()调用中引入的填充整数 0。然后,64 个时间步的每个向量都会输入到一个双向 LSTM 层,该层将每个单词转换为大小为(64,)的向量。LSTM 在每个时间步的输出会输入到一个密集层,该层产生一个大小为(64,)的向量,并使用 ReLU 激活函数。该密集层的输出会输入到另一个密集层,该层在每个时间步输出一个大小为(1,)的向量,并通过 sigmoid 激活进行调节。

该模型使用二元交叉熵损失函数和 Adam 优化器进行编译,并经过 10 轮训练:

class SentimentAnalysisModel(tf.keras.Model):
    def __init__(self, vocab_size, max_seqlen, **kwargs):
        super(SentimentAnalysisModel, self).__init__(**kwargs)
        self.embedding = tf.keras.layers.Embedding(
            vocab_size, max_seqlen)
        self.bilstm = tf.keras.layers.Bidirectional(
            tf.keras.layers.LSTM(max_seqlen)
        )
        self.dense = tf.keras.layers.Dense(64, activation="relu")
        self.out = tf.keras.layers.Dense(1, activation="sigmoid")
    def call(self, x):
        x = self.embedding(x)
        x = self.bilstm(x)
        x = self.dense(x)
        x = self.out(x)
        return x
model = SentimentAnalysisModel(vocab_size+1, max_seqlen)
model.build(input_shape=(batch_size, max_seqlen))
model.summary()
# compile
model.compile(
    loss="binary_crossentropy",
    optimizer="adam",
    metrics=["accuracy"]
)
# train
data_dir = "./data"
logs_dir = os.path.join("./logs")
best_model_file = os.path.join(data_dir, "best_model.h5")
checkpoint = tf.keras.callbacks.ModelCheckpoint(best_model_file,
    save_weights_only=True,
    save_best_only=True)
tensorboard = tf.keras.callbacks.TensorBoard(log_dir=logs_dir)
num_epochs = 10
history = model.fit(train_dataset, epochs=num_epochs,
    validation_data=val_dataset,
    callbacks=[checkpoint, tensorboard]) 

从输出中你可以看到,训练集的准确率达到了 99.8%,验证集的准确率约为 78.5%。训练集的准确率较高是预期的,因为模型是在该数据集上训练的。你还可以查看以下的损失图,准确看到模型开始在训练集上过拟合的位置。注意,训练损失持续下降,但验证损失最初下降后开始上升。当验证损失开始上升时,我们就知道模型在训练集上过拟合了:

Epoch 1/10
29/29 [==============================] - 7s 239ms/step - loss: 0.6918 - accuracy: 0.5148 - val_loss: 0.6940 - val_accuracy: 0.4750
Epoch 2/10
29/29 [==============================] - 3s 98ms/step - loss: 0.6382 - accuracy: 0.5928 - val_loss: 0.6311 - val_accuracy: 0.6000
Epoch 3/10
29/29 [==============================] - 3s 100ms/step - loss: 0.3661 - accuracy: 0.8250 - val_loss: 0.4894 - val_accuracy: 0.7600
Epoch 4/10
29/29 [==============================] - 3s 99ms/step - loss: 0.1567 - accuracy: 0.9564 - val_loss: 0.5469 - val_accuracy: 0.7750
Epoch 5/10
29/29 [==============================] - 3s 99ms/step - loss: 0.0768 - accuracy: 0.9875 - val_loss: 0.6197 - val_accuracy: 0.7450
Epoch 6/10
29/29 [==============================] - 3s 100ms/step - loss: 0.0387 - accuracy: 0.9937 - val_loss: 0.6529 - val_accuracy: 0.7500
Epoch 7/10
29/29 [==============================] - 3s 99ms/step - loss: 0.0215 - accuracy: 0.9989 - val_loss: 0.7597 - val_accuracy: 0.7550
Epoch 8/10
29/29 [==============================] - 3s 100ms/step - loss: 0.0196 - accuracy: 0.9987 - val_loss: 0.6745 - val_accuracy: 0.7450
Epoch 9/10
29/29 [==============================] - 3s 99ms/step - loss: 0.0136 - accuracy: 0.9962 - val_loss: 0.7770 - val_accuracy: 0.7500
Epoch 10/10
29/29 [==============================] - 3s 99ms/step - loss: 0.0062 - accuracy: 0.9988 - val_loss: 0.8344 - val_accuracy: 0.7450 

图 5.6 显示了训练和验证数据集的准确率和损失的 TensorBoard 图:

图表,折线图,散点图 描述自动生成

图 5.6:来自 TensorBoard 的情感分析网络训练准确率和损失图

我们的检查点回调基于最低的验证损失保存了最佳模型,现在我们可以重新加载该模型,并用它对我们保留的测试集进行评估:

best_model = SentimentAnalysisModel(vocab_size+1, max_seqlen)
best_model.build(input_shape=(batch_size, max_seqlen))
best_model.load_weights(best_model_file)
best_model.compile(
    loss="binary_crossentropy",
    optimizer="adam",
    metrics=["accuracy"]
) 

评估模型与数据集的最简单高层方法是使用model.evaluate()调用:

test_loss, test_acc = best_model.evaluate(test_dataset)
print("test loss: {:.3f}, test accuracy: {:.3f}".format(
    test_loss, test_acc)) 

这将给我们以下输出:

test loss: 0.487, test accuracy: 0.782 

我们还可以使用model.predict()来获取预测结果,并将其与标签逐一对比,利用外部工具(例如来自 scikit-learn 的工具)来计算我们的结果:

labels, predictions = [], []
idx2word[0] = "PAD"
is_first_batch = True
for test_batch in test_dataset:
   inputs_b, labels_b = test_batch
   pred_batch = best_model.predict(inputs_b)
   predictions.extend([(1 if p > 0.5 else 0) for p in pred_batch])
   labels.extend([l for l in labels_b])
   if is_first_batch:
       # print first batch of label, prediction, and sentence
       for rid in range(inputs_b.shape[0]):
           words = [idx2word[idx] for idx in inputs_b[rid].numpy()]
           words = [w for w in words if w != "PAD"]
           sentence = " ".join(words)
           print("{:d}\t{:d}\t{:s}".format(
               labels[rid], predictions[rid], sentence))
       is_first_batch = False
print("accuracy score: {:.3f}".format(accuracy_score(labels, predictions)))
print("confusion matrix")
print(confusion_matrix(labels, predictions)) 

对于我们测试数据集中的第一批 64 个句子,我们重建句子并显示标签(第一列)以及模型的预测(第二列)。这里我们展示前 10 个句子。如你所见,模型在这个列表中的大多数句子上都预测正确:

LBL  PRED  SENT
1     1    one of my favorite purchases ever
1     1    works great
1     1    our waiter was very attentive friendly and informative
0     0    defective crap
0     1    and it was way to expensive
0     0    don't waste your money
0     0    friend's pasta also bad he barely touched it
1     1    it's a sad movie but very good
0     0    we recently witnessed her poor quality of management towards other guests as well
0     1    there is so much good food in vegas that i feel cheated for wasting an eating opportunity by going to rice and company 

我们还报告了所有测试数据集句子的结果。如你所见,测试准确率与evaluate调用报告的结果相同。我们还生成了混淆矩阵,显示在 1,000 个测试示例中,我们的情感分析网络正确预测了 782 次,错误预测了 218 次:

accuracy score: 0.782
confusion matrix
[[391  97]
 [121 391]] 

本例的完整代码位于此章节源代码文件夹中的lstm_sentiment_analysis.py中。可以通过以下命令从命令行运行:

$ python lstm_sentiment_analysis.py 

我们的下一个例子将描述一个用于词性标注英文文本的多对多网络。

示例 ‒ 多对多 ‒ 词性标注

在本例中,我们将使用 GRU 层构建一个网络,用于进行词性标注(POS tagging)。词性是跨多个句子使用的词汇类别。词性的例子包括名词、动词、形容词等。例如,名词通常用于识别事物,动词通常用于识别它们的行为,形容词用于描述这些事物的属性。过去词性标注是手动完成的,但现在主要通过统计模型解决,最*则通过端到端的深度学*模型,如 Collobert 等人[21]所述,进一步解决了这个问题。

对于我们的训练数据,我们需要标有词性标签的句子。Penn Treebank [22] 是其中之一,它是约 450 万个美国英语单词的人工注释语料库。但它是非免费资源。Penn Treebank 的 10%样本作为 NLTK [23]的一部分免费提供,我们将使用它来训练我们的网络。

我们的模型将接受句子中的单词序列作为输入,然后将为每个单词输出相应的词性标签。因此,对于由单词[The, cat, sat. on, the, mat, .]组成的输入序列,输出序列应为词性符号[DT, NN, VB, IN, DT, NN, .]

要获取数据,如果尚未安装 NLTK 库(NLTK 已包含在 Anaconda 分发中),则需要安装 NLTK 库(NLTK)。要安装树库数据集,请在 Python REPL 中执行以下操作:

>>> import nltk
>>> nltk.download("treebank") 

完成这些步骤后,我们就可以构建我们的网络了。像往常一样,我们将从导入必要的包开始:

import numpy as np
import os
import shutil
import tensorflow as tf 

我们将懒惰地将 NLTK treebank 数据集导入成一对平行的扁平文件,一个包含句子,另一个包含相应的词性序列:

def download_and_read(dataset_dir, num_pairs=None):
    sent_filename = os.path.join(dataset_dir, "treebank-sents.txt")
    poss_filename = os.path.join(dataset_dir, "treebank-poss.txt")
    if not(os.path.exists(sent_filename) and os.path.exists(poss_filename)):
        import nltk   
        if not os.path.exists(dataset_dir):
            os.makedirs(dataset_dir)
        fsents = open(sent_filename, "w")
        fposs = open(poss_filename, "w")
        sentences = nltk.corpus.treebank.tagged_sents()
        for sent in sentences:
            fsents.write(" ".join([w for w, p in sent]) + "\n")
            fposs.write(" ".join([p for w, p in sent]) + "\n")
        fsents.close()
        fposs.close()
    sents, poss = [], []
    with open(sent_filename, "r") as fsent:
        for idx, line in enumerate(fsent):
            sents.append(line.strip())
            if num_pairs is not None and idx >= num_pairs:
                break
    with open(poss_filename, "r") as fposs:
        for idx, line in enumerate(fposs):
            poss.append(line.strip())
            if num_pairs is not None and idx >= num_pairs:
                break
    return sents, poss
sents, poss = download_and_read("./datasets")
assert(len(sents) == len(poss))
print("# of records: {:d}".format(len(sents))) 

我们的数据集中有 3,194 个句子。前面的代码将句子及其对应的标签写入平行文件,即treebank-sents.txt中的第一行包含第一句,treebank-poss.txt中的第一行包含句子中每个单词的相应词性标签。表 5.1显示了这个数据集中的两个句子及其相应的词性标签:

句子 词性标签
Pierre Vinken, 61 years old, will join the board as a nonexecutive director Nov. 29. NNP NNP , CD NNS JJ , MD VB DT NN IN DT JJ NN NNP CD.
Mr. Vinken is chairman of Elsevier N.V., the Dutch publishing group. NNP NNP VBZ NN IN NNP NNP , DT NNP VBG NN.

表 5.1:句子及其相应的词性标签

然后,我们将使用 TensorFlow(tf.keras)的分词器对句子进行分词,并创建句子标记的列表。我们重复使用相同的基础设施对词性进行分词,尽管我们也可以直接按空格分割。每个输入记录当前是一个文本标记序列,但它们需要是一个整数序列。在分词过程中,分词器还会维护词汇表中的标记,从中我们可以建立从标记到整数的映射,并可以进行反向映射。

我们需要考虑两个词汇表,一个是句子集合中的词汇表,另一个是词性集合中的词性标签词汇表。以下代码展示了如何对这两个集合进行分词并生成必要的映射字典:

def tokenize_and_build_vocab(texts, vocab_size=None, lower=True):
    if vocab_size is None:
        tokenizer = tf.keras.preprocessing.text.Tokenizer(lower=lower)
    else:
        tokenizer = tf.keras.preprocessing.text.Tokenizer(
            num_words=vocab_size+1, oov_token="UNK", lower=lower)
    tokenizer.fit_on_texts(texts)
    if vocab_size is not None:
        # additional workaround, see issue 8092
        # https://github.com/keras-team/keras/issues/8092
        tokenizer.word_index = {e:i for e, i in
            tokenizer.word_index.items() if 
            i <= vocab_size+1 }
    word2idx = tokenizer.word_index
    idx2word = {v:k for k, v in word2idx.items()}
    return word2idx, idx2word, tokenizer
word2idx_s, idx2word_s, tokenizer_s = tokenize_and_build_vocab(
    sents, vocab_size=9000)
word2idx_t, idx2word_t, tokenizer_t = tokenize_and_build_vocab(
    poss, vocab_size=38, lower=False)
source_vocab_size = len(word2idx_s)
target_vocab_size = len(word2idx_t)
print("vocab sizes (source): {:d}, (target): {:d}".format(
    source_vocab_size, target_vocab_size)) 

我们的句子将具有不同的长度,尽管句子中的标记数量及其相应的词性标签序列是相同的。网络要求输入具有相同的长度,因此我们需要决定句子的长度是多少。以下(临时)代码计算了不同的百分位数,并将这些百分位数的句子长度打印到控制台:

sequence_lengths = np.array([len(s.split()) for s in sents])
print([(p, np.percentile(sequence_lengths, p))
    for p in [75, 80, 90, 95, 99, 100]])
[(75, 33.0), (80, 35.0), (90, 41.0), (95, 47.0), (99, 58.0), (100, 271.0)] 

我们看到,设置句子长度为约 100 并不会有太大问题,尽管会有一些被截断的句子。长度小于我们选择的长度的句子将在末尾进行填充。由于我们的数据集较小,我们希望尽可能多地使用它,因此最终选择了最大长度。

下一步是根据我们的输入创建数据集。首先,我们需要将输入和输出序列中的标记和词性标签序列转换为整数序列。其次,我们需要将较短的序列填充到最大长度 271\。请注意,在填充之后,我们对词性标签序列进行额外的操作,而不是保持它为整数序列;我们将其转换为一系列独热编码,使用to_categorical()函数。TensorFlow 2.0 确实提供了处理输出为整数序列的损失函数,但我们希望尽可能简化代码,因此选择自行进行转换。最后,我们使用from_tensor_slices()函数创建数据集,对其进行打乱,并将其划分为训练集、验证集和测试集:

max_seqlen = 271
# convert sentences to sequence of integers
sents_as_ints = tokenizer_s.texts_to_sequences(sents)
sents_as_ints = tf.keras.preprocessing.sequence.pad_sequences(
    sents_as_ints, maxlen=max_seqlen, padding="post")
# convert POS tags to sequence of (categorical) integers
poss_as_ints = tokenizer_t.texts_to_sequences(poss)
poss_as_ints = tf.keras.preprocessing.sequence.pad_sequences(
    poss_as_ints, maxlen=max_seqlen, padding="post")
poss_as_catints = []
for p in poss_as_ints:
    poss_as_catints.append(tf.keras.utils.to_categorical(p,
        num_classes=target_vocab_size+1, dtype="int32"))
poss_as_catints = tf.keras.preprocessing.sequence.pad_sequences(
    poss_as_catints, maxlen=max_seqlen)
dataset = tf.data.Dataset.from_tensor_slices(
    (sents_as_ints, poss_as_catints))
idx2word_s[0], idx2word_t[0] = "PAD", "PAD"
# split into training, validation, and test datasets
dataset = dataset.shuffle(10000)
test_size = len(sents) // 3
val_size = (len(sents) - test_size) // 10
test_dataset = dataset.take(test_size)
val_dataset = dataset.skip(test_size).take(val_size)
train_dataset = dataset.skip(test_size + val_size)
# create batches
batch_size = 128
train_dataset = train_dataset.batch(batch_size)
val_dataset = val_dataset.batch(batch_size)
test_dataset = test_dataset.batch(batch_size) 

接下来,我们将定义我们的模型并实例化它。我们的模型是一个顺序模型,由嵌入层、丢弃层、双向 GRU 层、全连接层和 softmax 激活层组成。输入是一个形状为(batch_sizemax_seqlen)的整数序列批次。当通过嵌入层时,序列中的每个整数都会被转换为大小为(embedding_dim)的向量,因此现在我们的张量形状是(batch_sizemax_seqlenembedding_dim)。这些向量会传递到双向 GRU 的相应时间步中,GRU 的输出维度为 256\。

因为 GRU 是双向的,这相当于将一个 GRU 堆叠在另一个 GRU 之上,因此从双向 GRU 中输出的张量具有维度(batch_sizemax_seqlen2*rnn_output_dimension)。每个时间步长的张量形状为(batch_size12*rnn_output_dimension),它会被送入一个全连接层,该层将每个时间步长转换为一个与目标词汇表大小相同的向量,即(batch_sizenumber_of_timestepsoutput_vocab_size)。每个时间步长表示一个输出标记的概率分布,因此最终会对每个时间步应用 softmax 层,返回一个输出 POS 标记序列。

最后,我们声明模型并设置一些参数,然后使用 Adam 优化器、分类交叉熵损失函数和准确度作为度量来编译它:

class POSTaggingModel(tf.keras.Model):
    def __init__(self, source_vocab_size, target_vocab_size,
            embedding_dim, max_seqlen, rnn_output_dim, **kwargs):
        super(POSTaggingModel, self).__init__(**kwargs)
        self.embed = tf.keras.layers.Embedding(
            source_vocab_size, embedding_dim, input_length=max_seqlen)
        self.dropout = tf.keras.layers.SpatialDropout1D(0.2)
        self.rnn = tf.keras.layers.Bidirectional(
            tf.keras.layers.GRU(rnn_output_dim, return_sequences=True))
        self.dense = tf.keras.layers.TimeDistributed(
            tf.keras.layers.Dense(target_vocab_size))
        self.activation = tf.keras.layers.Activation("softmax")
    def call(self, x):
        x = self.embed(x)
        x = self.dropout(x)
        x = self.rnn(x)
        x = self.dense(x)
        x = self.activation(x)
        return x
embedding_dim = 128
rnn_output_dim = 256
model = POSTaggingModel(source_vocab_size, target_vocab_size,
    embedding_dim, max_seqlen, rnn_output_dim)
model.build(input_shape=(batch_size, max_seqlen))
model.summary()
model.compile(
    loss="categorical_crossentropy",
    optimizer="adam",
    metrics=["accuracy", masked_accuracy()]) 
 the label and the prediction, as a result of which the accuracy numbers are very optimistic. In fact, the validation accuracy reported at the end of the very first epoch is 0.9116. However, the quality of POS tags generated is very poor.

或许最好的方法是用一个忽略所有数字为零的匹配项的损失函数来替代当前的损失函数;然而,一个更简单的方法是构建一个更严格的度量,并使用它来判断何时停止训练。因此,我们构建了一个新的准确度函数masked_accuracy(),其代码如下所示:

def masked_accuracy():
    def masked_accuracy_fn(ytrue, ypred):
        ytrue = tf.keras.backend.argmax(ytrue, axis=-1)
        ypred = tf.keras.backend.argmax(ypred, axis=-1)
        mask = tf.keras.backend.cast(
            tf.keras.backend.not_equal(ypred, 0), tf.int32)
        matches = tf.keras.backend.cast(
            tf.keras.backend.equal(ytrue, ypred), tf.int32) * mask
        numer = tf.keras.backend.sum(matches)
        denom = tf.keras.backend.maximum(tf.keras.backend.sum(mask), 1)
        accuracy =  numer / denom
        return accuracy
    return masked_accuracy_fn 

我们现在准备训练我们的模型。像往常一样,我们设置了模型检查点和 TensorBoard 回调,然后调用模型的fit()便捷方法,以批量大小 128 训练模型 50 个 epoch:

num_epochs = 50
best_model_file = os.path.join(data_dir, "best_model.h5")
checkpoint = tf.keras.callbacks.ModelCheckpoint(
    best_model_file,
    save_weights_only=True,
    save_best_only=True)
tensorboard = tf.keras.callbacks.TensorBoard(log_dir=logs_dir)
history = model.fit(train_dataset,
    epochs=num_epochs,
    validation_data=val_dataset,
    callbacks=[checkpoint, tensorboard]) 

训练的一个截断输出如下所示。如你所见,masked_accuracyval_masked_accuracy的数值似乎比accuracyval_accuracy的数值更为保守。这是因为掩码版本没有考虑输入为 PAD 字符的序列位置:

Epoch 1/50
19/19 [==============================] - 8s 431ms/step - loss: 1.4363 - accuracy: 0.7511 - masked_accuracy_fn: 0.00
38 - val_loss: 0.3219 - val_accuracy: 0.9116 - val_masked_accuracy_fn: 0.5833
Epoch 2/50
19/19 [==============================] - 6s 291ms/step - loss: 0.3278 - accuracy: 0.9183 - masked_accuracy_fn: 0.17
12 - val_loss: 0.3289 - val_accuracy: 0.9209 - val_masked_accuracy_fn: 0.1357
Epoch 3/50
19/19 [==============================] - 6s 292ms/step - loss: 0.3187 - accuracy: 0.9242 - masked_accuracy_fn: 0.1615 - val_loss: 0.3131 - val_accuracy: 0.9186 - val_masked_accuracy_fn: 0.2236
Epoch 4/50
19/19 [==============================] - 6s 293ms/step - loss: 0.3037 - accuracy: 0.9186 - masked_accuracy_fn: 0.1831 - val_loss: 0.2933 - val_accuracy: 0.9129 - val_masked_accuracy_fn: 0.1062
Epoch 5/50
19/19 [==============================] - 6s 294ms/step - loss: 0.2739 - accuracy: 0.9182 - masked_accuracy_fn: 0.1054 - val_loss: 0.2608 - val_accuracy: 0.9230 - val_masked_accuracy_fn: 0.1407
...
Epoch 45/50
19/19 [==============================] - 6s 292ms/step - loss: 0.0653 - accuracy: 0.9810 - masked_accuracy_fn: 0.7872 - val_loss: 0.1545 - val_accuracy: 0.9611 - val_masked_accuracy_fn: 0.5407
Epoch 46/50
19/19 [==============================] - 6s 291ms/step - loss: 0.0640 - accuracy: 0.9815 - masked_accuracy_fn: 0.7925 - val_loss: 0.1550 - val_accuracy: 0.9616 - val_masked_accuracy_fn: 0.5441
Epoch 47/50
19/19 [==============================] - 6s 291ms/step - loss: 0.0619 - accuracy: 0.9818 - masked_accuracy_fn: 0.7971 - val_loss: 0.1497 - val_accuracy: 0.9614 - val_masked_accuracy_fn: 0.5535
Epoch 48/50
19/19 [==============================] - 6s 292ms/step - loss: 0.0599 - accuracy: 0.9825 - masked_accuracy_fn: 0.8033 - val_loss: 0.1524 - val_accuracy: 0.9616 - val_masked_accuracy_fn: 0.5579
Epoch 49/50
19/19 [==============================] - 6s 293ms/step - loss: 0.0585 - accuracy: 0.9830 - masked_accuracy_fn: 0.8092 - val_loss: 0.1544 - val_accuracy: 0.9617 - val_masked_accuracy_fn: 0.5621
Epoch 50/50
19/19 [==============================] - 6s 291ms/step - loss: 0.0575 - accuracy: 0.9833 - masked_accuracy_fn: 0.8140 - val_loss: 0.1569 - val_accuracy: 0.9615 - val_masked_accuracy_fn: 0.5511
11/11 [==============================] - 2s 170ms/step - loss: 0.1436 - accuracy: 0.9637 - masked_accuracy_fn: 0.5786
test loss: 0.144, test accuracy: 0.963, masked test accuracy: 0.578 

这里展示了一些随机句子的 POS 标签,这些句子来自测试集,并与相应的真实标签句子的 POS 标签一起展示。如你所见,尽管度量值并不完美,但它似乎已经学会了相当好地进行 POS 标注:

labeled  : among/IN segments/NNS that/WDT t/NONE 1/VBP continue/NONE 2/TO to/VB operate/RB though/DT the/NN company/POS 's/NN steel/NN division/VBD continued/NONE 3/TO to/VB suffer/IN from/JJ soft/NN demand/IN for/PRP its/JJ tubular/NNS goods/VBG serving/DT the/NN oil/NN industry/CC and/JJ other/NNS
predicted: among/IN segments/NNS that/WDT t/NONE 1/NONE continue/NONE 2/TO to/VB operate/IN though/DT the/NN company/NN 's/NN steel/NN division/NONE continued/NONE 3/TO to/IN suffer/IN from/IN soft/JJ demand/NN for/IN its/JJ tubular/NNS goods/DT serving/DT the/NNP oil/NN industry/CC and/JJ other/NNS
labeled  : as/IN a/DT result/NN ms/NNP ganes/NNP said/VBD 0/NONE t/NONE 2/PRP it/VBZ is/VBN believed/IN that/JJ little/CC or/DT no/NN sugar/IN from/DT the/CD 1989/NN 90/VBZ crop/VBN has/VBN been/NONE shipped/RB 1/RB yet/IN even/DT though/NN the/NN crop/VBZ year/CD is/NNS six/JJ
predicted: as/IN a/DT result/NN ms/IN ganes/NNP said/VBD 0/NONE t/NONE 2/PRP it/VBZ is/VBN believed/NONE that/DT little/NN or/DT no/NN sugar/IN from/DT the/DT 1989/CD 90/NN crop/VBZ has/VBN been/VBN shipped/VBN 1/RB yet/RB even/IN though/DT the/NN crop/NN year/NN is/JJ
labeled  : in/IN the/DT interview/NN at/IN headquarters/NN yesterday/NN afternoon/NN both/DT men/NNS exuded/VBD confidence/NN and/CC seemed/VBD 1/NONE to/TO work/VB well/RB together/RB
predicted: in/IN the/DT interview/NN at/IN headquarters/NN yesterday/NN afternoon/NN both/DT men/NNS exuded/NNP confidence/NN and/CC seemed/VBD 1/NONE to/TO work/VB well/RB together/RB
labeled  : all/DT came/VBD from/IN cray/NNP research/NNP
predicted: all/NNP came/VBD from/IN cray/NNP research/NNP
labeled  : primerica/NNP closed/VBD at/IN 28/CD 25/NONE u/RB down/CD 50/NNS
predicted: primerica/NNP closed/VBD at/CD 28/CD 25/CD u/CD down/CD 

如果你想自己运行这段代码,你可以在本章的代码文件夹中找到它。要从命令行运行,输入以下命令。输出将写入控制台:

$ python gru_pos_tagger.py 

现在我们已经看过了三种常见的 RNN 网络拓扑结构的示例,让我们来探索其中最流行的一个——seq2seq 模型,它也被称为递归编码器-解码器架构。

编码器-解码器架构 – seq2seq

我们刚才看到的多对多网络示例与多对一网络非常相似。唯一的重要区别是,RNN 在每个时间步返回输出,而不是在最后返回一个合并的输出。另一个显著的特点是输入时间步的数量等于输出时间步的数量。当你学*编码器-解码器架构时,这种“另一种”更流行的多对多网络风格,你会注意到另一个区别——在多对多网络中,输出与输入是对齐的,也就是说,网络不需要等到所有输入被处理完后才生成输出。

编码器-解码器架构也被称为 seq2seq 模型。顾名思义,网络由一个编码器和一个解码器组成,二者都基于 RNN,并且能够处理并返回对应多个时间步的输出序列。seq2seq 网络最大的应用是神经机器翻译,尽管它同样适用于那些大致可以结构化为翻译问题的任务。一些例子包括句子解析[10]和图像标注[24]。seq2seq 模型也已被用于时间序列分析[25]和问答系统。

在 seq2seq 模型中,编码器处理源序列,这是一批整数序列。序列的长度是输入时间步的数量,对应于最大输入序列长度(根据需要进行填充或截断)。因此,输入张量的维度为(batch_sizenumber_of_encoder_timesteps)。这个张量被传递到嵌入层,嵌入层将每个时间步的整数转换为嵌入向量。嵌入层的输出是一个形状为(batch_sizenumber_of_encoder_timestepsencoder_embedding_dim)的张量。

这个张量被输入到 RNN 中,RNN 将每个时间步的向量转换为与其编码维度相对应的大小。这个向量是当前时间步和所有之前时间步的组合。通常,编码器会返回最后一个时间步的输出,代表整个序列的上下文或“思想”向量。这个张量的形状为(batch_sizeencoder_rnn_dim)。

解码器网络的架构与编码器相似,唯一不同的是每个时间步上会有一个额外的密集层来转换输出。解码器每个时间步的输入是上一个时间步的隐藏状态和由解码器在上一个时间步预测的标记向量。对于第一个时间步,隐藏状态来自编码器的上下文向量,而输入向量对应于在目标端启动序列生成的标记。例如,在翻译的用例中,它是开始字符串BOS)伪标记。隐藏信号的形状是(batch_sizeencoder_rnn_dim),而在所有时间步上的输入信号形状是(batch_sizenumber_of_decoder_timesteps)。

一旦通过嵌入层,输出张量的形状是(batch_sizenumber_of_decoder_timestepsdecoder_embedding_dim)。下一步是解码器 RNN 层,其输出是一个形状为(batch_sizenumber_of_decoder_timestepsdecoder_rnn_dim)的张量。每个时间步的输出会经过一个密集层,该层将向量转换为目标词汇表的大小,因此密集层的输出形状是(batch_sizenumber_of_decoder_timestepsoutput_vocab_size)。这基本上是每个时间步上的标记概率分布,因此如果我们计算最后一维的 argmax,我们就可以将其转换回目标语言中的预测标记序列。图 5.7展示了 seq2seq 架构的高层次视图:

图示  描述自动生成

图 5.7:Seq2seq 网络数据流。图片来源:Artur Suilin [25]

在接下来的部分,我们将查看一个用于机器翻译的 seq2seq 网络的示例。

示例 ‒ 无注意力机制的 seq2seq 机器翻译

为了更详细地理解 seq2seq 模型,我们将通过一个例子来学*如何使用 Tatoeba 项目(1997-2019)的法英双语数据集将英文翻译成法文。[26]该数据集包含大约 167,000 对句子。为了加快训练速度,我们只会使用前 30,000 对句子进行训练。

和往常一样,我们将从导入开始:

import nltk
import numpy as np
import re
import shutil
import tensorflow as tf
import os
import unicodedata
from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction 

数据作为远程 zip 文件提供。访问该文件的最简单方法是从www.manythings.org/anki/fra-eng.zip下载并使用 unzip 在本地解压。zip 文件包含一个名为fra.txt的制表符分隔文件,其中法语和英语的句子对通过制表符分隔,每行一个句子对。代码期望在与自身相同目录的dataset文件夹中找到fra.txt文件。我们希望从中提取三个不同的数据集。

如果你回忆一下 seq2seq 网络的结构,编码器的输入是一个英语单词序列。在解码器一侧,输入是一个法语单词序列,输出是一个时间步偏移的法语单词序列。以下函数将下载压缩文件,解压并创建前面描述的数据集。

输入数据经过预处理,以 ascii 化 字符,分离出与相邻单词的特定标点符号,并移除除字母和这些特定标点符号之外的所有字符。最后,句子被转换为小写。每个英语句子被转换为一个单一的词序列。每个法语句子被转换为两个序列,一个以 BOS 伪词开头,另一个以 句子结束EOS)伪词结尾。

第一个序列从位置 0 开始,停止于句子中的倒数第二个单词,第二个序列从位置 1 开始,一直延伸到句子的末尾:

def preprocess_sentence(sent):
    sent = "".join([c for c in unicodedata.normalize("NFD", sent)
        if unicodedata.category(c) != "Mn"])
    sent = re.sub(r"([!.?])", r" \1", sent)
    sent = re.sub(r"[^a-zA-Z!.?]+", r" ", sent)
    sent = re.sub(r"\s+", " ", sent)
    sent = sent.lower()
    return sent
def download_and_read():
    en_sents, fr_sents_in, fr_sents_out = [], [], []
    local_file = os.path.join("datasets", "fra.txt")
    with open(local_file, "r") as fin:
        for i, line in enumerate(fin):
            en_sent, fr_sent = line.strip().split('\t')
            en_sent = [w for w in preprocess_sentence(en_sent).split()]
            fr_sent = preprocess_sentence(fr_sent)
            fr_sent_in = [w for w in ("BOS " + fr_sent).split()]
            fr_sent_out = [w for w in (fr_sent + " EOS").split()]
            en_sents.append(en_sent)
            fr_sents_in.append(fr_sent_in)
            fr_sents_out.append(fr_sent_out)
            if i >= num_sent_pairs - 1:
                break
    return en_sents, fr_sents_in, fr_sents_out
sents_en, sents_fr_in, sents_fr_out = download_and_read() 

我们的下一步是对输入进行分词,并创建词汇表。由于我们有两种不同语言的序列,我们将为每种语言分别创建两个不同的分词器和词汇表。

tf.keras 框架提供了一个非常强大且多功能的分词器类——在这里,我们将过滤器设置为空字符串,lower 设置为 False,因为我们已经在 preprocess_sentence() 函数中完成了分词所需的操作。分词器创建了各种数据结构,我们可以从中计算词汇表大小和查找表,允许我们从单词到单词索引之间进行转换。

接下来,我们通过使用 pad_sequences() 函数在序列末尾填充零来处理不同长度的单词序列。因为我们的字符串比较短,所以我们不进行截断,只对句子的最大长度进行填充(英语为 8 个单词,法语为 16 个单词):

tokenizer_en = tf.keras.preprocessing.text.Tokenizer(
    filters="", lower=False)
tokenizer_en.fit_on_texts(sents_en)
data_en = tokenizer_en.texts_to_sequences(sents_en)
data_en = tf.keras.preprocessing.sequence.pad_sequences(
    data_en, padding="post")
tokenizer_fr = tf.keras.preprocessing.text.Tokenizer(
    filters="", lower=False)
tokenizer_fr.fit_on_texts(sents_fr_in)
tokenizer_fr.fit_on_texts(sents_fr_out)
data_fr_in = tokenizer_fr.texts_to_sequences(sents_fr_in)
data_fr_in = tf.keras.preprocessing.sequence.pad_sequences(
    data_fr_in, padding="post")
data_fr_out = tokenizer_fr.texts_to_sequences(sents_fr_out)
data_fr_out = tf.keras.preprocessing.sequence.pad_sequences(
    data_fr_out, padding="post")
vocab_size_en = len(tokenizer_en.word_index)
vocab_size_fr = len(tokenizer_fr.word_index)
word2idx_en = tokenizer_en.word_index
idx2word_en = {v:k for k, v in word2idx_en.items()}
word2idx_fr = tokenizer_fr.word_index
idx2word_fr = {v:k for k, v in word2idx_fr.items()}
print("vocab size (en): {:d}, vocab size (fr): {:d}".format(
    vocab_size_en, vocab_size_fr))
maxlen_en = data_en.shape[1]
maxlen_fr = data_fr_out.shape[1]
print("seqlen (en): {:d}, (fr): {:d}".format(maxlen_en, maxlen_fr)) 

最后,我们将数据转换成 TensorFlow 数据集,然后将其分割为训练数据集和测试数据集:

batch_size = 64
dataset = tf.data.Dataset.from_tensor_slices(
    (data_en, data_fr_in, data_fr_out))
dataset = dataset.shuffle(10000)
test_size = NUM_SENT_PAIRS // 4
test_dataset = dataset.take(test_size).batch(
    batch_size, drop_remainder=True)
train_dataset = dataset.skip(test_size).batch(
    batch_size, drop_remainder=True) 

我们的数据现在已经准备好用于训练 seq2seq 网络,接下来我们将定义该网络。我们的编码器是一个嵌入层,后跟一个 GRU 层。编码器的输入是一个整数序列,这个序列会被转换成一个大小为 embedding_dim 的嵌入向量序列。这个向量序列被送入 RNN,在每个 num_timesteps 时间步中将输入转换为大小为 encoder_dim 的向量。只有最后一个时间步的输出会被返回,正如 return_sequences=False 所示。

解码器的结构几乎与编码器相同,唯一不同的是它有一个额外的全连接层,用于将从 RNN 输出的大小为 decoder_dim 的向量转换为一个表示目标词汇表概率分布的向量。解码器还会返回所有时间步的输出。

在我们的示例网络中,我们选择将嵌入维度设置为 128,然后是每个 1024 的编码器和解码器 RNN 维度。请注意,我们必须为英文和法文词汇表的词汇大小分别加 1,以便考虑在 pad_sequences() 步骤中添加的 PAD 字符:

class Encoder(tf.keras.Model):
    def __init__(self, vocab_size, num_timesteps,
            embedding_dim, encoder_dim, **kwargs):
        super(Encoder, self).__init__(**kwargs)
        self.encoder_dim = encoder_dim
        self.embedding = tf.keras.layers.Embedding(
            vocab_size, embedding_dim, input_length=num_timesteps)
        self.rnn = tf.keras.layers.GRU(
            encoder_dim, return_sequences=False, return_state=True)
    def call(self, x, state):
        x = self.embedding(x)
        x, state = self.rnn(x, initial_state=state)
        return x, state
    def init_state(self, batch_size):
        return tf.zeros((batch_size, self.encoder_dim))
class Decoder(tf.keras.Model):
    def __init__(self, vocab_size, embedding_dim, num_timesteps,
            decoder_dim, **kwargs):
        super(Decoder, self).__init__(**kwargs)
        self.decoder_dim = decoder_dim
        self.embedding = tf.keras.layers.Embedding(
            vocab_size, embedding_dim, input_length=num_timesteps)
        self.rnn = tf.keras.layers.GRU(
            decoder_dim, return_sequences=True, return_state=True)
        self.dense = tf.keras.layers.Dense(vocab_size)
    def call(self, x, state):
        x = self.embedding(x)
        x, state = self.rnn(x, state)
        x = self.dense(x)
        return x, state
embedding_dim = 256
encoder_dim, decoder_dim = 1024, 1024
encoder = Encoder(vocab_size_en+1, 
    embedding_dim, maxlen_en, encoder_dim)
decoder = Decoder(vocab_size_fr+1, 
    embedding_dim, maxlen_fr, decoder_dim) 

现在我们已经定义了 EncoderDecoder 类,让我们回顾一下它们的输入和输出的维度。以下一段(临时)代码可以用来打印系统中各种输入和输出的维度。为了方便,代码已经作为注释块保留在本章随附的代码中:

for encoder_in, decoder_in, decoder_out in train_dataset:
    encoder_state = encoder.init_state(batch_size)
    encoder_out, encoder_state = encoder(encoder_in, encoder_state)
    decoder_state = encoder_state
    decoder_pred, decoder_state = decoder(decoder_in, decoder_state)
    break
print("encoder input          :", encoder_in.shape)
print("encoder output         :", encoder_out.shape, "state:", encoder_state.shape)
print("decoder output (logits):", decoder_pred.shape, "state:", decoder_state.shape)
print("decoder output (labels):", decoder_out.shape) 

这将产生以下输出,符合我们的预期。编码器输入是一批整数序列,每个序列的大小为 8,这是我们英文句子中最大数量的标记,因此其维度为(batch_sizemaxlen_en)。

编码器的输出是一个形状为(batch_sizeencoder_dim)的单一张量(return_sequences=False),表示一批上下文向量,代表输入句子。编码器状态张量具有相同的维度。解码器的输出也是一批整数序列,但法文句子的最大大小为 16;因此,维度为(batch_sizemaxlen_fr)。

解码器的预测是一个跨所有时间步的概率分布的批次;因此,维度为(batch_sizemaxlen_frvocab_size_fr+1),解码器状态的维度与编码器状态相同(batch_sizedecoder_dim):

encoder input          : (64, 8)
encoder output         : (64, 1024) state: (64, 1024)
decoder output (logits): (64, 16, 7658) state: (64, 1024)
decoder output (labels): (64, 16) 

接下来,我们定义损失函数。由于我们对句子进行了填充,我们不想通过考虑标签和预测之间填充词的相等性来偏倚结果。我们的损失函数通过标签对预测进行掩蔽,因此标签上的任何填充位置也会从预测中移除,我们仅使用标签和预测中的非零元素来计算损失。实现如下:

def loss_fn(ytrue, ypred):
    scce = tf.keras.losses.SparseCategoricalCrossentropy(
        from_logits=True)
    mask = tf.math.logical_not(tf.math.equal(ytrue, 0))
    mask = tf.cast(mask, dtype=tf.int64)
    loss = scce(ytrue, ypred, sample_weight=mask)
    return loss 

由于 seq2seq 模型不容易打包成一个简单的 Keras 模型,我们还需要手动处理训练循环。我们的 train_step() 函数处理数据流,并在每一步计算损失,应用损失的梯度回到可训练的权重上,并返回损失。

请注意,训练代码与我们之前讨论的 seq2seq 模型中描述的有所不同。在这里,似乎整个 decoder_input 一次性输入到解码器中,以产生偏移一个时间步长的输出,而在讨论中,我们说这发生是按顺序的,其中前一个时间步生成的标记作为下一个时间步的输入。

这是一种常用的技术,称为教师强迫(Teacher Forcing),其中解码器的输入是实际的输出,而不是来自上一个时间步的预测。这样做的优势是可以加速训练,但也可能导致预测质量的下降。为了解决这个问题,可以使用定时采样(Scheduled Sampling)等技术,在这种技术中,输入会根据某个阈值从实际输出或上一个时间步的预测中随机选择(这个阈值依赖于问题,通常在 0.1 到 0.4 之间):

@tf.function
def train_step(encoder_in, decoder_in, decoder_out, encoder_state):
    with tf.GradientTape() as tape:
        encoder_out, encoder_state = encoder(encoder_in, encoder_state)
        decoder_state = encoder_state
        decoder_pred, decoder_state = decoder(
            decoder_in, decoder_state)
        loss = loss_fn(decoder_out, decoder_pred)

    variables = (encoder.trainable_variables + 
        decoder.trainable_variables)
    gradients = tape.gradient(loss, variables)
    optimizer.apply_gradients(zip(gradients, variables))
    return loss 

predict()方法用于从数据集中随机选择一个英语句子,并利用目前训练的模型预测法语句子。为了参考,标签的法语句子也会显示出来。evaluate()方法计算双语评估准则(BiLingual Evaluation Understudy)BLEU)分数[35],该分数衡量测试集中的所有记录的标签和预测之间的差异。BLEU 分数通常用于多个地面真实标签存在的情况(我们这里只有一个),并比较参考句子和候选句子中的最多 4-gram(n-gram 中的n=4)。predict()evaluate()方法在每个 epoch 结束时都会被调用:

def predict(encoder, decoder, batch_size,
        sents_en, data_en, sents_fr_out,
        word2idx_fr, idx2word_fr):
    random_id = np.random.choice(len(sents_en))
    print("input    : ",  " ".join(sents_en[random_id]))
    print("label    : ", " ".join(sents_fr_out[random_id]))
    encoder_in = tf.expand_dims(data_en[random_id], axis=0)
    decoder_out = tf.expand_dims(sents_fr_out[random_id], axis=0)
    encoder_state = encoder.init_state(1)
    encoder_out, encoder_state = encoder(encoder_in, encoder_state)
    decoder_state = encoder_state
    decoder_in = tf.expand_dims(
        tf.constant([word2idx_fr["BOS"]]), axis=0)
    pred_sent_fr = []
    while True:
        decoder_pred, decoder_state = decoder(
            decoder_in, decoder_state)
        decoder_pred = tf.argmax(decoder_pred, axis=-1)
        pred_word = idx2word_fr[decoder_pred.numpy()[0][0]]
        pred_sent_fr.append(pred_word)
        if pred_word == "EOS":
            break
        decoder_in = decoder_pred

    print("predicted: ", " ".join(pred_sent_fr))
def evaluate_bleu_score(encoder, decoder, test_dataset,
        word2idx_fr, idx2word_fr):
    bleu_scores = []
    smooth_fn = SmoothingFunction()
    for encoder_in, decoder_in, decoder_out in test_dataset:
        encoder_state = encoder.init_state(batch_size)
        encoder_out, encoder_state = encoder(encoder_in, encoder_state)
        decoder_state = encoder_state
        decoder_pred, decoder_state = decoder(
            decoder_in, decoder_state)
        # compute argmax
        decoder_out = decoder_out.numpy()
        decoder_pred = tf.argmax(decoder_pred, axis=-1).numpy()
        for i in range(decoder_out.shape[0]):
            ref_sent = [idx2word_fr[j] for j in 
                decoder_out[i].tolist() if j > 0]
            hyp_sent = [idx2word_fr[j] for j in 
                decoder_pred[i].tolist() if j > 0]
            # remove trailing EOS
            ref_sent = ref_sent[0:-1]
            hyp_sent = hyp_sent[0:-1]
            bleu_score = sentence_bleu([ref_sent], hyp_sent,
                smoothing_function=smooth_fn.method1)
            bleu_scores.append(bleu_score)
    return np.mean(np.array(bleu_scores)) 

训练循环如下所示。我们将使用 Adam 优化器进行模型训练。我们还设置了检查点,以便在每 10 个 epoch 后保存我们的模型。然后,我们训练模型 250 个 epoch,并打印出损失、一个示例句子及其翻译,以及在整个测试集上计算的 BLEU 分数:

optimizer = tf.keras.optimizers.Adam()
checkpoint_prefix = os.path.join(checkpoint_dir, "ckpt")
checkpoint = tf.train.Checkpoint(optimizer=optimizer,
                                 encoder=encoder,
                                 decoder=decoder)
num_epochs = 250
eval_scores = []
for e in range(num_epochs):
    encoder_state = encoder.init_state(batch_size)
    for batch, data in enumerate(train_dataset):
        encoder_in, decoder_in, decoder_out = data
        # print(encoder_in.shape, decoder_in.shape, decoder_out.shape)
        loss = train_step(
            encoder_in, decoder_in, decoder_out, encoder_state)

    print("Epoch: {}, Loss: {:.4f}".format(e + 1, loss.numpy()))
    if e % 10 == 0:
        checkpoint.save(file_prefix=checkpoint_prefix)

    predict(encoder, decoder, batch_size, sents_en, data_en,
        sents_fr_out, word2idx_fr, idx2word_fr)
    eval_score = evaluate_bleu_score(encoder, decoder, 
        test_dataset, word2idx_fr, idx2word_fr)
    print("Eval Score (BLEU): {:.3e}".format(eval_score))
    # eval_scores.append(eval_score)
checkpoint.save(file_prefix=checkpoint_prefix) 

以下是训练的前 5 个和最后 5 个 epoch 的结果。注意到,损失从大约 1.5 降到 247 epoch 时的约 0.07。BLEU 分数也提高了约 2.5 倍。然而,最令人印象深刻的是前 5 个和最后 5 个 epoch 之间的翻译质量差异:

Epoch-# Loss (Training) BLEU Score (Test) 英文 法文(真实) 法文(预测)
1 1.4119 1.957e-02 汤姆很特别。 tom est special. elle est tres bon.
2 1.1067 2.244e-02 他讨厌购物。 il deteste faire les courses. il est tres mineure.
3 0.9154 2.700e-02 她说了吗? l a t elle dit? n est ce pas clair?
4 0.7817 2.803e-02 我宁愿走路。 je prefererais marcher. je suis alle a kyoto.
5 0.6632 2.943e-02 我在车里。 je suis dans la voiture. je suis toujours inquiet.
...
245 0.0896 4.991e-02 她起诉了他。 elle le poursuivit en justice. elle l a poursuivi en justice.
246 0.0853 5.011e-02 她不穷。 elle n est pas pauvre. elle n est pas pauvre.
247 0.0738 5.022e-02 哪一个是我的? lequel est le mien? lequel est le mien?
248 0.1208 4.931e-02 我在变老。 je me fais vieux. je me fais vieux.
249 0.0837 4.856e-02 值得一试。 ca valait le coup d essayer. ca valait le coup d essayer.
250 0.0967 4.869e-02 不要退缩。 ne reculez pas! ne reculez pas!

表 5.2:按训练轮次的训练结果

这个例子的完整代码可以在本章附带的源代码中找到。你需要一台基于 GPU 的计算机来运行它,尽管通过使用较小的网络维度(embedding_dimencoder_dimdecoder_dim),较小的超参数(batch_sizenum_epochs)和较少的句子对,可能能够在 CPU 上运行它。要完整运行代码,执行以下命令。输出将写入控制台:

$ python seq2seq_wo_attn.py 

在接下来的部分,我们将介绍一种通过让 seq2seq 网络以数据驱动的方式更加关注输入的某些部分,从而提高性能的机制。这种机制被称为注意力机制。

注意力机制

在上一部分,我们看到来自编码器最后时间步的上下文或思维向量被作为初始隐藏状态输入到解码器中。随着上下文通过解码器的时间步流动,信号与解码器输出结合,并逐渐变弱。结果是,上下文对解码器后续时间步的影响变得较小。

此外,解码器输出的某些部分可能会更依赖于输入的某些部分。例如,考虑输入“thank you very much”以及相应的输出“merci beaucoup”——这是我们在上一部分看到的英法翻译网络的一个例子。在这里,英语短语“thank you”和“very much”分别对应法语的“merci”和“beaucoup”。这些信息通过单一的上下文向量无法得到充分传递。

注意力机制在解码器的每个时间步提供对所有编码器隐藏状态的访问。解码器学*在编码器状态中该关注哪一部分。使用注意力机制显著提高了机器翻译的质量,并对各种标准的自然语言处理任务产生了巨大改进。

注意力机制的使用不限于 seq2seq 网络。例如,注意力是“Embed, Encode, Attend, Predict”公式中的一个关键组成部分,用于创建最先进的深度学*模型进行自然语言处理 [34]。在这里,注意力机制被用来尽可能保留信息,在从更大的表示缩减到更紧凑的表示时,举例来说,当将一系列词向量缩减为一个单一的句子向量时。

本质上,注意力机制提供了一种为目标中的令牌打分的方式,评估与源中所有令牌的关系,并相应地修改传递给解码器的输入信号。考虑一个编码器-解码器架构,其中输入和输出时间步由索引ij表示,编码器和解码器在这些时间步的隐藏状态分别由h[i]和s[j]表示。编码器的输入由x[i]表示,解码器的输出由y[j]表示。在没有注意力的编码器-解码器网络中,解码器状态s[j]的值由上一时间步的隐藏状态s[j][-1]和输出y[j][-1]给出。注意力机制添加了一个第三信号c[j],称为注意力上下文。因此,使用注意力后,解码器的隐藏状态s[j]是y[j][-1]、s[j][-1]和c[j]*的函数,如下所示:

注意力上下文信号c[j]的计算方法如下。对于每一个解码器步j,我们计算解码器状态s[j][-1]与每个编码器状态h[i]之间的对齐度。这为每个解码器状态j提供了一组N个相似度值e[ij],然后我们通过计算它们的 softmax 值b[ij]来将其转换为一个概率分布。最后,注意力上下文c[j]作为加权和计算,其中加权系数是编码器状态h[i]及其相应的 softmax 权重b[ij],涵盖了所有N个编码器时间步。以下方程组表示了每个解码器步骤j的这一转换:

基于对齐方式,已经提出了多种注意力机制。接下来我们将描述几种。为了便于记法,我们将编码器端的状态向量h[i]表示为h,将解码器端的状态向量s[j][-1]表示为s

对齐的最简单形式是基于内容的注意力。它由 Graves、Wayne 和 Danihelka [27] 提出,简单来说,就是编码器和解码器状态之间的余弦相似度。使用这种形式的先决条件是编码器和解码器的隐藏状态向量必须具有相同的维度:

另一种形式,称为加性Bahdanau 注意力,是由 Bahdanau、Cho 和 Bengio [28] 提出的。它通过在一个小型神经网络中使用可学*的权重来组合状态向量,公式如下所示。在这里,sh向量被连接并与学*的权重W相乘,这等价于使用两个学*的权重W[s]和W[h]分别与sh相乘,并将结果相加:

Luong、Pham 和 Manning [29] 提出了一组三种注意力形式(点积、一般形式和拼接形式),其中一般形式也称为乘法Luong 的注意力

dotconcat 注意力的公式类似于前面讨论的基于内容和加性注意力的公式。乘性注意力的公式如下所示:

最后,Vaswani 等人 [30] 提出了基于内容的注意力机制的变种,称为缩放点积注意力,其公式如下所示。这里,N 是编码器隐藏状态 h 的维度。缩放点积注意力用于 Transformer 架构,我们将在下一章学*:

注意力机制还可以根据其关注的对象进行分类。使用这种分类方案,注意力机制可以是自注意力、全局或软注意力,以及局部或硬注意力。

自注意力是指在同一序列的不同部分之间计算对齐,并已被证明对机器阅读、抽象文本摘要和图像字幕生成等应用非常有用。

软注意力或全局注意力是指对整个输入序列计算对齐,而硬注意力或局部注意力是指对部分序列计算对齐。软注意力的优点是它是可微分的;然而,它的计算代价可能很高。相反,硬注意力在推理时计算更便宜,但它是不可微分的,并且在训练时需要更复杂的技术。

在下一节中,我们将看到如何将注意力机制与 seq2seq 网络集成,以及它如何提高性能。

示例 ‒ 带有注意力机制的 seq2seq 用于机器翻译

让我们看一下本章前面看到的相同的机器翻译示例,只不过解码器现在将使用 Bahdanau 等人 [28] 提出的加性注意力机制,以及 Luong 等人 [29] 提出的乘性注意力机制来关注编码器输出。

第一个变化是对编码器的改动。它不再返回单一的上下文或思想向量,而是会在每个时间点返回输出,因为注意力机制需要这些信息。以下是突出显示的修改后的编码器类:

class Encoder(tf.keras.Model):
     def __init__(self, vocab_size, num_timesteps,
           embedding_dim, encoder_dim, **kwargs):
        super(Encoder, self).__init__(**kwargs)
        self.encoder_dim = encoder_dim
        self.embedding = tf.keras.layers.Embedding(
            vocab_size, embedding_dim, input_length=num_timesteps)
        self.rnn = tf.keras.layers.GRU(
            encoder_dim, return_sequences=True, return_state=True)
    def call(self, x, state):
        x = self.embedding(x)
        x, state = self.rnn(x, initial_state=state)
        return x, state
    def init_state(self, batch_size):
        return tf.zeros((batch_size, self.encoder_dim)) 

解码器将进行更大的改动。最大的变化是注意力层的声明,需要先定义它们。首先,我们考虑 Bahdanau 提出的加性注意力的类定义。回顾一下,这将解码器在每个时间步的隐藏状态与所有编码器隐藏状态结合,以产生下一时间步解码器的输入,公式如下:

方程中的W [s;h]是两个单独线性变换的简写(形式为y = Wx + b),一个作用于s,另一个作用于h。这两个线性变换被实现为全连接层,如下实现所示。我们继承了tf.keras的 Layer 对象,因为我们的最终目标是将其用作网络中的一层,但继承 Model 对象也是可以接受的。call()方法接受查询(解码器状态)和值(编码器状态),计算分数,然后根据对应的 softmax 计算对齐,并根据方程给出上下文向量,最后返回它们。上下文向量的形状为(batch_sizenum_decoder_timesteps),对齐的形状为(batch_sizenum_encoder_timesteps1)。全连接层的W1W2V张量的权重在训练过程中学*:

class BahdanauAttention(tf.keras.layers.Layer):
    def __init__(self, num_units):
        super(BahdanauAttention, self).__init__()
        self.W1 = tf.keras.layers.Dense(num_units)
        self.W2 = tf.keras.layers.Dense(num_units)
        self.V = tf.keras.layers.Dense(1)
    def call(self, query, values):
        # query is the decoder state at time step j
        # query.shape: (batch_size, num_units)
        # values are encoder states at every timestep i
        # values.shape: (batch_size, num_timesteps, num_units)
        # add time axis to query: (batch_size, 1, num_units)
        query_with_time_axis = tf.expand_dims(query, axis=1)
        # compute score:
        score = self.V(tf.keras.activations.tanh(
            self.W1(values) + self.W2(query_with_time_axis)))
        # compute softmax
        alignment = tf.nn.softmax(score, axis=1)
        # compute attended output
        context = tf.reduce_sum(
            tf.linalg.matmul(
                tf.linalg.matrix_transpose(alignment),
                values
            ), axis=1
        )
        context = tf.expand_dims(context, axis=1)
        return context, alignment 

Luong 注意力是乘法形式的,但其一般实现类似。我们不再声明三个线性变换W1W2V,而是只有一个Wcall()方法中的步骤遵循相同的一般步骤——首先,我们根据 Luong 注意力的方程计算分数,如上一节所述。然后,我们计算对齐作为分数的对应 softmax 版本,再计算上下文向量作为对齐与值的点积。与 Bahdanau 注意力类中的权重一样,表示全连接层W的权重矩阵在训练过程中学*:

class LuongAttention(tf.keras.layers.Layer):
    def __init__(self, num_units):
        super(LuongAttention, self).__init__()
        self.W = tf.keras.layers.Dense(num_units)
    def call(self, query, values):
        # add time axis to query
        query_with_time_axis = tf.expand_dims(query, axis=1)
        # compute score
        score = tf.linalg.matmul(
            query_with_time_axis, self.W(values), transpose_b=True)
        # compute softmax
        alignment = tf.nn.softmax(score, axis=2)
        # compute attended output
        context = tf.matmul(alignment, values)
        return context, alignment 

为了验证这两个类是否可以互相替代,我们运行以下这段临时代码(在本示例的源代码中已被注释掉)。我们只需制造一些随机输入并将其发送给两个注意力类:

batch_size = 64
num_timesteps = 100
num_units = 1024
query = np.random.random(size=(batch_size, num_units))
values = np.random.random(size=(batch_size, num_timesteps, num_units))
# check out dimensions for Bahdanau attention
b_attn = BahdanauAttention(num_units)
context, alignments = b_attn(query, values)
print("Bahdanau: context.shape:", context.shape, 
    "alignments.shape:", alignments.shape)
# check out dimensions for Luong attention
l_attn = LuongAttention(num_units)
context, alignments = l_attn(query, values)
print("Luong: context.shape:", context.shape, 
    "alignments.shape:", alignments.shape) 

上述代码产生了以下输出,并如预期所示,两个类在给定相同输入时生成了相同形状的输出。因此,它们可以互相替代:

Bahdanau: context.shape: (64, 1024) alignments.shape: (64, 8, 1)
Luong: context.shape: (64, 1024) alignments.shape: (64, 8, 1) 

现在我们有了注意力类,让我们看看解码器。init()方法中的区别在于增加了注意力类变量,我们已将其设置为BahdanauAttention类。此外,我们还有两个附加的变换,WcWs,它们将应用于解码器 RNN 的输出。第一个变换使用tanh激活函数,将输出调节到-1 和+1 之间,接下来的变换是标准的线性变换。与没有注意力解码器组件的 seq2seq 网络相比,这个解码器在call()方法中接受额外的参数encoder_output,并返回一个额外的上下文向量:

class Decoder(tf.keras.Model):
    def __init__(self, vocab_size, embedding_dim, num_timesteps,
            decoder_dim, **kwargs):
        super(Decoder, self).__init__(**kwargs)
        self.decoder_dim = decoder_dim
        self.attention = BahdanauAttention(embedding_dim)
        # self.attention = LuongAttention(embedding_dim)

        self.embedding = tf.keras.layers.Embedding(
            vocab_size, embedding_dim, input_length=num_timesteps)
        self.rnn = tf.keras.layers.GRU(
            decoder_dim, return_sequences=True, return_state=True)
        self.Wc = tf.keras.layers.Dense(decoder_dim, activation="tanh")
        self.Ws = tf.keras.layers.Dense(vocab_size)
    def call(self, x, state, encoder_out):
        x = self.embedding(x)
        context, alignment = self.attention(x, encoder_out)
        x = tf.expand_dims(
                tf.concat([
                    x, tf.squeeze(context, axis=1)
                ], axis=1),
            axis=1)
        x, state = self.rnn(x, state)
        x = self.Wc(x)
        x = self.Ws(x)
        return x, state, alignment 

训练循环也有所不同。与没有注意力机制的 seq2seq 网络不同,在该网络中我们使用教师强制(teacher forcing)来加速训练,而使用注意力机制意味着我们现在必须逐个消费解码器的输入。这是因为前一步的解码器输出通过注意力机制对当前时间步的输出影响更大。我们新的训练循环由下面的train_step函数描述,且比没有注意力机制的 seq2seq 网络的训练循环要慢得多。然而,这种训练循环也可以用于前述网络,特别是在我们想要实现计划性采样策略时:

@tf.function
def train_step(encoder_in, decoder_in, decoder_out, encoder_state):
    with tf.GradientTape() as tape:
        encoder_out, encoder_state = encoder(encoder_in, encoder_state)
        decoder_state = encoder_state
        loss = 0
        for t in range(decoder_out.shape[1]):
            decoder_in_t = decoder_in[:, t]
            decoder_pred_t, decoder_state, _ = decoder(decoder_in_t,
                decoder_state, encoder_out)
            loss += loss_fn(decoder_out[:, t], decoder_pred_t)
    variables = (encoder.trainable_variables +
        decoder.trainable_variables)
    gradients = tape.gradient(loss, variables)
    optimizer.apply_gradients(zip(gradients, variables))
    return loss / decoder_out.shape[1] 

predict()evaluate() 方法也有类似的变化,因为它们同样实现了新的数据流,涉及到额外的 encoder_out 参数和额外的 context 返回值。

我们训练了两种版本的带注意力机制的 seq2seq 网络,一种是加性(Bahdanau)注意力机制,另一种是乘性(Luong)注意力机制。两种网络都训练了 50 个周期,而不是 250 个周期。然而,在这两种情况下,产生的翻译质量与训练了 250 个周期的没有注意力机制的 seq2seq 网络相似。使用注意力机制的 seq2seq 网络的训练损失稍微低一些,测试集上的 BLEU 得分略高于没有注意力机制的 seq2seq 网络:

网络描述 训练集最终损失 测试集最终 BLEU 得分
没有注意力机制的 seq2seq,训练 250 个周期 0.0967 4.869e-02
使用加性注意力机制的 seq2seq,训练 50 个周期 0.0893 5.508e-02
使用乘性注意力机制的 seq2seq,训练 50 个周期 0.0706 5.563e-02

表 5.3: 不同方法的 BLEU 得分

以下是由两种网络产生的翻译示例。每个示例都提到训练的周期数和使用的注意力类型。注意,即使翻译结果与标签不完全相同,许多翻译仍然是原文的有效翻译:

注意力类型 周期数 英语 法语(标签) 法语(预测)
Bahdanau 20 your cat is fat. ton chat est gras. ton chat est mouille.
25 i had to go back. il m a fallu retourner. il me faut partir.
30 try to find it. tentez de le trouver. tentez de le trouver.
Luong 20 that s peculiar. c est etrange. c est deconcertant.
25 tom is athletic. thomas est sportif. tom est sportif.
30 it s dangerous. c est dangereux. c est dangereux.

表 5.4: 英语到法语的翻译示例

这里描述的网络的完整代码在本章的代码文件夹中的seq2seq_with_attn.py文件中。要从命令行运行代码,请使用以下命令。你可以通过注释掉Decoder类的init()方法中的一个或另一个来切换使用 Bahdanau(加性)或 Luong(乘性)注意力机制:

$ python seq2seq_with_attn.py 

摘要

在这一章中,我们学*了 RNN(递归神经网络),一种专门处理序列数据的网络类型,如自然语言、时间序列、语音等。就像 CNN 利用图像的几何结构一样,RNN 利用其输入的序列结构。我们学*了基本的 RNN 单元,它如何处理来自前一个时间步的状态,以及由于 BPTT(反向传播通过时间)的固有问题,它如何遭遇梯度消失和梯度爆炸的问题。我们看到这些问题导致了新型 RNN 单元架构的出现,如 LSTM、GRU 和窥视 LSTM。我们还学*了一些让 RNN 更有效的简单方法,比如使其成为双向或有状态的。

接着,我们讨论了不同的 RNN 拓扑结构,以及每种拓扑如何适应特定问题。经过大量理论讲解后,我们最终看到了这三种拓扑的实例。然后我们专注于其中一种拓扑,称为 seq2seq,这一拓扑最初在机器翻译领域获得了广泛应用,但此后也被用于那些可以适应为类似机器翻译问题的场景中。

接下来,我们讨论了注意力机制,它最初是为了提高 seq2seq 网络的性能而提出的,但此后它在许多场合中被非常有效地使用,尤其是当我们希望在尽量减少数据丢失的同时压缩表示时。我们学*了不同类型的注意力机制,并且给出了在带有注意力机制的 seq2seq 网络中使用它们的实例。

在下一章,你将学*关于 transformers(变压器)的内容,这是一种最先进的编码器-解码器架构,其中递归层已被注意力层替代。

参考文献

  1. Jozefowicz, R., Zaremba, R. 和 Sutskever, I. (2015). 递归神经网络架构的经验探索。机器学*学报

  2. Greff, K., 等人. (2016 年 7 月). LSTM:一个搜索空间的奥德赛。IEEE 神经网络与学*系统学报

  3. Bernal, A., Fok, S., 和 Pidaparthi, R. (2012 年 12 月). 使用递归神经网络进行金融市场时间序列预测

  4. Hadjeres, G., Pachet, F., 和 Nielsen, F. (2017 年 8 月). DeepBach: 一种可控制的巴赫合唱生成模型。第 34 届国际机器学*大会(ICML)论文集

  5. Karpathy, A. (2015). 递归神经网络的非凡效果。网址: karpathy.github.io/2015/05/21/rnn-effectiveness/

  6. Karpathy, A., Li, F. (2015). 深度视觉-语义对齐用于生成图像描述。模式识别与计算机视觉会议 (CVPR)

  7. Socher, 等. (2013). 情感组成树上情感合成的递归深度模型。2013 年实证方法自然语言处理会议(EMNLP)论文集

  8. Bahdanau, D., Cho, K., 和 Bengio, Y. (2015). 通过联合学*对齐和翻译进行神经机器翻译。arXiv: 1409.0473 [cs.CL]

  9. Wu, Y., 等. (2016). Google 的神经机器翻译系统:弥合人类与机器翻译之间的差距。arXiv 1609.08144 [cs.CL]

  10. Vinyals, O., 等. (2015). 语法作为外语。神经信息处理系统进展(NIPS)

  11. Rumelhart, D. E., Hinton, G. E., 和 Williams, R. J. (1985). 通过误差传播学*内部表示。并行分布式处理:认知微结构探索

  12. Britz, D. (2015). 递归神经网络教程,第三部分 - 通过时间的反向传播和梯度消失问题: www.wildml.com/2015/10/recurrent-neural-networks-tutorial-part-3-backpropagation-through-time-and-vanishing-gradients/

  13. Pascanu, R., Mikolov, T., 和 Bengio, Y. (2013). 训练递归神经网络的困难。第 30 届国际机器学*大会(ICML)论文

  14. Hochreiter, S., 和 Schmidhuber, J. (1997). LSTM 能够解决困难的长时间延迟问题。神经信息处理系统进展(NIPS)

  15. Britz, D. (2015). 递归神经网络教程,第四部分 - 使用 Python 和 Theano 实现 GRU/LSTM RNN: www.wildml.com/2015/10/recurrent-neural-network-tutorial-part-4-implementing-a-grulstm-rnn-with-python-and-theano/

  16. Olah, C. (2015). 理解 LSTM 网络: colah.github.io/posts/2015-08-Understanding-LSTMs/

  17. Cho, K., 等. (2014). 使用 RNN 编码器-解码器学*短语表示用于统计机器翻译。arXiv: 1406.1078 [cs.CL]

  18. Shi, X., 等. (2015). 卷积 LSTM 网络:一种用于降水短期预报的机器学*方法。arXiv: 1506.04214 [cs.CV]

  19. Gers, F.A., 和 Schmidhuber, J. (2000). 时间与计数的递归网络。IEEE-INNS-ENNS 国际神经网络联合会议(IJCNN)会议论文

  20. Kotzias, D. (2015). 情感标注句子数据集,作为“从群体到个体标签的深度特征”部分提供(KDD 2015):archive.ics.uci.edu/ml/datasets/Sentiment+Labelled+Sentences

  21. Collobert, R., 等 (2011). 从零开始的自然语言处理。机器学*研究期刊(JMLR)

  22. Marcus, M. P., Santorini, B., 和 Marcinkiewicz, M. A. (1993). 构建大型英语注释语料库:Penn Treebank。计算语言学杂志

  23. Bird, S., Loper, E., 和 Klein, E. (2009). 使用 Python 进行自然语言处理,O'Reilly Media Inc。安装:www.nltk.org/install.xhtml

  24. Liu, C., 等人 (2017). MAT:一种用于图像字幕的多模态注意力翻译器。arXiv: 1702.05658v3 [cs.CV]

  25. Suilin, A. (2017). Kaggle 网络流量时间序列预测。GitHub 仓库:github.com/Arturus/kaggle-web-traffic

  26. Tatoeba Project. (1997-2019). 制表符分隔的双语句对: tatoeba.orgwww.manythings.org/anki

  27. Graves, A., Wayne, G., 和 Danihelka, I. (2014). 神经图灵机。arXiv: 1410.5401v2 [cs.NE]

  28. Bahdanau, D., Cho, K., 和 Bengio, Y. (2015). 通过联合学*对齐和翻译的神经机器翻译。arXiv: 1409.0473v7 [cs.CL]

  29. Luong, M., Pham, H., 和 Manning, C. (2015). 基于注意力的神经机器翻译的有效方法。arXiv: 1508.04025v5 [cs.CL]

  30. Vaswani, A., 等人 (2017). 注意力机制是你所需要的全部。第 31 届神经信息处理系统大会(NeurIPS)

  31. Zhang, A., Lipton, Z. C., Li, M., 和 Smola, A. J. (2019). 深入学*www.d2l.ai

  32. Ba, J. L., Kiros, J. R., 和 Hinton, G. E. (2016). 层归一化。arXiv: 1607.06450v1 [stat.ML]

  33. Allamar, J. (2018). 图解 Transformerjalammar.github.io/illustrated-transformer/

  34. Honnibal, M. (2016). 嵌入、编码、注意、预测:先进自然语言处理模型的新深度学*公式explosion.ai/blog/deep-learning-formula-nlp

  35. Papineni, K., Roukos, S., Ward, T., 和 Zhu, W. (2002). BLEU:一种自动评估机器翻译的方法。计算语言学协会(ACL)第 40 届年会论文集

  36. Project Gutenberg (2019): www.gutenberg.org/

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,与志同道合的人交流,并与超过 2000 名成员一起学*: packt.link/keras

第六章:变换器

基于变换器的架构在自然语言处理NLP)(及其他领域)中几乎已成为解决各种任务的通用方法,例如:

  • 神经机器翻译

  • 文本摘要

  • 文本生成

  • 命名实体识别

  • 问答系统

  • 文本分类

  • 文本相似度

  • 冒犯性信息/脏话检测

  • 查询理解

  • 语言建模

  • 下一句预测

  • 阅读理解

  • 情感分析

  • 意译

以及更多内容。

在不到四年的时间里,谷歌研究团队于 2017 年发布的Attention Is All You Need论文,成功让变换器在自然语言处理(NLP)社区引起轰动,打破了过去三十年间任何记录。

基于变换器的模型使用所谓的注意力机制,识别输入序列(例如句子)中单词之间的复杂关系。注意力机制帮助解决了编码“成对相关性”的挑战——这一点是其“前辈”,如 LSTM RNN 甚至 CNN,在建模顺序数据(如文本)时无法实现的。

模型——例如 BERT、T5 和 GPT(本章稍后会更详细介绍)——如今已经构成了几乎每个领域的新应用的最前沿基础构件,从计算机视觉到语音识别、翻译或蛋白质和编码序列。注意力也已被应用于强化学*中的游戏:例如,DeepMind 的 AlphaStar(rdcu.be/bVI7Gwww.deepmind.com/blog/alphastar-grandmaster-level-in-starcraft-ii-using-multi-agent-reinforcement-learning)中,玩家和对手的《星际争霸》游戏单位的观察被自注意力处理。例如。因此,斯坦福大学最*提出了“基础模型”这一术语,用来定义一组基于巨型预训练变换器的大语言模型LLMs)。

这些进展得益于一些简单的想法,我们将在接下来的几节中进行回顾。

你将会学*:

  • 什么是变换器

  • 它们是如何随时间演变的

  • 一些优化技术

  • 注意事项

  • 未来会是什么样子

让我们开始关注变换器。你会惊讶地发现,注意力其实就是你所需要的全部!

架构

尽管典型的变换器架构通常不同于循环网络的架构,但它基于几个源自 RNN 的关键思想。在撰写本书时,变换器代表了与文本以及任何可以表示为序列的数据相关的深度学*架构的下一个进化步骤,因此,它应该成为你工具箱中的必备部分。

原始 Transformer 架构是编码器-解码器架构的一种变体,其中循环层被(自)注意力层替代。Transformer 最初由 Google 在 2017 年发布的开创性论文《Attention Is All You Need》中提出,论文作者包括 Ashish Vaswani、Noam Shazeer、Niki Parmar、Jakob Uszkoreit、Llion Jones、Aidan N. Gomez、Lukasz Kaiser 和 Illia Polosukhin,arxiv.org/abs/1706.03762,并提供了一个参考实现,我们将在本讨论中使用该实现。

该架构是自 2014-2015 年以来广泛使用的编码器-解码器模型的一个实例(例如 Sutskever 等人(2014)的《序列到序列学*与神经网络》,arxiv.org/abs/1409.3215)。在此之前,注意力机制已经与长短期记忆网络LSTM)以及其他RNN循环神经网络)模型结合使用,相关内容在前一章中有讨论。注意力机制首次出现在 2014 年,由 Bahdanau 等人提出的论文《神经机器翻译:联合学*对齐与翻译》,arxiv.org/abs/1409.0473,并在 2015 年应用于神经机器翻译,见 Luong 等人所写的《基于注意力的神经机器翻译的有效方法》,arxiv.org/abs/1508.04025,同时也有其他注意力机制与不同类型模型的结合。

2017 年,首个 Transformer 模型证明了可以将 LSTM 从神经机器翻译NMT)模型中移除,转而使用所谓的(自)注意力模块(因此论文的标题是《Attention Is All You Need》)。

关键直觉

让我们从定义一些将在本章后面有用的概念开始。2017 年 Transformer 带来的创新主要基于四个关键思想:

  • 位置编码

  • 注意力

  • 自注意力

  • 多头(自)注意力

在接下来的章节中,我们将更详细地讨论这些内容。

位置编码

RNN 通过顺序处理单词来保持词序。这种方法的优点是简单,但其中一个缺点是这使得并行化变得困难(在多个硬件加速器上进行训练)。如果我们想有效地利用高度并行的架构,如 GPUs 和 TPUs,就需要一种替代的方式来表示顺序。

Transformer 使用一种简单的替代顺序表示方法,称为位置编码,将每个单词与表示其在文本中位置的数字关联。例如:

[("Transformers", 1), ("took", 2), ("NLP", 3), ("by", 4), ("storm", 5)] 

关键的直觉是,通过为变压器添加位置信息,模型能够学*每个标记(文本/句子中的一个单词)的位置重要性。注意,位置编码在变压器之前就存在(正如在 RNN 章节中所讨论的那样),但这个直觉在创建基于变压器的模型时尤其重要。在原始变压器论文中引入(绝对)位置编码之后,还出现了其他变体,如相对位置编码(Self-Attention with Relative Position Representations,Shaw 等人,2018 年,arxiv.org/abs/1803.02155),以及旋转位置编码(RoFormer: Enhanced Transformer with Rotary Position Embedding,Su 等人,2021 年,arxiv.org/abs/2104.09864)。

现在我们已经定义了位置编码,让我们把注意力转向注意力机制。

注意力

变压器配方的另一个关键成分是注意力。这个机制最早是在 2014 年,由 Bahdanou 等人首次在机器翻译中引入,见于Neural Machine Translation by Jointly Learning to Align and Translate(Dzmitry Bahdanau, KyungHyun Cho, Yoshua Bengio),arxiv.org/pdf/1409.0473.pdf。一些研究论文还将注意力机制的思想归功于 Alex Graves 的Generating Sequences with Recurrent Neural Networks,这篇论文可追溯到 2013 年,arxiv.org/pdf/1308.0850.pdf

这个成分——这个关键思想——后来成为了第一篇变压器论文标题的一部分,Attention is All You Need。为了获得一个高层次的概述,我们来看看这篇介绍注意力机制的论文中的一个例子:

欧洲经济区协议于 1992 年 8 月签署。

在法语中,这可以翻译为:

L’accord sur la zone économique européenne a été signé en août 1992.

早在 80 年代初期,最初的自动机器翻译尝试是基于逐字翻译的方法。这种方法非常有限,因为文本结构从源语言到目标语言可能有很多不同的变化。例如,在法语翻译中,一些词的顺序可能会发生变化:在英语中,形容词通常位于名词之前,比如“European Economic Area”,而在法语中,形容词可以位于名词之后——“la zone économique européenne”。此外,与英语不同,法语中有性别区分的词汇。例如,形容词“économique”和“européenne”必须使用阴性形式,因为它们属于阴性名词“la zone”。

注意力方法背后的关键直觉是构建一个文本模型,在将源句子中的单词翻译成目标语言时,“查看”源句子中的每个单词。在 2017 年原始的变换器论文中,作者指出,这种做法的计算成本是二次方的,但在翻译准确性上所获得的收益是相当可观的。最*的研究降低了这种初始的二次复杂度,例如 Choromanski 等人(2020)在《Rethinking Attention with Performers》论文中提出的Fast Attention Via Positive Orthogonal RandomFAVOR+)特性,该论文由谷歌、DeepMind、剑桥大学和艾伦·图灵研究所联合发布。

让我们回顾一下 Bahdanou 等人(2014)原始注意力论文中的一个很好的例子:

A picture containing graphical user interface  Description automatically generated

图 6.1:一个关于英语句子“The agreement on the European Economic Area was signed in August 1992.”的注意力示例。该图可视化了“注释权重”——与注释相关的权重。来源:《Neural Machine Translation by Jointly Learning to Align and Translate》Bahdanau 等人(2014)(https://arxiv.org/abs/1409.0473)

使用注意力机制,神经网络可以学*每个源英语单词与每个目标法语单词之间的热力图。请注意,关系不仅仅存在于对角线位置,还可能遍布整个矩阵。例如,当模型输出法语单词“européenne”时,它会特别关注输入单词“European”和“Economic”。(在图 6.1中,这对应于对角线及其相邻单元格。)Bahdanou 等人于 2014 年发表的注意力论文表明,模型(使用带有注意力的 RNN 编码器-解码器框架)能够学*对齐并关注输入元素而无需监督,并且正如图 6.1所示,将输入的英语句子翻译成法语。当然,训练集越大,基于注意力的模型可以学*到的相关性就越多。

简而言之,注意力机制可以访问所有之前的单词,并根据学*到的相关性度量对其进行加权。这样,注意力机制就能提供关于目标句子中远离的词元的相关信息。

现在,我们可以关注变换器的另一个关键部分——“自注意力”。

自注意力

原始的 Transformer 论文中推广的第三个关键思想是使用自注意力机制来处理源语言同一句子中的词汇关系——自注意力。通过这种机制,神经网络可以被训练来学*每个输入序列(例如一句话)中所有单词(或其他元素)之间的关系,而不管它们的位置,随后再集中处理(机器)翻译。自注意力的概念可以追溯到 2016 年 Cheng 等人发表的论文 Long Short-Term Memory-Networks for Machine Readingarxiv.org/pdf/1601.06733.pdf

让我们通过以下两个句子来举个例子:

“Server, can I have the check?”

“看起来我刚刚把服务器搞崩了。”

显然,单词“server”在两种句子中的含义完全不同,而自注意力机制能够理解每个单词,并根据周围单词的上下文来判断它们的含义。再次强调,注意力机制可以访问所有之前的单词,并根据学*到的相关性度量为它们赋予权重。自注意力为位于源句子中较远位置的词汇提供了相关信息。

多头(自)注意力

原始的 Transformer 会多次执行(自)注意力功能。一组被称为权重矩阵的集合(在 如何计算注意力 部分详细介绍)称为一个注意力头。当你拥有多组这些矩阵时,就有了多个注意力头。多头(自)注意力层通常有多个并行的(自)注意力层。注意,引入多个头的做法使我们能够定义哪些单词彼此之间是“相关”的。而且,所有这些相关性定义可以通过现代硬件加速器并行计算,从而加速计算过程。

现在我们已经浏览了 Transformer 关键元素的高层定义,接下来我们将深入探讨如何计算注意力机制。

如何计算注意力

在原始的 Transformer 中,自注意力功能是通过使用所谓的缩放点积单元来计算的。2017 年论文的作者甚至称他们的注意力方法为 缩放点积注意力。你可能还记得高中时学过的,两个向量之间的点积可以很好地判断这两个向量有多“接*”。

每个输入的令牌序列(例如一句话)的嵌入经过 Transformer(编码器和/或解码器)后,会产生 注意力权重(下文会详细介绍),这些权重是同时计算的,计算过程涵盖了每个序列元素(如单词)之间的关系。最终的输出会为每个令牌生成嵌入,这些嵌入包含令牌本身以及与其相关的其他令牌,且每个令牌的权重由其相对的注意力权重决定。

注意力层将输入向量转换为查询、键和值矩阵,然后将其拆分为注意力头(因此是多头注意力):

  • 查询词可以解释为我们正在计算注意力函数的“目标”词。

  • 关键字和价值词是我们正在关注的词。

点积(下面将进一步解释)告诉我们词语之间的相似度。如果两个词的向量更对齐,注意力得分会更高。Transformer 将以这样的方式学*权重,即如果一个句子中的两个词相互相关,那么它们的词向量将会对齐。

每个注意力层学*三个权重矩阵:

  • 查询权重 W[Q]

  • 关键权重 W[K]

  • 值权重 W[V]

对于每个单词 i,计算一个输入词嵌入 x[i],得到:

  • 一个查询向量 q[i] = x[i]W[Q]

  • 一个关键向量 k[i] = x[i]W[K]

  • 一个值向量 v[i] = x[i]W[V]

给定查询和相应的关键向量,以下的点积公式生成了原始 transformer 论文中的 注意力权重

其中:

  • a[i,j] 是单词 i 到单词 j 的注意力。

  • . 是查询与关键字的点积,这将给我们一个感觉,表示向量之间有多“接*”。

注意,单词 i 的注意力单元是所有单词值向量的加权和,权重由 a[i,j] 表示,即单词 i 到单词 j 的注意力。

现在,为了在训练过程中稳定梯度,注意力权重被除以关键向量维度的平方根

然后,结果通过 softmax 函数进行归一化处理。注意,单词 i 到单词 j 的注意力函数与单词 j 到单词 i 的注意力函数不同。

注意,由于现代深度学*加速器与矩阵的兼容性较好,我们可以使用大矩阵为所有词计算注意力。

定义 q[i]、k[i]、v[i](其中 i 是第 i 行)为矩阵 QKV,分别对应。然后,我们可以将注意力函数总结为一个注意力矩阵:

本节讨论了如何计算原始 transformer 论文中介绍的注意力函数。接下来,我们将讨论编码器-解码器架构。

编码器-解码器架构

类似于 seq2seq 模型,(Sequence to Sequence Learning with Neural Networks 由 Ilya Sutskever、Oriol Vinyals、Quoc V. Le(2014)所描述) 中的 第五章循环神经网络,原始的 transformer 模型也使用了编码器-解码器架构:

  • 编码器接受输入(源)嵌入序列,并将其转换为一个新的固定长度的输入嵌入向量。

  • 解码器获取来自编码器的输出嵌入向量,并将其转化为一系列输出嵌入。

  • 编码器和解码器都由多个堆叠的层组成。每个编码器和解码器层都使用前面描述的注意力机制。

我们将在本节的后面更加详细地了解 transformer 架构。

自从引入 transformer 架构以来,其他一些更新的网络只使用了编码器或解码器组件(或两者),这些内容将在本章的transformer 分类部分中讨论。

接下来,让我们简要回顾一下原始 transformer 的其他组件——残差和归一化层。

残差连接和归一化层

通常,基于 transformer 的网络会重用其他现有的最先进的机器学*方法,例如注意力机制。因此,你不必惊讶于编码器和解码器层将神经网络与残差连接(He 等人提出的深度残差学*用于图像识别,2016 年,arxiv.org/abs/1512.03385)和归一化步骤(Ba 等人提出的层归一化,2016 年,arxiv.org/abs/1607.06450)相结合。

好的,现在我们已经具备了深入研究 transformer 的所有关键要素。

Transformer 架构概述

现在我们已经涵盖了原始 transformer 的一些关键概念,让我们深入探讨 2017 年开创性论文中介绍的架构。请注意,基于 transformer 的模型通常通过利用各种注意力机制而不使用 RNN 来构建。这也是由于注意力机制本身能够与 RNN(编码器-解码器)模型的注意力机制相匹配并超越它们。因此,这篇开创性论文的标题是Attention is all You Need

图 6.2展示了一个带有 RNN 和注意力的 seq2seq 网络,并与原始的 transformer 网络进行了比较。

Transformer 与 seq2seq 加注意力模型在以下方面相似:

  • 两种方法都处理源(输入)和目标(输出)序列

  • 如前所述,两者都使用了编码器-解码器架构。

  • 编码器最后一个块的输出作为上下文或思维向量,用于计算解码器中的注意力函数。

  • 目标(输出)序列的嵌入被输入到密集(全连接)块中,这些块将输出嵌入转换为最终的整数形式序列:

图示 说明自动生成

图 6.2:数据流在(a)seq2seq + Attention 和(b)Transformer 架构中的流动。图像来源:Zhang 等人。

这两种架构在以下方面有所不同:

  • seq2seq 网络在编码器中使用循环层和注意力层,在解码器中使用循环层。

    Transformer 用所谓的 transformer 块(N 个相同层的堆叠)替代了这些层,正如图 6.2所示:

    • 在编码器中,transformer 块由一系列子层组成:一个多头(自)注意力层和一个位置逐一的前馈层。每一层都有一个残差连接,之后是一个归一化层。

    • 在解码器中,Transformer 块包含一个变种的多头(自)注意力层,带有 掩码— 掩码多头自注意力层,以及一个与编码器中类似的前馈层(具有相同的残差连接和归一化层)。掩码有助于防止位置关注未来。此外,解码器包含第二个多头(自)注意力层,用于计算对编码器 Transformer 块输出的关注(掩码将在本节稍后详细介绍)。

  • 在带有注意力机制的 seq2seq 网络中,编码器状态传递到第一个递归时间步,就像在带有注意力机制的 seq2seq 网络中一样。

    在 Transformer 中,编码器状态会传递到解码器中的每个 Transformer 块。这允许 Transformer 网络在时间步之间并行工作,因为不像 seq2seq 网络那样存在时间依赖。

    最后的解码器后跟一个最终的线性变换(一个全连接层),并使用 softmax 函数生成输出(下一个标记)的概率。

  • 由于前述提到的并行性,添加了一个编码层来提供位置信息,以区分 Transformer 网络序列中每个元素的位置(位置编码层)。这样,第一个编码器将输入序列的位置信息和嵌入作为输入,而不仅仅是编码,从而考虑到位置信息。

让我们一步步地了解数据如何通过 Transformer 网络流动。在本章后续部分,我们将使用 TensorFlow 和 Keras API 从头开始创建并训练一个 Transformer 模型:

  1. 作为数据预处理的一部分,输入和输出会被分词并转换为嵌入。

  2. 接下来,位置编码被应用于输入和输出嵌入,以获得序列中标记的相对位置信息。在编码器部分:

    • 根据 图 6.2,编码器部分由一个嵌入层和一个位置编码层组成,后面跟着六个相同的 Transformer 块(原始 Transformer 中有六个“层”)。正如我们之前所学,每个编码器中的 Transformer 块由一个多头(自)注意力层和一个位置逐元素前馈层组成。

    我们已经简要看到自注意力是关注同一序列部分的过程。当我们处理一个句子时,我们可能希望知道与当前单词最相关的其他单词是什么。

    • 多头自注意力层由多个(在开创性论文中参考实现为 8 个)并行的自注意力层组成。自注意力通过构建三个向量 Q(查询),K(键),和 V(值)来实现,这些向量是从输入嵌入中构造的。通过将输入嵌入与三个可训练的权重矩阵 W[Q],W[K],和 W[V] 相乘来创建这些向量。输出向量 Z 是通过结合每个自注意力层中的 KQV,并使用以下公式生成的。这里,d[K] 是 KQV 向量的维度(在参考实现中为 64):

    • 多头自注意力层会为 Z 创建多个值(基于每个自注意力层中多个可训练的权重矩阵 W[Q],W[K] 和 W[V]),然后将它们连接起来,作为位置-wise 前馈层的输入。

    • 位置-wise 前馈层的输入由序列中不同元素(或句子中的单词)的嵌入表示组成,这些嵌入通过多头自注意力层的自注意力机制进行处理。每个标记在内部由一个固定长度的嵌入向量表示(在开创性论文中,参考实现中为 512)。每个向量都会并行地通过前馈层。FFN 的输出是下一个 transformer 块中多头自注意力层的输入(或被送入该层)。在编码器的最后一个 transformer 块中,输出是传递给解码器的上下文向量。

    • 多头自注意力层和位置-wise FFN 层不仅传递来自前一层的信号,还会将来自输入的残差信号传递到它们的输出中。输出和残差输入会经过一个层归一化步骤,这在图 6.2中作为“Add & Norm”层展示。

    • 由于整个序列在编码器中是并行处理的,因此单个元素的位置信息会丢失。为了弥补这一点,输入的嵌入向量被增强了一个位置嵌入,位置嵌入通过一个不带学*参数的正弦函数实现。位置嵌入被加到输入嵌入中。

  3. 接下来,让我们来看看数据是如何在解码器中流动的:

    • 编码器的输出结果是一个由 KV 组成的注意力向量对,它们会并行地传送到解码器中的所有 transformer 块。解码器中的 transformer 块与编码器中的类似,唯一的不同是它增加了一个额外的多头自注意力层,用于处理来自编码器的注意力向量。这个额外的多头自注意力层的工作方式类似于编码器中的那个以及下面的那个,只不过它会将来自下层的 Q 向量与来自编码器状态的 KQ 向量结合。

    • 类似于 seq2seq 网络,输出序列一次生成一个 token,使用来自前一个时间步的输入。与编码器的输入类似,解码器的输入也会通过位置嵌入进行增强。与编码器不同,解码器中的自注意力过程仅允许关注之前时间点的 token。这是通过屏蔽未来时间点的 token 来实现的。

    • 解码器中最后一个变换器块的输出是一个低维嵌入序列(参考文献中提到的原始论文中为 512)。该序列传递给全连接层,将其转换为一个针对目标词汇表的概率分布序列,从中我们可以通过贪婪算法或更复杂的技术(如束搜索)生成最可能的词汇。

图 6.3 展示了涵盖了上述所有内容的变换器架构:

Attention_diagram_transformer

图 6.3:基于 Vaswani 等人(2017 年)《Attention Is All You Need》中的原始图像的变换器架构

训练

变换器通常通过半监督学*分两步训练:

  1. 首先,进行无监督预训练,通常是在非常大的语料库上进行。

  2. 然后,基于较小的标注数据集进行有监督的微调。

预训练和微调可能需要大量的 GPU/TPU 资源、内存和时间。考虑到大型语言模型(简称 LLM)的参数数量在不断增加,尤其如此,正如我们将在下一节中看到的。

有时候,第二阶段只有一小部分标注数据。这就是所谓的少量样本学*,考虑基于有限的样本数量进行预测。

变换器架构

在本节中,我们提供了变换器所使用的最重要架构的高级概述,以及计算注意力的不同方法。

变换器的分类

在本节中,我们将把变换器分类为不同的类别。下一段将介绍最常见的变换器。

解码器或自回归

一个典型的例子是GPT生成预训练模型),你可以在本章稍后的 GPT-2 和 GPT-3 部分了解更多,或者参考openai.com/blog/language-unsupervised。自回归模型仅使用原始变压器模型的解码器,其注意力头只能看到文本中的前部分内容,而看不见后续内容,并在完整句子上使用遮罩机制。自回归模型通过预训练来猜测在观察到所有前面的标记后,下一个标记是什么。通常,自回归模型用于自然语言生成NLG)文本生成任务。其他自回归模型的例子包括原始 GPT、GPT-2、Transformer-XL、Reformer 和 XLNet,稍后将在本章中详细介绍。

编码器或自编码

一个典型的例子是BERT来自变压器的双向编码器表示),将在本章稍后介绍。自编码器对应于原始变压器模型中的编码器,能够访问完整的输入标记,没有遮罩。自编码模型通过遮罩/更改输入标记然后尝试重构原始句子进行预训练。通常,这些模型构建了完整句子的双向表示。需要注意的是,自编码器和自回归模型的唯一区别在于预训练阶段,因此相同的架构可以用于这两种方式。自编码器可用于 NLG,也可用于分类和许多其他 NLP 任务。除了 BERT 外,其他自编码模型的例子包括 ALBERT、RoBERTa 和 ELECTRA,你可以在本章后续部分了解更多。

Seq2seq

一个典型的例子是T5文本到文本转移变压器)和原始变压器。序列到序列模型使用原始变压器架构中的编码器和解码器。Seq2seq 可以针对许多任务进行微调,如翻译、摘要、排序和问答。除了原始变压器和 T5 外,另一个 Seq2seq 模型的例子是多任务统一模型MUM)。

多模态

一个典型的例子是 MUM。多模态模型将文本输入与其他类型的内容(例如图像、视频和音频)混合。

检索

一个典型的例子是 RETRO。一些模型在(预)训练和推理过程中使用文档检索。这通常是减少模型规模、快速访问记忆信息的一种有效策略,从而节省使用的参数数量。

注意力

现在我们已经理解了如何对变压器进行分类,接下来让我们关注注意力机制!

注意力机制有多种类型,比如自注意力、局部/硬注意力和全局/软注意力,下面我们将关注一些例子。

完整与稀疏

如前所述,原始 2017 年 Transformer 论文中的(缩放)点积注意力通常是在一个完整的平方矩阵 O(L²) 上计算的,其中 L 是最大考虑序列的长度(在某些配置中,L = 512)。谷歌研究院在 2020 年提出的 BigBird 类型 Transformer,并在本章后续更详细讨论,提出了通过利用稀疏矩阵来使用稀疏注意力的思想(基于 OpenAI 2019 年发布的论文《利用稀疏 Transformer 生成长序列》,作者为 Child 等人, arxiv.org/abs/1904.10509)。

LSH 注意力

Reformer 提出了通过哈希化来降低注意力机制复杂度的思想——模型的作者称之为局部敏感哈希注意力。该方法基于仅在计算 softmax(QK^T) 时使用最大元素的概念。换句话说,对于每个查询 ,只有接* q 的键 会被计算。为了计算接*度,使用根据局部敏感哈希技术计算的多个哈希函数。

局部注意力

一些 Transformer 模型采用了仅具有局部上下文窗口的思想(例如,右边和左边的几个标记)。其想法是,使用更少的参数允许我们考虑更长的序列,但注意力的程度是有限的。由于这个原因,局部注意力不太流行。

预训练

如你之前所学,原始的 Transformer 具有编码器-解码器架构。然而,研究社区意识到,在某些情况下,仅使用编码器或仅使用解码器,或者同时使用两者是有益的。

编码器预训练

如前所述,这些模型也被称为自编码模型,它们仅在预训练过程中使用编码器。预训练通过遮蔽输入序列中的词并训练模型重建序列来进行。通常,编码器可以访问所有输入词。仅编码器模型通常用于分类。

解码器预训练

解码器模型被称为自回归模型。在预训练过程中,解码器被优化为预测下一个词。特别是,解码器只能访问序列中给定词之前的所有词。解码器仅模型通常用于文本生成。

编码器-解码器预训练

在这种情况下,模型可以同时使用编码器和解码器。编码器中的注意力可以使用序列中的所有词语,而解码器中的注意力只能使用序列中给定词之前的词语。编码器-解码器有广泛的应用,包括文本生成、翻译、摘要和生成式问答。

预训练任务的分类

这对于将预训练组织成由《自然语言处理中的预训练模型:一项调查》(Xipeng Qiu, 2020)建议的分类法非常有用,详见arxiv.org/abs/2003.08271

  • 语言建模 (LM):对于单向语言建模,任务是预测下一个标记;对于双向语言建模,任务是预测前一个和下一个标记。

  • 掩蔽语言建模 (MLM):其核心思想是将输入句子中的一些标记进行掩蔽。然后,训练模型根据未被掩蔽的标记预测被掩蔽的标记。

  • 置换语言建模 (PLM):这与语言建模(LM)类似,但对输入序列进行了随机置换。然后,从中选择一个标记子集作为目标,并训练模型去预测这些目标。

  • 去噪自编码器 (DAE):故意提供部分损坏的输入。例如,随机抽取输入标记并将其替换为特殊的[MASK]元素;或者,随机删除输入标记;又或者,随机打乱句子的顺序。任务是恢复原始未损坏的输入。

  • 对比学* (CTL):任务是通过假设一些观测到的文本对比随机抽取的文本具有更高的语义相似性,来学*文本对的评分函数。这类技术包括一些具体技术,如:

    • 深度信息最大化 (DIM):最大化输入图像表示与同一图像的各个局部区域之间的互信息。

    • 替换标记检测 (RTD):预测给定周围环境下,输入标记是否被替换。

    • 下一个句子预测 (NSP):训练模型去区分两个输入句子是否在训练语料库中是连续的。

    • 句子顺序预测 (SOP):与 NSP 相似,但增加了一些附加信号:两个连续的片段是正例,两个交换的片段是负例。

在这一部分中,我们简要回顾了不同的预训练技术。下一部分将回顾一些最常用的变换器(transformer)模型。

一些流行且知名模型的概述

自从开创性论文《Attention is All You Need》发布后,已经提出了大量基于变换器的替代模型。我们来回顾一些最受欢迎和知名的模型。

BERT

BERT(双向编码器表示变换器)是谷歌 AI 研究团队于 2018 年开发的语言表示模型。我们来了解一下该模型的主要直觉:

  1. BERT 通过所谓的“双向自注意力”机制,从左右两侧考虑每个单词的上下文。

  2. 训练通过随机掩盖输入的单词标记进行,并避免循环以确保单词不能间接地“看到”自己。在自然语言处理术语中,这被称为“填空”。换句话说,预训练任务涉及掩盖一小部分未标记的输入,然后训练网络恢复这些原始输入。(这是 MLM 的一个例子。)

  3. 该模型使用分类进行预训练,以预测句子序列 S 是否在句子 T 之前。通过这种方式,BERT 能够理解句子之间的关系(“下一句预测”),例如“句子 T 是否在句子 S 之后?”预训练的思想成为了大语言模型的新标准。

  4. BERT——即 BERT Large——成为第一个大规模语言模型之一,拥有 24 个变换器模块、1024 个隐藏层、16 个自注意力头和 340M 参数。该模型在一个包含 33 亿个单词的大型语料库上进行训练。

BERT 在 11 项 NLP 任务中取得了最先进的结果,包括:

  • GLUE 得分为 80.4%,相比之前的最佳结果提高了 7.6%。

  • 在 SQuAD 1.1 上取得 93.2%的准确率,超越人类表现 2%。

我们将在本章后面看到 GLUE 和 SQuAD 的指标。如果你想了解更多,可以参考以下资料:

GPT-2

GPT-2 是 OpenAI 在 语言模型是无监督多任务学*者(由 Alec Radford、Jeffrey Wu、Rewon Child、David Luan、Dario Amodei 和 Ilya Sutskever 编写)中介绍的模型,openai.com/blog/better-language-models/openai.com/blog/gpt-2-6-month-follow-up/www.openai.com/blog/gpt-2-1-5b-release/,以及 github.com/openai/gpt-2

让我们回顾一下关键直觉:

  • 四种模型大小中最大的一个是具有 15 亿参数的 48 层变换器,训练数据集为名为 Webtext 的新数据集,包含来自 4500 万个网页的文本。

  • GPT-2 使用了原始的 2017 年基于变换器的架构,并对 Radford 等人(2018)开发的原始 GPT 模型(同样由 OpenAI 开发)进行了修改,通过生成预训练提高语言理解openai.com/blog/language-unsupervised/,以及 cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf

  • 研究表明,在一个大型且多样化的数据集上训练的 LLM 可以在各种 NLP 任务中表现良好,如问答、机器翻译、阅读理解和摘要生成。以前,这些任务通常通过在任务特定数据集上的监督学*来处理。GPT-2 采用无监督方式进行训练,并在零样本任务迁移中表现出色。

  • 最初,OpenAI 只发布了一个较小版本的 GPT-2,具有 1.17 亿个参数,“因为担心大型语言模型会被用来大规模生成具有误导性、偏见或攻击性的语言。”随后,模型被发布: openai.com/blog/gpt-2-1-5b-release/

  • 有趣的是,OpenAI 开发了一种基于机器学*的检测方法,用于测试演员是否生成用于宣传的合成文本。检测率为~95%,用于检测 1.5B 个 GPT-2 生成的文本:github.com/openai/gpt-2-output-dataset

与 2018 年原版 GPT 类似,GPT-2 不需要原始 transformer 模型的编码器部分——它使用多层解码器进行语言建模。解码器只能从句子中的前置单词获取信息。它以单词向量为输入,并输出下一个单词的概率估计,但它是自回归的,这意味着句子中的每个标记依赖于前一个单词的上下文。另一方面,BERT 不是自回归的,因为它一次性使用整个周围上下文。

GPT-2 是第一个展示常识推理的 LLM,能够执行多个 NLP 任务,包括翻译、问答和阅读理解。该模型在 8 个测试的语言建模数据集中的 7 个上达到了最先进的结果。

GPT-3

GPT-3 是 OpenAI 开发的自回归语言模型,2019 年在 Tom B. Brown 等人发布的Language Models are Few-Shot Learners论文中介绍,arxiv.org/abs/2005.14165。我们来看一下关键直觉:

  • GPT-3 使用与 GPT-2 相似的架构和模型,主要区别在于采用了稀疏注意力机制。

  • 对于每个任务,模型评估有三种不同的方法:

    • 少量学*:模型在推理时接收少量任务示范(通常少于一百个)。然而,不允许进行权重更新。

    • 一次性学*:模型仅接收一次示范和任务的自然语言描述。

    • 零样本学*:模型不接收任何示范,只能访问任务的自然语言描述。

  • 对于所有任务,GPT-3 在没有任何梯度更新的情况下应用,任务和少量示范完全通过与模型的文本交互来指定。

研究人员训练 GPT-3 的参数数量范围从 1.25 亿(GPT-3 Small)到 1750 亿(GPT-3 175B)。在没有微调的情况下,该模型在许多 NLP 任务中取得了显著成果,包括翻译和问答,有时甚至超过了最先进的模型。特别是,GPT-3 在自然语言生成(NLG)方面表现出色,创作的新闻文章几乎与真实文章难以区分。该模型证明它能够解决需要即时推理或领域适应的任务,例如解码单词、在句子中使用新词,或进行三位数的算术运算。

GPT-3 的底层模型未公开,我们无法对模型进行预训练,但一些数据集统计信息可以在github.com/openai/gpt-3查看,并且我们可以运行数据并对 GPT-3 引擎进行微调。

Reformer

Reformer 模型由 UC Berkeley 和 Google AI 研究人员 Nikita Kitaev、Łukasz Kaiser 和 Anselm Levskaya 在 2020 年的论文Reformer: The Efficient Transformer中介绍,arxiv.org/abs/2001.04451

让我们来看一下关键的直觉:

  • 作者展示了你可以训练 Reformer 模型,该模型在处理长序列时,在内存效率和速度上与变换器模型表现相当。

  • 变换器的一个限制是处理长序列的能力,因为计算注意力需要二次方时间。

  • Reformer 通过使用三种技术解决了变换器训练过程中计算和内存的挑战。

  • 首先,Reformer 将(缩放的)点积注意力替换为使用局部敏感哈希注意力的*似方法(本章前面简要描述了这一点)。论文的作者将前者在注意力层中的O(L²)因素替换为 O(LlogL),其中L是序列的长度(参见图 6.4,其中 LSH 应用于序列中的块)。有关局部敏感哈希的更多信息,请参考计算机科学中的相关介绍:en.wikipedia.org/wiki/Locality-sensitive_hashing

  • 其次,该模型将注意力层和前馈层与可逆残差层结合,而不是使用普通的残差层(基于 Gomez 等人 2017 年提出的The reversible residual network: Backpropagation without storing activations的思想,proceedings.neurips.cc/paper/2017/hash/f9be311e65d81a9ad8150a60844bb94c-Abstract.xhtml)。可逆残差层允许存储激活一次,而不是N次,从而减少了内存和时间复杂度的开销。

  • 第三,Reformer 在某些计算中使用了分块技术,包括前馈层和反向传播的计算。

  • 你可以阅读 Google AI 博客文章,了解 Reformer 如何通过提高效率:ai.googleblog.com/2020/01/reformer-efficient-transformer.xhtml

图示  描述自动生成

图 6.4:局部敏感哈希提高变换器的效率 – 来源:ai.googleblog.com/2020/01/reformer-efficient-transformer.xhtml

BigBird

BigBird 是另一种 transformer,于 2020 年由谷歌研究团队提出,采用稀疏注意力机制,以应对计算长序列时所需的二次复杂度。欲了解更多详情,请参阅论文 Big Bird: Transformers for Longer Sequences,作者包括 Manzil Zaheer、Guru Guruganesh、Avinava Dubey、Joshua Ainslie、Chris Alberti、Santiago Ontanon、Philip Pham、Anirudh Ravula、Qifan Wang、Li Yang 和 Amr Ahmed, arxiv.org/pdf/2007.14062.pdf

让我们来看一下关键的直觉:

  • 作者们证明了 BigBird 能够处理更长的上下文——在类似硬件上,它的序列长度比 BERT 增加了 8 倍。在某些 NLP 任务中,它的表现“显著”优于 BERT,例如问答和文档摘要。

  • BigBird 采用稀疏注意力机制,以克服 BERT 的二次依赖性。研究人员证明了复杂度从 O(L²) 降低到 O(L)。

  • 通过这种方式,BigBird 可以处理的序列长度是 BERT 的 8 倍。换句话说,BERT 的限制是 512 个 tokens,而 BigBird 增加到 4,096 个 tokens。

Transformer-XL

Transformer-XL 是一种基于自注意力机制的模型,由卡内基梅隆大学和谷歌大脑的研究人员于 2019 年在论文 Transformer-XL: Attentive Language Models Beyond a Fixed-Length Context 中提出,该论文的作者包括 Zihang Dai、Zhilin Yang、Yiming Yang、Jaime Carbonell、Quoc V. Le 和 Ruslan Salakhutdinov,aclanthology.org/P19-1285.pdf

让我们来看一下关键的直觉:

  • 与原始 transformer 和 RNNs 不同,Transformer-XL 展示了它能够在生成相对连贯的文本时,建模超出固定长度上下文的长期依赖关系。

  • Transformer-XL 引入了一种新的段级递归机制和一种新的相对位置编码类型(与绝对位置编码相对),使得模型能够学*比 RNN 长 80% 和比传统 transformer 长 450% 的依赖关系。传统上,由于计算限制,transformer 将整个语料库划分为较短的段落,并且仅在每个段落内训练模型。

  • 在训练过程中,前一个段落计算出的隐藏状态序列被固定并缓存,以便在模型处理下一个新段落时作为扩展上下文重用,如 图 6.5 所示。尽管梯度仍然局限于某个段落内,但这种额外的输入使得网络能够利用历史信息,从而具备建模长期依赖的能力,避免了上下文的碎片化。

  • 在评估过程中,可以复用前几个段落的表示,而不需要像传统模型那样从头开始计算。通过这种方式,Transformer-XL 在评估过程中比传统模型快了多达 1,800 倍以上:

Chart, scatter chart  Description automatically generated

图 6.5:Transformer-XL 与输入的递归缓存前段

XLNet

XLNet 是一种无监督语言表示学*方法,由卡内基梅隆大学和谷歌大脑的研究人员在 2019 年开发。它基于广义排列语言建模目标。XLNet 使用 Transformer-XL 作为主干模型。这里的参考论文是XLNet: Generalized Autoregressive Pre-training for Language Understanding,作者包括 Zhilin Yang、Zihang Dai、Yiming Yang、Jaime Carbonell、Ruslan Salakhutdinov 和 Quoc V. Le,arxiv.org/abs/1906.08237

让我们再来看一下关键的直觉:

  • 像 BERT 一样,XLNet 使用双向上下文,查看给定标记前后的词语,以预测它应该是什么。

  • XLNet 最大化了相对于所有可能排列顺序的因式分解顺序的序列期望对数似然。由于排列操作,每个位置的上下文可以由来自左右两边的标记组成。换句话说,XLNet 捕捉到了双向上下文。

  • XLNet 在 20 个任务上超越了 BERT,并在 18 个任务上达到了最先进的结果。

  • 代码和预训练模型可以在这里找到:github.com/zihangdai/xlnet

XLNet 被认为在几乎所有 NLP 任务中都优于 BERT,在 20 个任务上超越了 BERT,通常差距较大。当它被引入时,该模型在 18 个 NLP 任务上达到了最先进的性能,包括情感分析、自然语言推理、问答和文档排序。

RoBERTa

RoBERTa(一个强健优化的 BERT 模型)是 2019 年由华盛顿大学和 Facebook AI(Meta)的研究人员提出的,在RoBERTa: A Robustly Optimized BERT Pretraining Approach论文中,由 Yinhan Liu、Myle Ott、Naman Goyal、Jingfei Du、Mandar Joshi、Danqi Chen、Omer Levy、Mike Lewis、Luke Zettlemoyer 和 Veselin Stoyanov 编写,arxiv.org/abs/1907.11692

让我们来看一下关键的直觉:

  • 在复制 BERT 时,研究人员发现 BERT“显著欠训练”。

  • RoBERTa 的作者提出了一种 BERT 变种,通过修改关键超参数(更长的训练时间、更大的批量、更多的数据),移除下一个句子预训练目标,并在更长的序列上进行训练。作者还提出了动态变化应用于训练数据的掩蔽模式。

  • 研究人员收集了一个新的数据集,称为 CC-News,大小与其他私有数据集相似。

  • 代码可以在这里找到:github.com/pytorch/fairseq

RoBERTa 在 GLUE 和 SQuAD 任务上超过了 BERT,并在其中一些任务上与 XLNet 持平。

ALBERT

ALBERTA Lite BERT)是由 Google Research 和芝加哥丰田技术研究院的研究人员于 2019 年提出的模型,论文标题为ALBERT: A Lite BERT for Self-supervised Learning of Language Representations,作者包括 Zhenzhong Lan、Mingda Chen、Sebastian Goodman、Kevin Gimpel、Piyush Sharma 和 Radu Soricut,arxiv.org/abs/1909.11942v1

让我们来看一下关键的直觉:

  • 大型模型通常通过增加模型大小来提高预训练自然语言表示的性能。然而,由于 GPU/TPU 内存限制、更长的训练时间和意外的模型退化,增加模型大小可能变得困难。

  • ALBERT 试图解决内存限制、通信开销和模型退化问题,采用了一种结合了两种参数减少技术的架构:因式分解嵌入参数化和跨层参数共享。通过因式分解嵌入参数化,隐藏层的大小与词汇嵌入的大小分离,通过将大词汇嵌入矩阵分解为两个小矩阵来实现。通过跨层参数共享,模型防止了随着网络深度增加而参数数量的增长。这两种技术在不“严重”影响性能的情况下提高了参数效率。

  • 与原始的 BERT-Large 模型相比,ALBERT 的参数量减少了 18 倍,训练速度提高了 1.7 倍,性能仅略微下降。

  • 代码可在此处获取:github.com/brightmart/albert_zh

ALBERT 声称在所有当前最先进的语言基准(如 GLUE、SQuAD 和 RACE)上都建立了新的最先进成果。

StructBERT

StructBERT 是 2019 年提出的一个模型,论文标题为StructBERT: Incorporating Language Structures into Pre-training for Deep Language Understanding,作者包括 Wei Wang、Bin Bi、Ming Yan、Chen Wu、Zuyi Bao、Jiangnan Xia、Liwei Peng 和 Luo Si,arxiv.org/abs/1908.04577

让我们来看一下关键的直觉:

  • 阿里巴巴团队建议在预训练过程中通过利用词级和句子级的顺序扩展 BERT。通过混合多个 token 来扩展 BERT 的预训练掩码,模型需要预测正确的顺序。

  • 此外,该模型随机打乱句子顺序,并通过特定的预测任务预测下一句和上一句。

  • 这种附加的词汇和句子打乱以及预测原始顺序的任务使得 StructBERT 能够在预训练过程中学*语言结构。

阿里巴巴的 StructBERT 声称在不同的 NLP 任务(如情感分类、自然语言推理、语义文本相似性和问答)中达到了最先进的结果,超越了 BERT。

T5 和 MUM

2019 年,Google 的研究人员在 Colin Raffel、Noam Shazeer、Adam Roberts、Katherine Lee、Sharan Narang、Michael Matena、Yanqi Zhou、Wei Li 和 Peter J. Liu 的论文《Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer》中介绍了一种名为 Text-to-Text Transfer Transformer(简称 T5)的框架,arxiv.org/abs/1910.10683。这篇论文是变压器模型领域的基础性论文。

以下是一些关键观点:

  • T5 将许多自然语言处理任务作为“文本到文本”的问题进行处理。T5 是一个单一模型(具有不同数量的参数),可以在众多任务上进行训练。该框架如此强大,能够应用于摘要生成、情感分析、问答和机器翻译等任务。

  • 转移学*,即先在数据丰富的任务上预训练一个模型,再在下游任务上进行微调,通过比较预训练目标、架构、未标注数据集、转移方法及其他因素,在数十个语言理解任务上进行了深入分析。

  • 与原始的变压器类似,T5:1)使用编码器-解码器结构;2)将输入序列映射到学*的嵌入和位置嵌入,这些嵌入传递给编码器;3)在编码器和解码器中使用自注意力块,结合自注意力和前馈层(每层都具有归一化和跳跃连接)。

  • 训练是在一个名为“Colossal Clean Crawled Corpus”(C4)的数据集上进行的,每个 T5 模型的参数数量从 6000 万(T5 小型)到 110 亿不等。

  • 计算成本与 BERT 相似,但参数数量是其两倍。

  • 代码可在这里获取:github.com/google-research/text-to-text-transfer-transformer

  • Google 还提供了在 Colab 教程中使用免费的 TPU 运行 T5,网址为colab.research.google.com/github/google-research/text-to-text-transfer-transformer/blob/main/notebooks/t5-trivia.ipynb。我们将在本章稍后详细讨论这一内容。

当模型呈现时,具有 110 亿参数的 T5 模型在 24 个任务中有 17 个任务上达到了最先进的性能,成为事实上的最佳语言模型之一:

图表 描述自动生成

图 6.6:T5 在我们多种任务集上使用相同的模型、损失函数、超参数等——包括翻译、问答和分类任务

mT5 由 Google Research 的 Xue 等人在 2020 年开发,通过使用单一的 Transformer 模型来处理多语言。它在基于 Common Crawl 的数据集上进行了预训练,涵盖了 101 种语言。你可以在 mT5: A Massively Multilingual Pre-trained Text-to-Text Transformer 中阅读更多信息,arxiv.org/pdf/2010.11934.pdf

MUM(即 Multitask Unified Model 的缩写)是一个使用 T5 文本到文本框架的模型,根据 Google 的说法,其性能是 BERT 的 1,000 倍。MUM 不仅能理解语言,还能生成语言。它还是多模态的,涵盖文本和图像等模态(未来将扩展到更多模态)。该模型在 75 种不同语言和多种任务上进行了训练。目前,MUM 被用于支持 Google 搜索排名:blog.google/products/search/introducing-mum/

ELECTRA

ELECTRA 是斯坦福大学和 Google Brain 研究人员于 2020 年推出的模型,发表在 ELECTRA: Pre-training Text Encoders as Discriminators Rather Than Generators 论文中,作者为 Kevin Clark、Minh-Thang Luong、Quoc V. Le 和 Christopher D. Manning,arxiv.org/abs/2003.10555

让我们来看一下关键的直觉:

  • BERT 的预训练任务包括掩盖一小部分未标记的输入,然后训练网络去恢复这些输入。通常只使用少量的词汇(约 15%)。

  • ELECTRA 的作者提出了一种新的预训练任务——“替换词检测”。其思想是将一些词用由小型语言模型生成的替代词替换。然后,预训练的判别器用来预测每个词是原始词还是替代词。通过这种方式,模型可以从所有词中学*,而不仅仅是一个子集:

图示 描述自动生成

图 6.7:ELECTRA 替换策略。判别器的任务是检测该词是原始词还是替代词——来源:arxiv.org/pdf/2003.10555.pdf

ELECTRA 超越了之前的最先进模型,同时需要更少的预训练工作。代码可以在 github.com/google-research/electra 获取。

DeBERTa

DeBERTa 是微软研究人员在 2020 年推出的模型,发表在 DeBERTa: Decoding-enhanced BERT with Disentangled Attention 论文中,作者为 Pengcheng He、Xiaodong Liu、Jianfeng Gao 和 Weizhu Chen,arxiv.org/abs/2006.03654

让我们来看看最重要的观点:

  • BERT 的自注意力集中在内容到内容和内容到位置的关系上,内容和位置的嵌入会在自注意力之前加上。DeBERTa 保持了两个独立的向量来表示内容和位置,从而使自注意力可以在内容到内容、内容到位置、位置到内容以及位置到位置之间进行计算。

  • DeBERTa 保留了绝对位置的信息,并结合了相关的位置信息。

由于模型使用了额外的结构信息,DeBERTa 声称在使用比其他模型(如 RoBERTa)更少的训练数据时,达到了最先进的结果。代码可在 github.com/microsoft/DeBERTa 上找到。

进化变换器与 MEENA

进化变换器是由谷歌大脑(Google Brain)研究人员在 2019 年提出的,发表于 The Evolved Transformer 这篇由 David R. So、Chen Liang 和 Quoc V. Le 撰写的论文中,arxiv.org/abs/1901.11117

让我们回顾一下主要的思想:

  • 变换器是一类手工设计的架构。进化变换器的研究人员应用了 神经架构搜索 (NAS),这是一套自动优化技术,用于学*如何结合基本架构构件,从而找到比人类手工设计的模型更优秀的模型。

  • NAS 被应用于变换器编码器和解码器块, resulting in a new architecture shown in Figures 6.8 and 6.9.

与原始变换器架构相比,进化变换器(Evolved Transformers)展示了稳定的改进。该模型是 MEENA 的核心,MEENA 是一个多轮开放域聊天机器人,经过端到端训练,使用从公共领域社交媒体对话中挖掘并过滤的数据。MEENA 使用了 26 亿个参数的进化变换器,拥有一个进化变换器编码器块和 13 个进化变换器解码器块。训练所使用的目标函数专注于最小化困惑度(perplexity),即预测下一个标记时的“不确定性”。与现有的最先进聊天机器人相比,MEENA 能进行更加敏感和具体的对话。参见谷歌博客文章 Towards a Conversational Agent that Can Chat About…Anythingai.googleblog.com/2020/01/towards-conversational-agent-that-can.xhtml

Diagram  Description automatically generated

图 6.8:进化变换器编码器块,来源:arxiv.org/pdf/1901.11117.pdf

Diagram  Description automatically generated

图 6.9:进化变换器解码器块,来源:arxiv.org/pdf/1901.11117.pdf

LaMDA

LaMDA 是谷歌研究人员在 2022 年提出的模型,来源于 LaMDA: Language Models for Dialog Applications 这篇由 Romal Thoppilan 等人撰写的论文,arxiv.org/abs/2201.08239。它是一种基于变换器(transformer)的神经语言模型,专门用于对话。我们来看一下关键的直觉:

  • 在预训练阶段,LaMDA 使用了 1.56 万亿个单词的数据集——比以前用于大规模语言模型的数据多出* 40 倍——来自公共对话数据和其他公共网页文档。在将数据集分词为 2.81 万亿 SentencePiece 令牌后,预训练根据前面的令牌预测句子中的每个下一个令牌。

  • 在微调阶段,LaMDA 执行生成任务和分类任务的混合,生成针对给定上下文的自然语言响应,并分类判断响应是否安全且高质量。生成与分类的结合提供了最终答案(见 图 6.10)。

  • LaMDA 定义了一套强健的质量、安全性和根植性评估指标:

    • 质量:该度量被分解为三个维度,合理性、特异性和趣味性SSI)。合理性考虑模型生成的响应是否符合对话上下文的逻辑。特异性判断响应是否针对前一个对话上下文,而不是一个可以应用于大多数上下文的通用回答。趣味性则衡量模型生成的响应是否富有见解、出乎意料或机智。

    • 安全性:考虑如何避免产生任何可能对用户造成伤害的意外结果,以及避免加剧不公平的偏见。

    • 根植性:考虑到信息的可信性,但该信息可能与外部权威来源支持的资料相矛盾。图表  描述自动生成

    图 6.10:LaMDA 生成并评分一个响应候选。来源:ai.googleblog.com/2022/01/lamda-towards-safe-grounded-and-high.xhtml

LaMDA 展示了接*人脑水平的惊人表现。根据 Google 的说法(ai.googleblog.com/2022/01/lamda-towards-safe-grounded-and-high.xhtml),LaMDA 在各个维度上,以及所有模型大小上,都明显优于预训练模型。质量度量(合理性、特异性和趣味性)随着模型参数的增加而普遍提高,无论是否经过微调。安全性似乎并未仅凭模型扩展得到改善,但经过微调后有了提升。根植性随着模型大小的增加而提高,可能是因为较大的模型能更好地记住不常见的知识,但微调使模型能够访问外部知识源,并有效地将一些记忆负担转移到外部知识源上。经过微调后,模型在质量上的差距可以缩小到接*人类水平,但在安全性和根植性方面,模型的表现仍低于人类水平:

图形用户界面  描述自动生成,信心中等

图 6.11:LaMDA 性能 – 来源:ai.googleblog.com/2022/01/lamda-towards-safe-grounded-and-high.xhtml

Switch Transformer

Switch Transformer 是由谷歌的研究人员在 2021 年提出的,发表于《Switch Transformers: Scaling to Trillion Parameter Models with Simple and Efficient Sparsity》一文中,由 William Fedus、Barret Zoph 和 Noam Shazeer 撰写,文章链接:arxiv.org/abs/2101.03961

让我们看看关键的直觉:

  • Switch Transformer 从 70 亿到 1.6 万亿参数进行了训练。如前所述,典型的变换器是一个由多头自注意力层组成的深度堆栈,每层的末端都有一个 FFN(前馈神经网络),用于聚合来自多个头的输出。Switch Transformer 将这个单一的 FFN 替换为多个 FFN,并将其称为“专家”。在每次前向传播中,在每层,对于输入的每个标记,模型会激活一个专家:图示 说明自动生成

    图 6.12:具有多个路由 FFN 的 Switch Transformer——变换器中存在的密集 FFN 层被稀疏的 Switch FFN 层(浅蓝色)替代。来源:arxiv.org/pdf/2101.03961.pdf

  • Switch-Base(70 亿参数)和 Switch-Large(260 亿参数)在语言建模、分类、共指解析、问答和摘要等任务上优于 T5-Base(2 亿参数)和 T5-Large(7 亿参数)。

Switch Transformer 的一个示例实现可以在keras.io/examples/nlp/text_classification_with_switch_transformer/找到。

RETRO

RETRO检索增强型变换器)是 DeepMind 在 2022 年提出的一种检索增强自回归语言模型,发表于 Sebastian Borgeaud 等人的《通过从万亿标记中检索来改进语言模型》中,arxiv.org/pdf/2112.04426/。我们来看一下关键的直觉:

  • 增加 LLM 中的参数数量已被证明是一种提高结果质量的方法。然而,这种方法并不可持续,因为它在计算上非常昂贵。

  • RETRO 将一个检索数据库DB)与变换器结合,形成混合架构。其思想是首先使用最*邻算法在预计算的 BERT 嵌入中进行搜索,这些嵌入存储在检索数据库中。然后,将这些嵌入作为输入传递给变换器的编码器。

  • 检索和变换器的结合使得 RETRO(从 1.5 亿扩展到 70 亿非嵌入参数)在使用 LLM 时节省了参数数量。

例如,考虑查询“2021 年美国网球公开赛冠军是”及图 6.13,其中缓存的 BERT 嵌入被传递给变换器编码器,以获得最终结果:

图示 说明自动生成

图 6.13:检索增强变压器(RETRO)的高级概览。来源:https://deepmind.com/research/publications/2021/improving-language-models-by-retrieving-from-trillions-of-tokens

Pathways 和 PaLM

Google Research 宣布了 Pathways(blog.google/technology/ai/introducing-pathways-next-generation-ai-architecture/),这是一个能够跨领域和任务泛化的单一模型,同时具有高度的效率。随后,Google 推出了Pathways 语言模型PaLM),这是一个包含 5400 亿参数、密集型解码器-only 的变压器模型,使我们能够在多个 TPU v4 Pods 上高效地训练单一模型。Google 对 PaLM 进行了数百项语言理解和生成任务的评估,发现它在大多数任务中都能实现最先进的性能,并且在许多情况下具有显著的优势(见ai.googleblog.com/2022/04/pathways-language-model-palm-scaling-to.xhtml?m=1)。

实现

在本节中,我们将通过一些使用变压器的任务。

变压器参考实现:一个翻译示例

在本节中,我们将简要回顾一个可以在www.tensorflow.org/text/tutorials/transformer上找到的变压器参考实现,具体来说,我们将利用这个机会在 Google Colab 中运行代码。

不是每个人都意识到训练变压器需要的 GPU 数量。幸运的是,你可以在colab.research.google.com/github/tensorflow/text/blob/master/docs/tutorials/transformer.ipynb上免费使用可用资源。

请注意,除非你需要实现一些非常具体的定制,或者你对核心研究感兴趣,否则从零开始实现变压器可能不是最佳选择。如果你对了解内部实现不感兴趣,可以跳到下一节。我们的教程使用创意共享署名 4.0 许可证授权,代码示例使用 Apache 2.0 许可证授权。我们将要执行的具体任务是将葡萄牙语翻译成英语。让我们一步一步地看一下代码:

  1. 首先,让我们安装数据集并导入正确的库。请注意,在线的 Colab 似乎缺少import tensorflow_text这一行,但在这里已添加:

    !pip install tensorflow_datasets
    !pip install -U 'tensorflow-text==2.8.*'
    import logging
    import time
    import numpy as np
    import matplotlib.pyplot as plt
    import tensorflow_text
    import tensorflow_datasets as tfds
    import tensorflow as tf
    logging.getLogger('tensorflow').setLevel(logging.ERROR)  # suppress warnings 
    
  2. 然后,加载葡萄牙语到英语的数据集:

    examples, metadata = tfds.load('ted_hrlr_translate/pt_to_en', with_info=True,
                                   as_supervised=True)
    train_examples, val_examples = examples['train'], examples['validation'] 
    
  3. 现在,让我们将文本转换为标记 ID 的序列,这些标记 ID 用作嵌入的索引:

    model_name = 'ted_hrlr_translate_pt_en_converter'
    tf.keras.utils.get_file(
        f'{model_name}.zip',
        f'https://storage.googleapis.com/download.tensorflow.org/models/{model_name}.zip',
        cache_dir='.', cache_subdir='', extract=True
    )
    tokenizers = tf.saved_model.load(model_name) 
    
  4. 让我们看一下标记化的 ID 和标记化的单词:

    for pt_examples, en_examples in train_examples.batch(3).take(1):
      print('> Examples in Portuguese:')
    for en in en_examples.numpy():
      print(en.decode('utf-8')) 
    
    and when you improve searchability , you actually take away the one advantage of print , which is serendipity .
    but what if it were active ?
    but they did n't test for curiosity . 
    
    encoded = tokenizers.en.tokenize(en_examples)
    for row in encoded.to_list():
      print(row) 
    
    [2, 72, 117, 79, 1259, 1491, 2362, 13, 79, 150, 184, 311, 71, 103, 2308, 74, 2679, 13, 148, 80, 55, 4840, 1434, 2423, 540, 15, 3]
    [2, 87, 90, 107, 76, 129, 1852, 30, 3]
    [2, 87, 83, 149, 50, 9, 56, 664, 85, 2512, 15, 3] 
    
    round_trip = tokenizers.en.detokenize(encoded)
    for line in round_trip.numpy():
      print(line.decode('utf-8')) 
    
    and when you improve searchability , you actually take away the one advantage of print , which is serendipity .
    but what if it were active ?
    but they did n ' t test for curiosity . 
    
  5. 现在让我们创建一个输入管道。首先,我们定义一个函数来丢弃超过 MAX_TOKENS 长度的示例。其次,我们定义一个函数来对原始文本的批次进行标记化。第三,我们创建批次:

    MAX_TOKENS=128
    def filter_max_tokens(pt, en):
      num_tokens = tf.maximum(tf.shape(pt)[1],tf.shape(en)[1])
      return num_tokens < MAX_TOKENS
    def tokenize_pairs(pt, en):
        pt = tokenizers.pt.tokenize(pt)
        # Convert from ragged to dense, padding with zeros.
        pt = pt.to_tensor()
        en = tokenizers.en.tokenize(en)
        # Convert from ragged to dense, padding with zeros.
        en = en.to_tensor()
        return pt, en
    BUFFER_SIZE = 20000
    BATCH_SIZE = 64
    def make_batches(ds):
      return (
          ds
          .cache()
          .shuffle(BUFFER_SIZE)
          .batch(BATCH_SIZE)
          .map(tokenize_pairs, num_parallel_calls=tf.data.AUTOTUNE)
          .filter(filter_max_tokens)
          .prefetch(tf.data.AUTOTUNE))
    train_batches = make_batches(train_examples)
    val_batches = make_batches(val_examples) 
    
  6. 现在我们添加位置编码,强制根据词汇的含义相似度和它们在句子中的位置,使得令牌彼此更接*,位于 d 维嵌入空间中:

    def get_angles(pos, i, d_model):
      angle_rates = 1 / np.power(10000, (2 * (i//2)) / np.float32(d_model))
      return pos * angle_rates
    def positional_encoding(position, d_model):
      angle_rads = get_angles(np.arange(position)[:, np.newaxis],
                              np.arange(d_model)[np.newaxis, :],
                              d_model)
      # apply sin to even indices in the array; 2i
      angle_rads[:, 0::2] = np.sin(angle_rads[:, 0::2])
      # apply cos to odd indices in the array; 2i+1
      angle_rads[:, 1::2] = np.cos(angle_rads[:, 1::2])
      pos_encoding = angle_rads[np.newaxis, ...]
      return tf.cast(pos_encoding, dtype=tf.float32) 
    
  7. 现在,让我们集中关注掩码过程。前瞻掩码用于掩蔽序列中的未来令牌,掩码指示哪些条目不应被使用。例如,为了预测第三个令牌,只会使用第一个和第二个令牌,而为了预测第四个令牌,只会使用第一个、第二个和第三个令牌,依此类推:

    def create_padding_mask(seq):
      seq = tf.cast(tf.math.equal(seq, 0), tf.float32)
      # add extra dimensions to add the padding
      # to the attention logits.
      return seq[:, tf.newaxis, tf.newaxis, :]  # (batch_size, 1, 1, seq_len)
    def create_look_ahead_mask(size):
      mask = 1 - tf.linalg.band_part(tf.ones((size, size)), -1, 0)
      return mask  # (seq_len, seq_len) 
    
  8. 我们离变换器的本质越来越*。让我们将注意力函数定义为一个缩放的点积:

    def scaled_dot_product_attention(q, k, v, mask):
      """Calculate the attention weights.
      q, k, v must have matching leading dimensions.
      k, v must have matching penultimate dimension, i.e.: seq_len_k = seq_len_v.
      The mask has different shapes depending on its type(padding or look ahead)
      but it must be broadcastable for addition.
      Args:
        q: query shape == (..., seq_len_q, depth)
        k: key shape == (..., seq_len_k, depth)
        v: value shape == (..., seq_len_v, depth_v)
        mask: Float tensor with shape broadcastable
              to (..., seq_len_q, seq_len_k). Defaults to None.
      Returns:
        output, attention_weights
      """
      matmul_qk = tf.matmul(q, k, transpose_b=True)  # (..., seq_len_q, seq_len_k)
      # scale matmul_qk
      dk = tf.cast(tf.shape(k)[-1], tf.float32)
      scaled_attention_logits = matmul_qk / tf.math.sqrt(dk)
      # add the mask to the scaled tensor.
      if mask is not None:
        scaled_attention_logits += (mask * -1e9)
      # softmax is normalized on the last axis (seq_len_k) so that the scores
      # add up to 1.
      attention_weights = tf.nn.softmax(scaled_attention_logits, axis=-1)  # (..., seq_len_q, seq_len_k)
      output = tf.matmul(attention_weights, v)  # (..., seq_len_q, depth_v)
      return output, attention_weights 
    
  9. 现在注意力已定义,我们需要实现多头机制。它有三个部分:线性层、缩放点积注意力和最终的线性层(见 图 6.14):图示  自动生成描述

    图 6.14:多头注意力

    class MultiHeadAttention(tf.keras.layers.Layer):
      def __init__(self,*, d_model, num_heads):
        super(MultiHeadAttention, self).__init__()
        self.num_heads = num_heads
        self.d_model = d_model
        assert d_model % self.num_heads == 0
        self.depth = d_model // self.num_heads
        self.wq = tf.keras.layers.Dense(d_model)
        self.wk = tf.keras.layers.Dense(d_model)
        self.wv = tf.keras.layers.Dense(d_model)
        self.dense = tf.keras.layers.Dense(d_model)
      def split_heads(self, x, batch_size):
        """Split the last dimension into (num_heads, depth).
        Transpose the result such that the shape is (batch_size, num_heads, seq_len, depth)
        """
        x = tf.reshape(x, (batch_size, -1, self.num_heads, self.depth))
        return tf.transpose(x, perm=[0, 2, 1, 3])
      def call(self, v, k, q, mask):
        batch_size = tf.shape(q)[0]
        q = self.wq(q)  # (batch_size, seq_len, d_model)
        k = self.wk(k)  # (batch_size, seq_len, d_model)
        v = self.wv(v)  # (batch_size, seq_len, d_model)
        q = self.split_heads(q, batch_size)  # (batch_size, num_heads, seq_len_q, depth)
        k = self.split_heads(k, batch_size)  # (batch_size, num_heads, seq_len_k, depth)
        v = self.split_heads(v, batch_size)  # (batch_size, num_heads, seq_len_v, depth)
        # scaled_attention.shape == (batch_size, num_heads, seq_len_q, depth)
        # attention_weights.shape == (batch_size, num_heads, seq_len_q, seq_len_k)
        scaled_attention, attention_weights = scaled_dot_product_attention(
            q, k, v, mask)
        scaled_attention = tf.transpose(scaled_attention, perm=[0, 2, 1, 3])  # (batch_size, seq_len_q, num_heads, depth)
        concat_attention = tf.reshape(scaled_attention,
                                      (batch_size, -1, self.d_model))  # (batch_size, seq_len_q, d_model)
        output = self.dense(concat_attention)  # (batch_size, seq_len_q, d_model)
        return output, attention_weights 
    
  10. 现在,我们可以定义一个逐点前馈网络,它由两个完全连接的层组成,中间有一个 ReLU 激活函数:

    def point_wise_feed_forward_network(d_model, dff):
     return tf.keras.Sequential([
         tf.keras.layers.Dense(dff, activation='relu'),  # (batch_size, seq_len, dff)
         tf.keras.layers.Dense(d_model)  # (batch_size, seq_len, d_model)
     ]) 
    
  11. 我们现在可以集中精力定义如 图 6.15 所示的编码器和解码器部分。记住,传统的变换器通过 N 个编码器层处理输入句子,而解码器使用编码器输出和它自己的输入(自注意力)来预测下一个词。每个编码器层都有由多头注意力(带填充掩码)和逐点前馈网络组成的子层。每个子层使用残差连接来解决梯度消失问题,并且有一个归一化层:

    class EncoderLayer(tf.keras.layers.Layer):
      def __init__(self,*, d_model, num_heads, dff, rate=0.1):
        super(EncoderLayer, self).__init__()
        self.mha = MultiHeadAttention(d_model=d_model, num_heads=num_heads)
        self.ffn = point_wise_feed_forward_network(d_model, dff)
        self.layernorm1 = tf.keras.layers.LayerNormalization(epsilon=1e-6)
        self.layernorm2 = tf.keras.layers.LayerNormalization(epsilon=1e-6)
        self.dropout1 = tf.keras.layers.Dropout(rate)
        self.dropout2 = tf.keras.layers.Dropout(rate)
      def call(self, x, training, mask):
        attn_output, _ = self.mha(x, x, x, mask)  # (batch_size, input_seq_len, d_model)
        attn_output = self.dropout1(attn_output, training=training)
        out1 = self.layernorm1(x + attn_output)  # (batch_size, input_seq_len, d_model)
        ffn_output = self.ffn(out1)  # (batch_size, input_seq_len, d_model)
        ffn_output = self.dropout2(ffn_output, training=training)
        out2 = self.layernorm2(out1 + ffn_output)  # (batch_size, input_seq_len, d_model)
        return out2 
    
  12. 每个解码器层由多个子层组成。首先是一个带掩蔽的多头注意力(带前瞻掩码和填充掩码)。然后是一个多头注意力(带填充掩码),V(值)和 K(键)接收编码器输出作为输入。Q(查询)接收来自掩蔽多头注意力子层的输出,最后是逐点前馈网络:

    class DecoderLayer(tf.keras.layers.Layer):
      def __init__(self,*, d_model, num_heads, dff, rate=0.1):
        super(DecoderLayer, self).__init__()
        self.mha1 = MultiHeadAttention(d_model=d_model, num_heads=num_heads)
        self.mha2 = MultiHeadAttention(d_model=d_model, num_heads=num_heads)
        self.ffn = point_wise_feed_forward_network(d_model, dff)
        self.layernorm1 = tf.keras.layers.LayerNormalization(epsilon=1e-6)
        self.layernorm2 = tf.keras.layers.LayerNormalization(epsilon=1e-6)
        self.layernorm3 = tf.keras.layers.LayerNormalization(epsilon=1e-6)
        self.dropout1 = tf.keras.layers.Dropout(rate)
        self.dropout2 = tf.keras.layers.Dropout(rate)
        self.dropout3 = tf.keras.layers.Dropout(rate)
      def call(self, x, enc_output, training,
               look_ahead_mask, padding_mask):
        # enc_output.shape == (batch_size, input_seq_len, d_model)
        attn1, attn_weights_block1 = self.mha1(x, x, x, look_ahead_mask)  # (batch_size, target_seq_len, d_model)
        attn1 = self.dropout1(attn1, training=training)
        out1 = self.layernorm1(attn1 + x)
        attn2, attn_weights_block2 = self.mha2(
            enc_output, enc_output, out1, padding_mask)  # (batch_size, target_seq_len, d_model)
        attn2 = self.dropout2(attn2, training=training)
        out2 = self.layernorm2(attn2 + out1)  # (batch_size, target_seq_len, d_model)
        ffn_output = self.ffn(out2)  # (batch_size, target_seq_len, d_model)
        ffn_output = self.dropout3(ffn_output, training=training)
        out3 = self.layernorm3(ffn_output + out2)  # (batch_size, target_seq_len, d_model)
        return out3, attn_weights_block1, attn_weights_block2 
    
  13. 现在我们已经定义了编码器层,可以用它来定义合适的编码器。编码器由三个阶段组成:输入嵌入、位置编码和 N 个编码器层:

    class Encoder(tf.keras.layers.Layer):
      def __init__(self,*, num_layers, d_model, num_heads, dff, input_vocab_size,
                   rate=0.1):
        super(Encoder, self).__init__()
        self.d_model = d_model
        self.num_layers = num_layers
        self.embedding = tf.keras.layers.Embedding(input_vocab_size, d_model)
        self.pos_encoding = positional_encoding(MAX_TOKENS, self.d_model)
        self.enc_layers = [
            EncoderLayer(d_model=d_model, num_heads=num_heads, dff=dff, rate=rate)
            for _ in range(num_layers)]
        self.dropout = tf.keras.layers.Dropout(rate)
      def call(self, x, training, mask):
        seq_len = tf.shape(x)[1]
        # adding embedding and position encoding.
        x = self.embedding(x)  # (batch_size, input_seq_len, d_model)
        x *= tf.math.sqrt(tf.cast(self.d_model, tf.float32))
        x += self.pos_encoding[:, :seq_len, :]
        x = self.dropout(x, training=training)
        for i in range(self.num_layers):
          x = self.enc_layersi
        return x  # (batch_size, input_seq_len, d_model) 
    
  14. 我们现在可以专注于解码器本身。解码器由输出嵌入、位置编码和 N 个解码器层组成:

    class Decoder(tf.keras.layers.Layer):
      def __init__(self,*, num_layers, d_model, num_heads, dff, target_vocab_size,
                   rate=0.1):
        super(Decoder, self).__init__()
        self.d_model = d_model
        self.num_layers = num_layers
        self.embedding = tf.keras.layers.Embedding(target_vocab_size, d_model)
        self.pos_encoding = positional_encoding(MAX_TOKENS, d_model)
        self.dec_layers = [
            DecoderLayer(d_model=d_model, num_heads=num_heads, dff=dff, rate=rate)
            for _ in range(num_layers)]
        self.dropout = tf.keras.layers.Dropout(rate)
      def call(self, x, enc_output, training,
               look_ahead_mask, padding_mask):
        seq_len = tf.shape(x)[1]
        attention_weights = {}
        x = self.embedding(x)  # (batch_size, target_seq_len, d_model)
        x *= tf.math.sqrt(tf.cast(self.d_model, tf.float32))
        x += self.pos_encoding[:, :seq_len, :]
        x = self.dropout(x, training=training)
        for i in range(self.num_layers):
          x, block1, block2 = self.dec_layersi
          attention_weights[f'decoder_layer{i+1}_block1'] = block1
          attention_weights[f'decoder_layer{i+1}_block2'] = block2
        # x.shape == (batch_size, target_seq_len, d_model)
        return x, attention_weights 
    
  15. 现在我们已经定义了编码器和解码器,我们可以把注意力转向变换器本身,它由编码器、解码器和最终的线性层组成(见 图 6.15):

    class Transformer(tf.keras.Model):
      def __init__(self,*, num_layers, d_model, num_heads, dff, input_vocab_size,
                   target_vocab_size, rate=0.1):
        super().__init__()
        self.encoder = Encoder(num_layers=num_layers, d_model=d_model,
                               num_heads=num_heads, dff=dff,
                               input_vocab_size=input_vocab_size, rate=rate)
        self.decoder = Decoder(num_layers=num_layers, d_model=d_model,
                               num_heads=num_heads, dff=dff,
                               target_vocab_size=target_vocab_size, rate=rate)
        self.final_layer = tf.keras.layers.Dense(target_vocab_size)
      def call(self, inputs, training):
        # Keras models prefer if you pass all your inputs in the first argument
        inp, tar = inputs
        enc_padding_mask, look_ahead_mask, dec_padding_mask = self.create_masks(inp, tar)
        enc_output = self.encoder(inp, training, enc_padding_mask)  # (batch_size, inp_seq_len, d_model)
        # dec_output.shape == (batch_size, tar_seq_len, d_model)
        dec_output, attention_weights = self.decoder(
            tar, enc_output, training, look_ahead_mask, dec_padding_mask)
        final_output = self.final_layer(dec_output)  # (batch_size, tar_seq_len, target_vocab_size)
        return final_output, attention_weights
      def create_masks(self, inp, tar):
        # Encoder padding mask
        enc_padding_mask = create_padding_mask(inp)
        # Used in the 2nd attention block in the decoder.
        # This padding mask is used to mask the encoder outputs.
        dec_padding_mask = create_padding_mask(inp)
        # Used in the 1st attention block in the decoder.
        # It is used to pad and mask future tokens in the input received by
        # the decoder.
        look_ahead_mask = create_look_ahead_mask(tf.shape(tar)[1])
        dec_target_padding_mask = create_padding_mask(tar)
        look_ahead_mask = tf.maximum(dec_target_padding_mask, look_ahead_mask)
        return enc_padding_mask, look_ahead_mask, dec_padding_mask 
    

图示  自动生成描述

图 6.15:传统的变换器

  1. 我们快完成了。我们只需要定义超参数和优化器,使用与开创性论文中完全相同的设置,并定义损失函数:

    num_layers = 4
    d_model = 128
    dff = 512
    num_heads = 8
    dropout_rate = 0.1
    class CustomSchedule(tf.keras.optimizers.schedules.LearningRateSchedule):
      def __init__(self, d_model, warmup_steps=4000):
        super(CustomSchedule, self).__init__()
        self.d_model = d_model
        self.d_model = tf.cast(self.d_model, tf.float32)
        self.warmup_steps = warmup_steps
      def __call__(self, step):
        arg1 = tf.math.rsqrt(step)
        arg2 = step * (self.warmup_steps ** -1.5)
        return tf.math.rsqrt(self.d_model) * tf.math.minimum(arg1, arg2)
    learning_rate = CustomSchedule(d_model)
    optimizer = tf.keras.optimizers.Adam(learning_rate, beta_1=0.9, beta_2=0.98,
                                         epsilon=1e-9)
    def loss_function(real, pred):
      mask = tf.math.logical_not(tf.math.equal(real, 0))
      loss_ = loss_object(real, pred)
      mask = tf.cast(mask, dtype=loss_.dtype)
      loss_ *= mask
      return tf.reduce_sum(loss_)/tf.reduce_sum(mask)
    def accuracy_function(real, pred):
      accuracies = tf.equal(real, tf.argmax(pred, axis=2))
      mask = tf.math.logical_not(tf.math.equal(real, 0))
      accuracies = tf.math.logical_and(mask, accuracies)
      accuracies = tf.cast(accuracies, dtype=tf.float32)
      mask = tf.cast(mask, dtype=tf.float32)
      return tf.reduce_sum(accuracies)/tf.reduce_sum(mask)
    train_loss = tf.keras.metrics.Mean(name='train_loss')
    train_accuracy = tf.keras.metrics.Mean(name='train_accuracy') 
    
  2. 现在是定义变换器的时候了。让我们看看代码:

    transformer = Transformer(
        num_layers=num_layers,
        d_model=d_model,
        num_heads=num_heads,
        dff=dff,
        input_vocab_size=tokenizers.pt.get_vocab_size().numpy(),
        target_vocab_size=tokenizers.en.get_vocab_size().numpy(),
        rate=dropout_rate) 
    
  3. 让我们也用以下代码定义检查点:

    checkpoint_path = './checkpoints/train'
    ckpt = tf.train.Checkpoint(transformer=transformer,
                               optimizer=optimizer)
    ckpt_manager = tf.train.CheckpointManager(ckpt, checkpoint_path, max_to_keep=5)
    # if a checkpoint exists, restore the latest checkpoint.
    if ckpt_manager.latest_checkpoint:
      ckpt.restore(ckpt_manager.latest_checkpoint)
      print('Latest checkpoint restored!!') 
    
  4. 记住,变换器是自回归的。当前的输出被用来预测接下来会发生什么。我们使用前瞻掩码,以防止模型看到预期的输出。我们现在准备定义 train_step

    train_step_signature = [
        tf.TensorSpec(shape=(None, None), dtype=tf.int64),
        tf.TensorSpec(shape=(None, None), dtype=tf.int64),
    ]
    @tf.function(input_signature=train_step_signature)
    def train_step(inp, tar):
      tar_inp = tar[:, :-1]
      tar_real = tar[:, 1:]
      with tf.GradientTape() as tape:
        predictions, _ = transformer([inp, tar_inp],
                                     training = True)
        loss = loss_function(tar_real, predictions)
      gradients = tape.gradient(loss, transformer.trainable_variables)
      optimizer.apply_gradients(zip(gradients, transformer.trainable_variables))
      train_loss(loss)
      train_accuracy(accuracy_function(tar_real, predictions))
    EPOCHS = 20
    for epoch in range(EPOCHS):
      start = time.time()
      train_loss.reset_states()
      train_accuracy.reset_states()
      # inp -> portuguese, tar -> english
      for (batch, (inp, tar)) in enumerate(train_batches):
        train_step(inp, tar)
        if batch % 50 == 0:
          print(f'Epoch {epoch + 1} Batch {batch} Loss {train_loss.result():.4f} Accuracy {train_accuracy.result():.4f}')
      if (epoch + 1) % 5 == 0:
        ckpt_save_path = ckpt_manager.save()
        print(f'Saving checkpoint for epoch {epoch+1} at {ckpt_save_path}')
      print(f'Epoch {epoch + 1} Loss {train_loss.result():.4f} Accuracy {train_accuracy.result():.4f}')
      print(f'Time taken for 1 epoch: {time.time() - start:.2f} secs\n') 
    

    在 Colab 中运行训练步骤后,我们得到了以下情况:

    Epoch 20 Loss 1.5030 Accuracy 0.6720
    Time taken for 1 epoch: 169.01 secs 
    
  5. 我们现在准备进行翻译。以下步骤用于翻译:

    1. 使用葡萄牙语分词器(tokenizers.pt)对输入句子进行编码。

    2. 解码器输入初始化为 [START] token。

    3. 计算填充掩码(padding masks)和前瞻掩码(look-ahead masks)。

    4. 然后,解码器通过查看编码器输出和自身输出(自注意力)来输出预测结果。

    5. 将预测的 token 拼接到解码器输入中,并传递给解码器:

    class Translator(tf.Module):
      def __init__(self, tokenizers, transformer):
        self.tokenizers = tokenizers
        self.transformer = transformer
      def __call__(self, sentence, max_length=MAX_TOKENS):
        # input sentence is portuguese, hence adding the start and end token
        assert isinstance(sentence, tf.Tensor)
        if len(sentence.shape) == 0:
          sentence = sentence[tf.newaxis]
        sentence = self.tokenizers.pt.tokenize(sentence).to_tensor()
        encoder_input = sentence
        # As the output language is english, initialize the output with the
        # english start token.
        start_end = self.tokenizers.en.tokenize([''])[0]
        start = start_end[0][tf.newaxis]
        end = start_end[1][tf.newaxis]
        # 'tf.TensorArray' is required here (instead of a python list) so that the
        # dynamic-loop can be traced by 'tf.function'.
        output_array = tf.TensorArray(dtype=tf.int64, size=0, dynamic_size=True)
        output_array = output_array.write(0, start)
        for i in tf.range(max_length):
          output = tf.transpose(output_array.stack())
          predictions, _ = self.transformer([encoder_input, output], training=False)
          # select the last token from the seq_len dimension
          predictions = predictions[:, -1:, :]  # (batch_size, 1, vocab_size)
          predicted_id = tf.argmax(predictions, axis=-1)
          # concatentate the predicted_id to the output which is given to the decoder
          # as its input.
          output_array = output_array.write(i+1, predicted_id[0])
          if predicted_id == end:
            break
        output = tf.transpose(output_array.stack())
        # output.shape (1, tokens)
        text = tokenizers.en.detokenize(output)[0]  # shape: ()
        tokens = tokenizers.en.lookup(output)[0]
        # 'tf.function' prevents us from using the attention_weights that were
        # calculated on the last iteration of the loop. So recalculate them outside
        # the loop.
        _, attention_weights = self.transformer([encoder_input, output[:,:-1]], training=False)
        return text, tokens, attention_weights 
    
  6. 让我们用以下代码片段对示例句子调用翻译器:

    translator = Translator(tokenizers, transformer)
    def print_translation(sentence, tokens, ground_truth):
      print(f'{"Input:":15s}: {sentence}')
      print(f'{"Prediction":15s}: {tokens.numpy().decode("utf-8")}')
      print(f'{"Ground truth":15s}: {ground_truth}')
    sentence = 'os meus vizinhos ouviram sobre esta ideia.'
    ground_truth = 'and my neighboring homes heard about this idea .'
    translated_text, translated_tokens, attention_weights = translator(
        tf.constant(sentence))
    print_translation(sentence, translated_text, ground_truth) 
    

    得到的结果是:

    Input:         : os meus vizinhos ouviram sobre esta ideia.
    Prediction     : my neighbors have heard about this idea .
    Ground truth   : and my neighboring homes heard about this idea . 
    

在这份详细分析中,我们讨论了如何实现传统的变换器,考虑到位置编码、多头注意力和掩码。分析的代码见 www.tensorflow.org/text/tutorials/transformer

接下来,我们将讨论如何利用更高层次的库来使用变换器。

Hugging Face

正如之前讨论的那样,除非你需要实现一些非常特定的定制化,或者对核心研究感兴趣,否则从头实现变换器(transformer)可能不是最佳选择。如果你想理解变换器架构的内部细节,或者希望修改变换器架构以生成新的变种,这样做是有用的。如今,有很多优秀的库提供高质量的解决方案,其中之一就是 Hugging Face,它提供了一些高效的工具。Hugging Face 的构建围绕着将其开源的变换器库商业化的想法展开。让我们来看一下为什么这个库变得如此流行:

  • Hugging Face 提供了一个通用的 API 来处理多种变换器架构。

  • 它不仅提供基础模型,还提供具有不同类型“头”的模型,用于处理特定任务(例如,对于 BERT 架构,它提供 TFBertModel,以及用于情感分析等任务的 TFBertForSequenceClassification,用于命名实体识别等任务的 TFBertForTokenClassification,以及用于问答的 TFBertForQuestionAnswering 等)。

  • 你还可以通过使用这里提供的预训练权重,例如使用 TFBertForPreTraining,来轻松创建一个用于特定任务的网络。

  • 除了下一小节中的 pipeline() 方法外,我们还可以按常规方式定义模型,并使用 fit() 来训练它,使用 predict() 来进行推理,就像普通的 TF 模型一样(PyTorch 也有 Trainer 接口)。我们将在本章后面看到一个示例。

现在,让我们看一些使用 Hugging Face 的示例。

生成文本

在这一部分,我们将使用 GPT-2 进行自然语言生成,这是一个生成自然语言输出的软件过程。让我们从安装 Hugging Face 库开始:

  1. 第一步是创建一个专门的虚拟环境,在其中安装 transformer 库。在我的例子中,我使用的是 TensorFlow 2.0 的库:

    python -m venv .env
    source .env/bin/activate
    pip install transformers[tf-cpu] 
    
  2. 然后让我们通过下载一个用于情感分析的预训练模型来验证一切是否正常工作:

    python -c "from transformers import pipeline; print(pipeline('sentiment-analysis')('we love you'))" 
    

    由于期望的情感应该是非常积极的,我们将看到如下的内容:

    [{'label': 'POSITIVE', 'score': 0.9998704791069031}] 
    
  3. 现在,让我们专注于使用 GPT-2 生成文本:

    from transformers import pipeline
    generator = pipeline(task="text-generation") 
    

    你应该看到如下的内容:

    No model was supplied, defaulted to gpt2 (https://huggingface.co/gpt2)
    Downloading: 100%|██████████████████████████████| 665/665 [00:00<00:00, 167kB/s]
    Downloading: 100%|███████████████████████████| 475M/475M [03:24<00:00, 2.44MB/s 
    
  4. 让我们给生成器传递一些文本,看看结果如何。第一句话来自托尔金的作品,第二句来自爱因斯坦的理论,第三句来自《哈利·波特》:

    generator("Three Rings for the Elven-kings under the sky, Seven for the Dwarf-lords in their halls of stone") 
    
    Setting 'pad_token_id' to 50256 (first 'eos_token_id') to generate sequence
    [{'generated_text': 'Three Rings for the Elven-kings under the sky, Seven for the Dwarf-lords in their halls of stone and Eight for the Dwarves in their halls of rock! Three new Rings of the Elven-kings under the sky, Seven for'}] 
    
    generator ("The original theory of relativity is based upon the premise that all coordinate systems in relative uniform translatory motion to each other are equally valid and equivalent ") 
    
    Setting 'pad_token_id' to 50256 (first 'eos_token_id') to generate sequence
    [{'generated_text': 'The original theory of relativity is based upon the premise that all coordinate systems in relative uniform translatory motion to each other are equally valid and equivalent \xa0to one another. In other words, they can all converge, and therefore all the laws are valid'}] 
    
    generator ("It takes a great deal of bravery to stand up to our enemies") 
    
    Setting 'pad_token_id' to 50256 (first 'eos_token_id') to generate sequence
    [{'generated_text': 'It takes a great deal of bravery to stand up to our enemies that day. She still has a lot to learn from it, or it could take decades to do.\n\nWhile some braver men struggle, many are not as lucky'}] 
    

很简单,不是吗?

自动选择模型和自动标记化

Hugging Face 在帮助开发者自动化尽可能多的步骤方面做得非常出色。让我们看一些例子:

  1. 你可以轻松地从几十种可用的预训练模型中导入一个。所有可用模型的完整列表在这里:huggingface.co/docs/transformers/model_doc/auto

    from transformers import TFAutoModelForSequenceClassification
    model = TFAutoModelForSequenceClassification.from_pretrained("distilbert-base-uncased") 
    
    Downloading: 100%|█████████████████████████████| 483/483 [00:00<00:00, 68.9kB/s]
    Downloading: 100%|███████████████████████████| 347M/347M [01:05<00:00, 5.59MB/s]
    … 
    

    你可能应该在下游任务上训练此模型,以便将其用于预测和推理。

  2. 你可以使用AutoTokenizer将单词转换为模型使用的标记:

    from transformers import AutoTokenizer
    tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
    sequence = "The original theory of relativity is based upon the premise that all coordinate systems"
    print(tokenizer(sequence)) 
    
    {'input_ids': [101, 1996, 2434, 3399, 1997, 20805, 2003, 2241, 2588, 1996, 18458, 2008, 2035, 13530, 3001, 102], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]} 
    

命名实体识别

命名实体识别NER)是一个经典的 NLP 任务。根据维基百科,命名实体识别——也称为(命名的)实体识别、实体分块和实体提取——是信息提取的一个子任务,旨在定位和分类未结构化文本中提到的命名实体,并将其分为预定义的类别,如人名、组织、地点、医学编码、时间表达、数量、货币值和百分比等。

让我们看看如何利用 Hugging Face 轻松地执行此任务:

  1. 首先,让我们创建一个 NER 流水线:

    from transformers import pipeline
    ner_pipe = pipeline("ner")
    sequence = """Mr. and Mrs. Dursley, of number four, Privet Drive, were proud to say that they were perfectly normal, thank you very much."""
    for entity in ner_pipe(sequence):
        print(entity) 
    
  2. 你将能够看到如下的内容,其中实体已被识别:

    {'entity': 'I-PER', 'score': 0.99908304, 'index': 6, 'word': 'Du', 'start': 13, 'end': 15}
    {'entity': 'I-PER', 'score': 0.9869529, 'index': 7, 'word': '##rs', 'start': 15, 'end': 17}
    {'entity': 'I-PER', 'score': 0.9784202, 'index': 8, 'word': '##ley', 'start': 17, 'end': 20}
    {'entity': 'I-ORG', 'score': 0.6860208, 'index': 14, 'word': 'P', 'start': 38, 'end': 39}
    {'entity': 'I-ORG', 'score': 0.7713562, 'index': 15, 'word': '##rive', 'start': 39, 'end': 43}
    {'entity': 'I-ORG', 'score': 0.76567733, 'index': 16, 'word': '##t', 'start': 43, 'end': 44}
    {'entity': 'I-ORG', 'score': 0.8087192, 'index': 17, 'word': 'Drive', 'start': 45, 'end': 50} 
    

命名实体识别可以理解九种不同的类别:

  • O:命名实体之外。

  • B-MIS:紧接着另一个杂项实体的杂项实体开始。

  • I-MIS:杂项实体。

  • B-PER:紧接着另一个人名的人的名字开始。

  • I-PER:一个人的名字。

  • B-ORG:紧接着另一个组织的组织开始。

  • I-ORG:组织。

  • B-LOC:紧接着另一个地点的地点开始。

  • I-LOC:地点。

这些实体在通常用于此任务的 CoNLL-2003 数据集中定义,并由 Hugging Face 自动选择。

总结

现在,让我们转向摘要任务,即以简洁明了的形式表达某些事物或某人最重要的事实或想法。Hugging Face 让使用 T5 模型作为默认模型变得非常简单。让我们来看一下代码:

  1. 首先,让我们使用默认的 T5 small 模型创建一个摘要管道:

    from transformers import pipeline
    summarizer = pipeline("summarization")
    ARTICLE = """
     Mr.
     and Mrs.
     Dursley, of number four, Privet Drive, were proud to say
     that they were perfectly normal, thank you very much.
     They were the last
     people you'd expect to be involved in anything strange or mysterious,
    because they just didn't hold with such nonsense.
     Mr.
     Dursley was the director of a firm called Grunnings, which made
     drills.
     He was a big, beefy man with hardly any neck, although he did
     have a very large mustache.
     Mrs.
     Dursley was thin and blonde and had
     nearly twice the usual amount of neck, which came in very useful as she
     spent so much of her time craning over garden fences, spying on the
     neighbors.
     The Dursleys had a small son called Dudley and in their
     opinion there was no finer boy anywhere"""
    print(summarizer(ARTICLE, max_length=130, min_length=30, do_sample=False)) 
    
  2. 结果,我们将看到类似以下内容:

    No model was supplied, defaulted to t5-small (https://huggingface.co/t5-small)
    Downloading: 100%|██████████████████████████| 1.17k/1.17k [00:00<00:00, 300kB/s]
    Downloading: 100%|███████████████████████████| 231M/231M [01:29<00:00, 2.71MB/s]
    [{'summary_text': "Mr. and Mrs. Dursley, of number four, were the last people you'd expect to be involved in anything strange or mysterious . the Dursleys had a small son called Dudley and in their opinion there was no finer boy anywhere ."}] 
    
  3. 假设你想换一个不同的模型。其实这非常简单,你只需要更改一个参数:

    summarizer = pipeline("summarization", model='t5-base') 
    
  4. 结果,我们可以看到类似以下内容:

    Downloading: 100%|████████████████████████████████████████████████████████████| 773k/773k [00:00<00:00, 1.28MB/s]
    Downloading: 100%|██████████████████████████████████████████████████████████| 1.32M/1.32M [00:00<00:00, 1.93MB/s]
    [{'summary_text': "bob greene says he and his wife were perfectly normal . he says they were the last people you'd expect to be involved in anything strange or mysterious . greene: they were a big, beefy man with hardly any neck, but had a very large mustache ."}] 
    

微调

对于 transformers,一个常见的使用模式是先使用预训练的大型语言模型(LLM),然后对该模型进行微调以适应特定的下游任务。当然,微调步骤是在你自己的数据集上进行的,而预训练则是在非常大的数据集上完成的。这种两步策略的优势在于节省计算成本并减少碳足迹。此外,微调使得你可以使用最先进的模型,而无需从头开始训练一个。让我们看看如何使用 TF 进行模型微调。这个例子可以在 huggingface.co/docs/transformers/training 找到,其中使用的预训练模型是 bert-base-cased,该模型在“Yelp 评论”数据集上进行了微调(数据集可以在 huggingface.co/datasets/yelp_review_full 找到)。让我们从 huggingface.co/docs/transformers/training 查看代码。

  1. 首先,让我们加载并标记化 Yelp 数据集:

    from datasets import load_dataset
    dataset = load_dataset("yelp_review_full")
    from transformers import AutoTokenizer
    tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
    def tokenize_function(examples):
        return tokenizer(examples["text"], padding="max_length", truncation=True)
    tokenized_datasets = dataset.map(tokenize_function, batched=True)
    small_train_dataset = tokenized_datasets["train"].shuffle(seed=42).select(range(1000))
    small_eval_dataset = tokenized_datasets["test"].shuffle(seed=42).select(range(1000)) 
    
  2. 然后,我们将其转换为 TF 格式的数据集:

    from transformers import DefaultDataCollator
    data_collator = DefaultDataCollator(return_tensors="tf")
    # convert the tokenized datasets to TensorFlow datasets
    tf_train_dataset = small_train_dataset.to_tf_dataset(
        columns=["attention_mask", "input_ids", "token_type_ids"],
        label_cols=["labels"],
        shuffle=True,
        collate_fn=data_collator,
        batch_size=8,
    )
    tf_validation_dataset = small_eval_dataset.to_tf_dataset(
        columns=["attention_mask", "input_ids", "token_type_ids"],
        label_cols=["labels"],
        shuffle=False,
        collate_fn=data_collator,
        batch_size=8,
    ) 
    
  3. 现在,我们可以使用 TFAutoModelForSequenceClassification,并特别选择 bert-base-cased

    import tensorflow as tf
    from transformers import TFAutoModelForSequenceClassification
    model = TFAutoModelForSequenceClassification.from_pretrained("bert-base-cased", num_labels=5) 
    
  4. 最后,微调就是使用 Keras/TF 2.0 中标准的训练模型方法,通过编译模型,然后使用 fit 进行训练:

    model.compile(
        optimizer=tf.keras.optimizers.Adam(learning_rate=5e-5),
        loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
        metrics=tf.metrics.SparseCategoricalAccuracy(),
    )
    model.fit(tf_train_dataset, validation_data=tf_validation_dataset, epochs=3) 
    

如果你愿意,可以在公开的 Colab 笔记本上测试代码(可访问 huggingface.co/docs/transformers/training)。如果你自己运行代码,你应该能够看到类似 图 6.16 的内容:

一张计算机的屏幕截图,描述自动生成,信心较高

图 6.16:在 Colab 笔记本上微调 BERT

接下来,我们将介绍 TFHub。

TFHub

在上一节中,我们讨论了如何使用 Hugging Face Transformer 库。现在,我们将看看另一个名为 TFHub 的库,网址为tfhub.dev/。TensorFlow Hub 是一个包含经过训练的机器学*模型的库,这些模型可以进行微调并部署到任何地方。其核心思想是仅需几行代码,就能重用像 BERT 和 Faster R-CNN 这样的预训练模型。

使用 TFHub 就像写几行代码一样简单。让我们来看一个简单的例子,其中我们加载一个预训练模型来计算嵌入。在这个例子中,我们使用nnlm-en-dim128,这是一个基于标记的文本嵌入模型,经过英语 Google News 200B 语料库的训练:

!pip install --upgrade tensorflow_hub
import tensorflow_hub as hub
model = hub.KerasLayer("https://tfhub.dev/google/nnlm-en-dim128/2")
embeddings = model(["The rain in Spain.", "falls",
                    "mainly", "In the plain!"])
print(embeddings.shape)  #(4,128) 

现在让我们看看如何使用 BERT。此代码改编自www.tensorflow.org/hub/tutorials/bert_experts,也可以在 Hugging Face 上找到(huggingface.co/docs/transformers/training):

  1. 让我们设置环境并导入一些有用的模块:

    !pip install seaborn
    !pip install sklearn
    !pip install tensorflow_hub
    !pip install tensorflow_text
    import seaborn as sns
    from sklearn.metrics import pairwise
    import tensorflow as tf
    import tensorflow_hub as hub
    import tensorflow_text as text  # Imports TF ops for preprocessing. 
    
  2. 让我们定义几个句子,用于比较它们之间的相似度:

    sentences = [
        "Do not pity the dead, Harry. Pity the living, and, above all those who live without love.",
        "It is impossible to manufacture or imitate love",
        "Differences of habit and language are nothing at all if our aims are identical and our hearts are open.",
        "What do I care how he looks? I am good-looking enough for both of us, I theenk! All these scars show is zat my husband is brave!",
        "Love as powerful as your mother's for you leaves it's own mark. To have been loved so deeply, even though the person who loved us is gone, will give us some protection forever.",
        "Family…Whatever yeh say, blood's important. . . .",
        "I cared more for your happiness than your knowing the truth, more for your peace of mind than my plan, more for your life than the lives that might be lost if the plan failed."
    ] 
    
  3. 然后,让我们使用 TFHub 上的预训练 BERT 模型来计算输入句子的嵌入。BERT 的输出就是这些嵌入本身:

    #@title Configure the model { run: "auto" }
    BERT_MODEL = "https://tfhub.dev/google/experts/bert/wiki_books/2" # @param {type: "string"} ["https://tfhub.dev/google/experts/bert/wiki_books/2", "https://tfhub.dev/google/experts/bert/wiki_books/mnli/2", "https://tfhub.dev/google/experts/bert/wiki_books/qnli/2", "https://tfhub.dev/google/experts/bert/wiki_books/qqp/2", "https://tfhub.dev/google/experts/bert/wiki_books/squad2/2", "https://tfhub.dev/google/experts/bert/wiki_books/sst2/2",  "https://tfhub.dev/google/experts/bert/pubmed/2", "https://tfhub.dev/google/experts/bert/pubmed/squad2/2"]
    # Preprocessing must match the model, but all the above use the same.
    PREPROCESS_MODEL = "https://tfhub.dev/tensorflow/bert_en_uncased_preprocess/3"
    preprocess = hub.load(PREPROCESS_MODEL)
    bert = hub.load(BERT_MODEL)
    inputs = preprocess(sentences)
    outputs = bert(inputs) 
    
  4. 现在让我们定义一些辅助函数,通过pairwise.cosine_similarity来展示嵌入之间的相似度:

    def plot_similarity(features, labels):
      """Plot a similarity matrix of the embeddings."""
      cos_sim = pairwise.cosine_similarity(features)
      sns.set(font_scale=1.2)
      cbar_kws=dict(use_gridspec=False, location="left")
      g = sns.heatmap(
          cos_sim, xticklabels=labels, yticklabels=labels,
          vmin=0, vmax=1, cmap="Blues", cbar_kws=cbar_kws)
      g.tick_params(labelright=True, labelleft=False)
      g.set_yticklabels(labels, rotation=0)
      g.set_title("Semantic Textual Similarity")
    plot_similarity(outputs["pooled_output"], sentences) 
    

有兴趣的读者可以在 Hugging Face 网站上访问 Colab 笔记本(可在huggingface.co/docs/transformers/training找到),并可视化一个显示句子之间相似度的热图。总的来说,使用 TFHub 与 LLM 一起使用真的很简单,不是吗?

评估

评估 transformers 模型需要考虑多种类别的指标,并理解这些类别之间的成本权衡。让我们来看一下主要的指标。

质量

transformers 模型的质量可以通过多个常用数据集进行衡量。让我们来看看最常用的几个数据集。

GLUE

通用语言理解评估GLUE)基准是一个用于训练、评估和分析自然语言理解系统的资源集合。GLUE 可以在gluebenchmark.com/访问。

GLUE 包括:

  • 一个建立在现有数据集上的九个句子或句子对语言理解任务的基准,选择这些数据集是为了覆盖不同的大小、文本类型和难度级别

  • 一个设计用于评估和分析模型在广泛语言现象方面的表现的诊断数据集,涵盖自然语言中常见的各种语言现象

  • 一个用于跟踪基准测试上表现的公开排行榜,以及一个可视化模型在诊断集上的表现的仪表板

图 6.17展示了 2022 年 3 月的 GLUE 仪表板:

图形用户界面,应用程序,电子邮件描述自动生成

图 6.17:GLUE 仪表板

SuperGLUE

*年来,新的预训练和迁移学*模型与方法推动了语言理解任务在表现上的显著提升。GLUE 基准提供了一个单一的评分指标,用于总结在多种语言理解任务上的进展,但该基准的表现最*接*非专家人类的水平,表明进一步研究的空间有限。

SuperGLUE 是一个新基准,类似于 GLUE,但提供了一组更困难的语言理解任务、改进的资源以及一个新的公开排行榜。图 6.18是 2022 年 3 月的 SuperGLUE 排行榜:

图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 6.18:SuperGLUE 排行榜

SQuAD

SQuAD 是一个用于评估问题与答案的数据集,rajpurkar.github.io/SQuAD-explorer/。具体而言,斯坦福问答数据集SQuAD)是一个阅读理解数据集,包含由群众工作者在一组维基百科文章上提出的问题,每个问题的答案是相应阅读段落中的一段文本或片段,否则该问题可能无法回答。

SQuAD2.0 结合了 SQuAD1.1 中的 100,000 个问题和超过 50,000 个由群众工作者故意编写的无法回答的问题,这些问题看起来与可以回答的问题相似。为了在 SQuAD2.0 中取得好成绩,系统不仅需要在可能的情况下回答问题,还必须判断何时没有答案可以支持该段落,并避免回答。

RACE

阅读理解数据集(Examinations)RACE)是一个机器阅读理解数据集,包含来自英语考试的 27,933 篇文章和 97,867 个问题,目标人群是 12 至 18 岁的中国学生。RACE 包括两个子集,分别是来自初中和高中考试的 RACE-M 和 RACE-H。RACE-M 包含 28,293 个问题,RACE-H 包含 69,574 个问题。每个问题都有四个候选答案,其中一个是正确的。RACE 的数据生成过程与大多数机器阅读理解数据集不同,RACE 中的问题专门设计用于测试人类阅读技能,由领域专家创建,而不是通过启发式方法或众包生成问题和答案。RACE 可在www.cs.cmu.edu/~glai1/data/race/获取。图 6.19展示了 RACE 排行榜:

表格 描述自动生成

图 6.19:RACE 排行榜

NLP-progress

NLP-progress 是一个仓库,用于跟踪自然语言处理(NLP)领域的进展,包括最常见的 NLP 任务的数据库和当前的最先进模型。该网站旨在跟踪 NLP 领域的进展,并概述最常见的 NLP 任务及其相应数据集中的最先进模型。NLP-progress 旨在涵盖传统和核心 NLP 任务,如依存句法分析和词性标注,以及更*期的任务,如阅读理解和自然语言推理。如果你需要一个良好的起点来寻找适合你任务的质量指标,那么nlpprogress.com/是你开始的地方。

尺寸

前一节概述了质量指标。本节重点介绍了各种变换器架构中使用的参数数量。如图 6.20所示,*年来,变换器的规模竞争愈加激烈。回顾 2018 年,BERT 的规模大约为 3.4 亿参数,到 2021 年,T5 的参数数达到了 110 亿,而 Megatron 突破了 5000 亿参数。最*的 Switch Transformer 拥有超过一万亿个参数,预计很快我们将看到第一个拥有 100 万亿个参数的模型。事实上,有证据表明,模型越大越有优势,它能够记忆信息并进行泛化。然而,训练如此大的模型需要巨大的计算资源:

Table  Description automatically generated

图 6.20:变换器的规模(以十亿个参数为单位)

万亿参数的变换器正在到来!

事实上,论文arxiv.org/pdf/1906.02243.pdf警告了训练大规模模型的可持续性影响(见图 6.21),无论是在云计算成本还是二氧化碳排放方面:

Table  Description automatically generated

图 6.21:训练模型的成本估算,包括二氧化碳排放(磅)和云计算成本(美元)- 来源:arxiv.org/pdf/1906.02243.pdf

所以,大小并不是唯一能提升变换器质量的因素,因为更大的模型实际上可能只会带来边际上的改进,而且训练它们需要巨大的计算资源。

更大并不总意味着更好

2022 年初,出现了一种新趋势,即采用一种混合方法,将大模型与更传统的检索机制结合使用。我们在本章前面讨论过这种方法,当时我们讨论了 RETRO。RETRO 语言模型实现了一种基于外部记忆使用的学*方案。DeepMind 声称,RETRO(或称“检索增强型变换器”)的表现相当于一个神经网络,大小是其原始模型的 25 倍。GPT-3 拥有 1750 亿个参数,而 RETRO 只使用了其中的 70 亿个。当然,这样做需要更少的时间、能量和计算能力来训练。

服务成本

提供模型服务的成本取决于多个因素,没有合理的假设很难估算成本。当然,服务成本与模型中的参数数量有关。此外,提交给模型进行推理的查询次数也是一个因素。然后,重要的是考虑是否由云服务提供商管理模型,或者是否在本地基础设施中提供服务。在这种情况下,可能需要记住,MLOps(参见en.wikipedia.org/wiki/MLOps)是开发机器学*模型并将其部署为生产系统的过程。当然,MLOps 的最佳实践可能被采用来优化服务成本。

在本节中,我们已经看到评估变压器模型的几个关键因素,即质量、大小和服务成本。这个列表显然不是完整的,适当的评估会考虑这些因素之间的最佳折衷。在下一节中,我们将讨论优化。

优化

优化变压器模型涉及构建轻量化、响应迅速且能效高的模型。让我们看看优化模型时最常采用的思想。

量化

量化的核心思想是通过使用较小的精度来逼*网络的权重。这个想法非常简单,但在实际中效果很好。如果你有兴趣了解更多,我们推荐由 Amir Gholami 等人撰写的论文《高效神经网络推理的量化方法综述》,arxiv.org/pdf/2103.13630.pdf

权重剪枝

权重剪枝的核心思想是移除网络中的一些连接。基于幅度的权重剪枝通常在训练过程中将模型权重逐渐归零,从而增加模型的稀疏性。这种简单的技术在模型大小和服务成本方面都有好处,因为基于幅度的权重剪枝会在训练过程中逐渐归零模型权重,从而实现模型稀疏性。稀疏模型更容易压缩,而且在推理时可以跳过零值,从而提高延迟性能。

再说一次,权重剪枝涉及权衡,因为它可能会导致一些质量损失,尽管通常这些损失非常小。如果你有兴趣了解更多,请查看 TensorFlow 关于剪枝的指南:www.tensorflow.org/model_optimization/guide/pruning/comprehensive_guide

蒸馏

知识蒸馏的核心思想是训练一个小模型来复制大模型的行为。这种压缩技术有时被称为师生学*。你应该查看的开创性论文是由 Geoffrey Hinton、Oriol Vinyals 和 Jeff Dean 撰写的《蒸馏神经网络中的知识》,arxiv.org/abs/1503.02531

在过去几年里,我们看到了许多蒸馏后的变换器。例如,DistilBERT 是一个基于 BERT 架构的小型、快速、便宜且轻量的变换器模型。知识蒸馏在预训练阶段进行,以减少 BERT 模型的大小 40%。Hugging Face 提供了一些现成的 Python 脚本,用于蒸馏 seq2seq T5 模型,脚本可在github.com/huggingface/transformers/tree/master/examples/research_projects/seq2seq-distillation找到。使用该脚本非常直观:

python distillation.py --teacher t5-small --data_dir cnn_dm \
--student_decoder_layers 3 --student_encoder_layers 6 --tokenizer_name t5-small \
--learning_rate=3e-4 --freeze_encoder --no_teacher --freeze_embeds \
--do_train --train_batch_size 32 \
--do_predict \
--model_name_or_path t5-small --eval_beams 2 --eval_max_gen_length 142 \
--val_check_interval 0.25 --n_val 1000 \
--output_dir distilt5 --gpus 1 --logger_name wandb 

在本节中,我们讨论了优化变换器的一些技术,具体包括量化、权重修剪和蒸馏。接下来,我们将讨论变换器常见的陷阱。

常见陷阱:做与不做

在本节中,我们将提供五个必做事项和一些典型的禁止事项,这些建议通常在处理变换器时会被推荐。

必做事项

让我们从推荐的最佳实践开始:

  • 使用预训练的大型模型。 如今,几乎总是从一个已预训练的模型(如 T5)开始比从头训练变换器更为方便。如果你使用一个预训练模型,肯定是在“巨人的肩膀”上站立,想一想吧!

  • 确实要从少量样本学*开始。 当你开始使用变换器时,通常从一个预训练模型开始,然后进行轻量的少量样本学*是一个不错的选择。通常,这样可以在不产生高计算成本的情况下提高结果的质量。

  • 使用领域数据和客户数据进行微调。 在玩转预训练模型和少量样本学*之后,你可以考虑在自己的专有数据或公开的领域数据上进行适当的微调。

  • 熟悉变换器的库。 Hugging Face 或 TFHub 提供了几乎所有已知变换器的最先进实现。除非你有一些非常特殊的需求或者在进行创新性研究工作,否则从这些库入手会非常有用。

  • 熟悉最常用的评估指标。 当你使用变换器时,理想的做法是考虑在质量、大小、服务成本以及许多其他因素之间的权衡。

不要做的事

现在让我们来看看一些你应该避免的陷阱!

  • 不要使用非常大的模型作为起点。 大型模型在训练和服务上都有一定的成本。你需要大量的资源进行微调,而且每次查询的服务成本可能也很高。最好从较小的模型开始,并了解它们是否能够满足你的质量需求。

  • 不要使用未优化的模型。 如今,量化、修剪和蒸馏是标准技术,任何投入生产的变换器系统都需要使用这些技术。

在本节中,我们已经了解了一些变换器的最佳实践。在下一节中,我们将讨论这些架构的未来解决方案。

变换器的未来

变换器最初应用于自然语言处理任务,而卷积神经网络(CNN)通常用于图像处理系统。最*,变换器开始成功地应用于视觉处理任务。视觉变换器计算图像中各个小区域(例如,16 x 16 像素)之间的像素关系。这一方法在 Alexey Dosovitskiy 等人所写的研讨会论文An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale中被提出,论文链接为arxiv.org/abs/2010.11929,旨在使注意力计算变得可行。

视觉变换器ViTs)如今已被用于复杂的应用,如自动驾驶。特斯拉的工程师展示了他们的特斯拉自动驾驶系统在汽车的多摄像头系统中使用了变换器。当然,ViTs 也用于更传统的计算机视觉任务,包括但不限于图像分类、目标检测、视频深度伪造检测、图像分割、异常检测、图像合成和聚类分析。其结果通常优于 CNN。

另一个需要考虑的方向是少样本学*(FSL)。少样本学*是指通过提供非常少量的训练数据来指导机器学*模型的预测,就像在推理时提供几个示例,而与标准的微调技术不同,后者需要相对大量的训练数据,才能使预训练模型适应所需任务并达到较高的准确性。

因此,为特定任务训练的模型可以以非常低的成本被重新用于全新的任务。例如,假设我们训练了一个文本生成模型。然后,我们希望执行新的任务,如翻译或总结。我们所做的就是提供一些翻译示例(比如一对对手动翻译的文本),或者一些总结示例(同样是几对示例)。仅此而已,不需要重新训练或微调训练。

由于 FSL 已被证明在多个不断扩展的领域中表现良好,因此不要惊讶于未来的人工智能训练阶段将变得越来越不重要。更多信息可以参考这篇论文,Code Generation Tools (Almost) for Free? A Study of Few-Shot, Pre-Trained Language Models on Code,作者为 Patrick Bareiß、Beatriz Souza、Marcelo d’Amorim 和 Michael Pradel。作者提出使用 FSL 通过 CodeGen 生成编程代码,CodeGen 是一个开源程序合成模型(见github.com/salesforce/CodeGen)。

总结

在这一章中,我们讨论了变压器模型,这是一种深度学*架构,已经彻底改变了传统的自然语言处理领域。我们首先回顾了架构背后的关键直觉,并介绍了各种类别的变压器以及对最流行模型的深入分析。然后,我们重点讲解了基于原始架构和流行库(如 Hugging Face 和 TFHub)的实现。接着,我们简要讨论了评估、优化和在使用变压器时常见的最佳实践。最后一部分着重回顾了变压器如何应用于计算机视觉任务,这是一个与自然语言处理完全不同的领域。这需要对注意力机制进行细致的定义。最终,注意力是你所需要的一切!在注意力的核心,仅仅是向量之间的余弦相似度。

下一章将专注于无监督学*。

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,结识志同道合的人,与 2000 多名成员一起学*,链接:packt.link/keras

第七章:无监督学*

到目前为止,本书主要集中在监督学*及其通过监督学*进行学*的模型。从这一章开始,我们将探索一个较少被探索且更具挑战性的领域——无监督学*、 self-supervised 学*和对比学*。在本章中,我们将深入探讨一些流行且有用的无监督学*模型。与监督学*不同,监督学*中的训练数据集包括输入数据和目标标签,而无监督学*则处理仅提供输入数据的情况。模型通过自身学*输入数据的内在分布,无需任何目标标签的引导。聚类和降维是最常用的两种无监督学*技术。在本章中,我们将学*与这两种技术相关的不同机器学*和神经网络方法。我们将涵盖聚类和降维所需的技术,并详细讲解玻尔兹曼机,最后使用 TensorFlow 实现上述技术。所涉及的概念将扩展到构建限制玻尔兹曼机RBMs)。本章将包括:

  • 主成分分析

  • K 均值聚类

  • 自组织映射

  • 玻尔兹曼机

  • RBMs

本章的所有代码文件可以在packt.link/dltfchp7找到。

让我们从最常见且最常用的降维技术——主成分分析方法开始。

主成分分析

主成分分析PCA)是最流行的多变量统计降维技术。它分析包含多个相关变量的训练数据,这些变量通常是相互关联的,并从训练数据中提取重要信息,形成一组新的正交变量,称为主成分。

我们可以使用两种方法进行 PCA:特征分解奇异值分解SVD)。

PCA 将* n 维输入数据减少到 r 维输入数据,其中 r <n 。简而言之,PCA 涉及平移原点并执行坐标轴旋转,使得其中一个轴(主轴)与数据点的方差最大。从原始数据集中,通过进行此变换并删除(移除)方差较小的正交轴,得到一个降维后的数据集。在此,我们使用 SVD 方法进行 PCA 降维。考虑X,它是一个 n 维数据,包含 p 个点,即 X 是一个大小为p × n*的矩阵。从线性代数中我们知道,任何实矩阵都可以通过奇异值分解进行分解:

其中,UV 是正交矩阵(即 U.U^T = V.V^T = 1),其大小分别为 p × pn × n 是一个大小为 p × n 的对角矩阵。U 矩阵称为左奇异矩阵V 矩阵称为右奇异矩阵,而 这个对角矩阵包含了 X 的奇异值,作为其对角元素。这里假设 X 矩阵是已居中的。V 矩阵的列是主成分,而 的列是经过主成分变换后的数据。

现在,为了将数据从 n 维降至 k 维(其中 k < n),我们将选择 U 的前 k 列和 左上角的 k × k 部分。两者的乘积将给出我们的降维矩阵:

获得的 Y 数据将是降维后的数据。接下来,我们将在 TensorFlow 2.0 中实现 PCA。

在 MNIST 数据集上进行 PCA

现在让我们在 TensorFlow 2.0 中实现 PCA。我们一定会使用 TensorFlow;此外,我们还需要 NumPy 来进行一些基础的矩阵计算,并使用 Matplotlib、Matplotlib 工具包以及 Seaborn 来进行绘图:

import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import seaborn as sns 

接下来,我们加载 MNIST 数据集。由于我们使用 PCA 进行降维,因此不需要测试数据集或标签;然而,我们加载标签是为了在降维后验证 PCA 的效果。PCA 应该将相似的数据点聚集在一个簇中;因此,如果我们看到使用 PCA 形成的簇与我们的标签相似,那么这就表明我们的 PCA 有效:

((x_train, y_train), (_, _)) = tf.keras.datasets.mnist.load_data() 

在进行 PCA 之前,我们需要预处理数据。我们首先对数据进行归一化,使其值介于 0 和 1 之间,然后将图像从 28 × 28 矩阵重塑为一个 784 维的向量,最后通过减去均值来居中数据:

x_train = x_train / 255.
x_train = x_train.astype(np.float32)
x_train = np.reshape(x_train, (x_train.shape[0], 784))
mean = x_train.mean(axis = 1)
x_train = x_train - mean[:,None] 

现在我们的数据已经是正确的格式,我们利用 TensorFlow 强大的线性代数模块 (linalg) 来计算训练数据集的 SVD。TensorFlow 提供了 svd() 函数,定义在 tf.linalg 中,用来执行这个任务。然后,使用 diag 函数将 sigma 数组(s,即奇异值的列表)转换为对角矩阵:

s, u, v = tf.linalg.svd(x_train)
s = tf.linalg.diag(s) 

这将为我们提供一个大小为 784 × 784 的对角矩阵 s;一个大小为 60,000 × 784 的左奇异矩阵 u;以及一个大小为 784 × 784 的右奇异矩阵 v。这是因为函数 svd() 的参数 full_matrices 默认设置为 False。因此,它不会生成完整的 U 矩阵(在这种情况下是 60,000 × 60,000 的矩阵);相反,如果输入 X 的大小为 m × n,它会生成大小为 p = min(m, n)U 矩阵。

现在可以通过乘以 us 的相应切片来生成降维后的数据。我们将数据从 784 维降至 3 维;我们可以选择降至任何小于 784 的维度,但这里我们选择了 3 维,以便稍后更容易可视化。我们使用 tf.Tensor.getitem 以 Pythonic 方式对矩阵进行切片:

k = 3
pca = tf.matmul(u[:,0:k], s[0:k,0:k]) 

以下代码进行原始数据和降维数据形状的比较:

print('original data shape',x_train.shape)
print('reduced data shape', pca.shape) 
original data shape (60000, 784)
reduced data shape (60000, 3) 

最后,让我们在三维空间中绘制数据点:

Set = sns.color_palette("Set2", 10)
color_mapping = {key:value for (key,value) in enumerate(Set)}
colors = list(map(lambda x: color_mapping[x], y_train))
fig = plt.figure()
ax = Axes3D(fig)
ax.scatter(pca[:, 0], pca[:, 1],pca[:, 2], c=colors) 

图表,散点图,表面图 描述自动生成图 7.1:使用 PCA 降维后的 MNIST 数据集散点图

您可以看到,颜色相同的点,因此标签相同的点聚集在一起。我们因此成功地使用 PCA 对 MNIST 图像进行了降维处理。每个原始图像的大小为 28 × 28。使用 PCA 方法,我们可以将其降至更小的尺寸。通常对于图像数据,降维是必要的。这是因为图像数据体积庞大,且包含大量冗余数据。

TensorFlow 嵌入 API

TensorFlow 还提供了一个嵌入 API,可以使用 TensorBoard 查找和可视化 PCA 和 tSNE [1] 聚类。您可以在此查看 MNIST 图像的实时 PCA: projector.tensorflow.org。下图为参考复制:

图表,散点图 描述自动生成图 7.2:主成分分析的可视化,应用于 MNIST 数据集

您可以使用 TensorBoard 处理您的数据。它包含一个名为 Embedding Projector 的工具,允许您交互式地可视化嵌入。Embedding Projector 工具有三个面板:

  • 数据面板:它位于左上角,您可以在此面板中选择数据、标签等。

  • 投影面板:位于左下角,您可以在这里选择所需的投影类型。它提供三种选择:PCA、t-SNE 和自定义。

  • 检查器面板:位于右侧,您可以在这里搜索特定的点,并查看最*邻的列表。

图形用户界面,图表,散点图 描述自动生成

图 7.3:Embedding Projector 工具的截图

PCA 是一个用于可视化数据集和寻找变量间线性关系的有用工具。它也可以用于聚类、异常值检测和特征选择。接下来,我们将学* K-means 算法,一种聚类数据的方法。

K-means 聚类

K-means 聚类,顾名思义,是一种对数据进行聚类的技术,即将数据划分为指定数量的数据点。它是一种无监督学*技术。它通过识别给定数据中的模式来工作。记得《哈利·波特》中的分院帽吗?它在书中所做的就是聚类——将新的(未标记的)学生分成四个不同的类别:格兰芬多、拉文克劳、赫奇帕奇和斯莱特林。

人类非常擅长将物体分组;聚类算法尝试将这种能力赋予计算机。有许多可用的聚类技术,如层次聚类、贝叶斯聚类或划分聚类。K-means 聚类属于划分聚类;它将数据划分为 k 个簇。每个簇都有一个中心,称为质心。簇的数量 k 必须由用户指定。

K-means 算法按以下方式工作:

  1. 随机选择 k 个数据点作为初始质心(簇中心)。

  2. 将每个数据点分配给离其最*的质心;可以使用不同的度量来衡量“最*”,最常见的是欧几里得距离。

  3. 使用当前的簇成员关系重新计算质心,使得平方距离的总和减少。

  4. 重复最后两个步骤,直到达到收敛。

在之前的 TensorFlow 版本中,KMeans 类是在 Contrib 模块中实现的;然而,该类在 TensorFlow 2.0 中已不可用。在这里,我们将改用 TensorFlow 2.0 提供的高级数学函数来实现 K-means 聚类。

TensorFlow 中的 K-means

为了演示 TensorFlow 中的 K-means,我们将在以下代码中使用随机生成的数据。我们生成的数据将包含 200 个样本,我们将其分成三个簇。首先导入所需的所有模块,定义变量,确定样本点的数量(points_n)、要形成的簇的数量(clusters_n)以及我们将进行的迭代次数(iteration_n)。我们还设置随机数种子以确保工作可复现:

import matplotlib.pyplot as plt
import numpy as np
import tensorflow as tf
points_n = 200
clusters_n = 3
iteration_n = 100
seed = 123
np.random.seed(seed)
tf.random.set_seed(seed) 

现在我们随机生成数据,并从中随机选择三个质心:

points = np.random.uniform(0, 10, (points_n, 2))
centroids = tf.slice(tf.random.shuffle(points), [0, 0], [clusters_n, -1]) 

现在让我们绘制这些点:

plt.scatter(points[:, 0], points[:, 1], s=50, alpha=0.5)
plt.plot(centroids[:, 0], centroids[:, 1], 'kx', markersize=15)
plt.show() 

你可以在下图中看到所有点的散点图以及随机选择的三个质心:

Chart, scatter chart  Description automatically generated

图 7.4:从三个随机选择的质心生成的随机数据绘制图

我们定义了函数 closest_centroids(),将每个点分配给离其最*的质心:

def closest_centroids(points, centroids):
    distances = tf.reduce_sum(tf.square(tf.subtract(points, centroids[:,None])), 2)
    assignments = tf.argmin(distances, 0)
    return assignments 

我们创建了另一个函数 move_centroids()。它重新计算质心,使得平方距离的总和减少:

def move_centroids(points, closest, centroids):
    return np.array([points[closest==k].mean(axis=0) for k in range(centroids.shape[0])]) 

现在我们迭代调用这两个函数 100 次。我们选择的迭代次数是任意的;你可以增加或减少迭代次数来观察效果:

for step in range(iteration_n):
    closest = closest_centroids(points, centroids)
    centroids = move_centroids(points, closest, centroids) 

现在让我们可视化质心在 100 次迭代后的变化:

plt.scatter(points[:, 0], points[:, 1], c=closest, s=50, alpha=0.5)
plt.plot(centroids[:, 0], centroids[:, 1], 'kx', markersize=15)
plt.show() 

图 7.5 中,你可以看到经过 100 次迭代后的最终质心。我们还根据每个数据点距离哪个质心最*来为其上色。黄色的点对应一个簇(最接*其中心的交叉点),紫色和绿色的簇点也同样如此:

图 7.5:经过 100 次迭代后的最终质心绘图

请注意,plot 命令在 Matplotlib 3.1.1 或更高版本中有效。

在上述代码中,我们决定将簇的数量限制为三个,但在大多数未标记数据的情况下,通常无法确定存在多少个簇。我们可以通过肘部法则来确定最优的簇数。该方法的原理是,我们应该选择一个能减少平方误差和SSE)距离的簇数。如果 k 是簇的数量,那么随着 k 的增加,SSE 会减少,当 k 等于数据点的数量时,SSE 为 0;此时,每个点都是自己的簇。显然,我们不希望将簇的数量设为这个值,因此当我们绘制 SSE 与簇数之间的图表时,我们应该看到图表中出现一个拐点,就像手肘的形状,这也就是该方法得名——肘部法则。以下代码计算了数据的平方误差和:

def sse(points, centroids):
    sse1 = tf.reduce_sum(tf.square(tf.subtract(points, centroids[:,None])), 2).numpy()
    s = np.argmin(sse1, 0)
    distance = 0
    for i in range(len(points)):
      distance += sse1[s[i], i]
    return distance/len(points) 

现在让我们使用肘部法则来寻找数据集的最优簇数。为此,我们将从一个簇开始,也就是所有点都属于同一个簇,然后按顺序增加簇的数量。在代码中,我们每次增加一个簇,最多为十一簇。对于每个簇的数量,我们使用上述代码来找到质心(因此找到簇),并计算 SSE:

w_sse = []
for n in range(1, 11):
  centroids = tf.slice(tf.random.shuffle(points), [0, 0], [n, -1])
  for step in range(iteration_n):
    closest = closest_centroids(points, centroids)
    centroids = move_centroids(points, closest, centroids)
  #print(sse(points, centroids))
  w_sse.append(sse(points, centroids))
plt.plot(range(1, 11),w_sse) 
plt.xlabel('Number of clusters') 

图 7.6 显示了数据集的不同簇值。当簇的数量为四时,拐点非常明显:

图 7.6:绘制 SSE 与簇数的关系图

K-means 聚类非常流行,因为它快速、简单且稳健。但它也有一些缺点,最大的缺点是用户必须指定簇的数量。其次,该算法并不保证全局最优解;如果初始随机选择的质心发生变化,结果也可能会变化。第三,它对离群值非常敏感。

K-means 的变种

在原始的 k-means 算法中,每个点都属于一个特定的簇(质心);这被称为硬聚类。然而,我们可以让一个点同时属于所有簇,并通过隶属度函数来定义它属于某个特定簇(质心)的程度。这被称为模糊聚类软聚类

这种变体由 J. C. Dunn 于 1973 年提出,后来由 J. C. Bezdek 在 1981 年进行了改进。尽管软聚类收敛的时间较长,但当一个点属于多个类时,或者当我们想知道某个点与不同簇的相似度时,它是非常有用的。

加速的 k-means 算法是由 Charles Elkan 于 2003 年创建的。他利用了三角不等式关系(即直线是连接两点之间最短的距离)。他不仅在每次迭代时进行所有距离计算,还跟踪了点与质心之间的距离的上下限。

2006 年,David Arthur 和 Sergei Vassilvitskii 提出了 k-means++算法。他们提出的主要改进是在质心的初始化上。他们表明,如果选择相距较远的质心,k-means 算法就不太可能收敛到一个次优解。

另一种替代方法是在每次迭代时不使用整个数据集,而是使用小批量数据。这一修改由 David Sculey 在 2010 年提出。现在,既然我们已经讲解了 PCA 和 k-means,接下来我们将介绍一个有趣的网络——自组织网络(self-organized network)或胜者为王单元(winner-take-all units)。

自组织映射

k-means 和 PCA 都可以对输入数据进行聚类;然而,它们没有保持拓扑关系。在本节中,我们将讨论自组织映射SOMs),有时也被称为科洪能网络Kohonen networks)或胜者为王单元WTUs)。它们保持拓扑关系。SOM 是一种非常特殊的神经网络,灵感来源于人类大脑的一个独特特征。在我们的大脑中,不同的感官输入是以拓扑有序的方式表示的。与其他神经网络不同,神经元之间不是通过权重相互连接的;相反,它们通过相互影响来进行学*。SOM 的最重要特点是,神经元以拓扑方式表示学*到的输入。它们由 Teuvo Kohonen 于 1982 年提出[7]。

在自组织映射(SOM)中,神经元通常放置在(1D 或 2D)格点的节点上。虽然也可以使用更高维度,但在实际应用中很少使用。格点中的每个神经元都通过权重矩阵与所有输入单元相连。图 7.7展示了一个具有 6 × 8(48 个神经元)和 5 个输入的 SOM。为了简洁起见,图中只显示了连接所有输入到一个神经元的权重向量。在这种情况下,每个神经元将有七个元素,从而形成一个大小为 40 × 5 的组合权重矩阵:

图 7.7:一个具有 5 个输入和 48 个神经元的自组织映射

SOM 通过竞争学*来学*。它可以看作是 PCA 的非线性推广,因此,像 PCA 一样,SOM 也可以用于降维。

为了实现 SOM,我们首先需要理解其工作原理。第一步是将网络的权重初始化为某个随机值,或者从输入中随机抽取样本。每个占据格点空间的神经元将被分配特定的位置。现在,当输入被呈现时,与输入距离最小的神经元被宣告为胜者(WTU)。这一过程是通过测量所有神经元的权重向量(W)和输入向量(X)之间的距离来实现的:

这里,d[j]是神经元j的权重与输入X之间的距离。具有最小d值的神经元就是胜者。

接下来,胜者神经元及其邻*神经元的权重将进行调整,确保下次如果相同的输入被呈现时,同一神经元仍然是胜者。

为了决定哪些邻*神经元需要被修改,网络使用一个邻域函数 ;通常,高斯墨西哥帽函数被选作邻域函数。邻域函数在数学上表示如下:

这里,是神经元影响半径的时间依赖性,d是神经元与胜出神经元的距离。从图形上看,该函数像一顶帽子(因此得名),如图 7.8所示:

图 7.8:以图形形式展示的“高斯墨西哥帽”函数

邻域函数的另一个重要特性是其半径随着时间的推移而减小。因此,在开始时,许多邻*神经元的权重会被修改,但随着网络的学*,最终只有少数神经元的权重(有时,甚至只有一个或没有)会在学*过程中被修改。

权重的变化由以下方程给出:

这个过程会对所有输入进行多次迭代。随着迭代的进行,我们会根据迭代次数逐步减少学*率和半径。

SOM 计算开销较大,因此对于非常大的数据集并不实用。不过,它们易于理解,并且能够很好地发现输入数据之间的相似性。因此,它们已被用于图像分割和确定自然语言处理中的词相似性映射。

使用 SOM 进行颜色映射

SOM 生成的输入空间特征图的一些有趣属性包括:

  • 特征图提供了输入空间的良好表示。这一特性可以用于执行向量量化,从而使我们能够拥有连续的输入空间,通过使用 SOM,我们可以将其表示为离散的输出空间。

  • 特征图是拓扑有序的,即输出格点中神经元的空间位置对应输入的特定特征。

  • 特征图还反映了输入空间的统计分布;拥有最多输入样本的领域在特征图中会占据更大的区域。

SOM 的这些特性使其成为许多有趣应用的自然选择。这里,我们使用 SOM 将一系列给定的 R、G、B 像素值聚类到相应的颜色映射中。我们从导入模块开始:

import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt 

代码的主要部分是我们的类WTU__init__函数初始化了 SOM 的各种超参数,包括我们 2D 格点的维度(m, n)、输入中的特征数量(dim)、邻域半径(sigma)、初始权重以及拓扑信息:

# Define the Winner Take All units
class WTU(object):
  #_learned = False
  def __init__(self, m, n, dim, num_iterations, eta = 0.5, sigma = None):
    """
    m x n : The dimension of 2D lattice in which neurons are arranged
    dim : Dimension of input training data
    num_iterations: Total number of training iterations
    eta : Learning rate
    sigma: The radius of neighbourhood function.
    """
    self._m = m
    self._n = n
    self._neighbourhood = []
    self._topography = []
    self._num_iterations = int(num_iterations)
    self._learned = False
    self.dim = dim
    self.eta = float(eta)

    if sigma is None:
      sigma = max(m,n)/2.0 # Constant radius
    else:
      sigma = float(sigma)
    self.sigma = sigma

    print('Network created with dimensions',m,n)

    # Weight Matrix and the topography of neurons
    self._W = tf.random.normal([m*n, dim], seed = 0)
    self._topography = np.array(list(self._neuron_location(m, n))) 

该类的最重要功能是training()函数,我们在其中使用之前讨论过的 Kohonen 算法来找到胜出单元,然后基于邻域函数更新权重:

def training(self,x, i):
    m = self._m
    n= self._n

    # Finding the Winner and its location
    d = tf.sqrt(tf.reduce_sum(tf.pow(self._W - tf.stack([x for i in range(m*n)]),2),1))
    self.WTU_idx = tf.argmin(d,0)

    slice_start = tf.pad(tf.reshape(self.WTU_idx, [1]),np.array([[0,1]]))
    self.WTU_loc = tf.reshape(tf.slice(self._topography, slice_start,[1,2]), [2])

    # Change learning rate and radius as a function of iterations
    learning_rate = 1 - i/self._num_iterations
    _eta_new = self.eta * learning_rate
    _sigma_new = self.sigma * learning_rate

    # Calculating Neighbourhood function
    distance_square = tf.reduce_sum(tf.pow(tf.subtract(
        self._topography, tf.stack([self.WTU_loc for i in range(m * n)])), 2), 1)
    neighbourhood_func = tf.exp(tf.negative(tf.math.divide(tf.cast(
distance_square, "float32"), tf.pow(_sigma_new, 2))))

    # multiply learning rate with neighbourhood func
    eta_into_Gamma = tf.multiply(_eta_new, neighbourhood_func)

    # Shape it so that it can be multiplied to calculate dW
    weight_multiplier = tf.stack([tf.tile(tf.slice(
        eta_into_Gamma, np.array([i]), np.array([1])), [self.dim])
        for i in range(m * n)])
    delta_W = tf.multiply(weight_multiplier,
        tf.subtract(tf.stack([x for i in range(m * n)]),self._W))
    new_W = self._W + delta_W
    self._W = new_W 

fit() 函数是一个辅助函数,它调用 training() 函数并存储质心网格,以便于后续检索:

def fit(self, X):
    """
    Function to carry out training
    """
    for i in range(self._num_iterations):
        for x in X:
            self.training(x,i)
    # Store a centroid grid for easy retrieval
    centroid_grid = [[] for i in range(self._m)]
    self._Wts = list(self._W)
    self._locations = list(self._topography)
    for i, loc in enumerate(self._locations):
        centroid_grid[loc[0]].append(self._Wts[i])
    self._centroid_grid = centroid_grid
    self._learned = True 

然后有一些更多的辅助函数来找到胜者并生成一个二维神经元格,还有一个将输入向量映射到二维格中相应神经元的函数:

def winner(self, x):
    idx = self.WTU_idx,self.WTU_loc
    return idx

def _neuron_location(self,m,n):
    """
    Function to generate the 2D lattice of neurons
    """
    for i in range(m):
       for j in range(n):
          yield np.array([i,j])
def get_centroids(self):
    """
    Function to return a list of 'm' lists, with each inner list containing the 'n' corresponding centroid locations as 1-D NumPy arrays.
    """
    if not self._learned:
       raise ValueError("SOM not trained yet")
    return self._centroid_grid
def map_vects(self, X):
    """
    Function to map each input vector to the relevant neuron in the lattice
    """
    if not self._learned:
       raise ValueError("SOM not trained yet")
       to_return = []
       for vect in X:
          min_index = min([i for i in range(len(self._Wts))],
                           key=lambda x: np.linalg.norm(vect -
                           self._Wts[x]))
          to_return.append(self._locations[min_index])
       return to_return 

我们还需要对输入数据进行归一化处理,因此我们创建了一个函数来实现这一操作:

def normalize(df):
    result = df.copy()
    for feature_name in df.columns:
        max_value = df[feature_name].max()
        min_value = df[feature_name].min()
        result[feature_name] = (df[feature_name] - min_value) / (max_value - min_value)
    return result.astype(np.float32) 

让我们读取数据。数据包含不同颜色的红色、绿色和蓝色通道值。让我们对它们进行归一化处理:

## Reading input data from file
import pandas as pd
df = pd.read_csv('colors.csv')  # The last column of data file is a label
data = normalize(df[['R', 'G', 'B']]).values
name = df['Color-Name'].values
n_dim = len(df.columns) - 1
# Data for Training
colors = data
color_names = name 

让我们创建我们的自组织映射(SOM)并进行拟合:

som = WTU(30, 30, n_dim, 400, sigma=10.0)
som.fit(colors) 

拟合函数运行稍微长一些,因为我们的代码并没有针对性能优化,而是为了说明概念。现在,让我们看看训练模型的结果。让我们运行以下代码:

# Get output grid
image_grid = som.get_centroids()
# Map colours to their closest neurons
mapped = som.map_vects(colors)
# Plot
plt.imshow(image_grid)
plt.title('Color Grid SOM')
for i, m in enumerate(mapped):
    plt.text(m[1], m[0], color_names[i], ha='center', va='center',
             bbox=dict(facecolor='white', alpha=0.5, lw=0)) 

你可以看到二维神经元格中的彩色图:

图 7.9:二维神经元格的彩色映射图

你可以看到,对于相似颜色的神经元,它们会被紧密地放置在一起。接下来,我们进入一个有趣的架构——限制玻尔兹曼机(RBM)。

限制玻尔兹曼机(RBM)

RBM 是一个两层的神经网络——第一层称为 可见层,第二层称为 隐藏层。它们被称为 浅层神经网络,因为它们只有两层深。最早由 Paul Smolensky 于 1986 年提出(他称其为和谐网络 [1]),后由 Geoffrey Hinton 在 2006 年提出 对比散度CD)作为训练方法。可见层的所有神经元都与隐藏层的所有神经元相连,但存在一个 限制——同一层中的神经元不能相连。RBM 中的所有神经元本质上是二值的;它们要么激活,要么不激活。

RBM 可以用于降维、特征提取和协同过滤。RBM 的训练可以分为三个部分:前向传播、反向传播,然后进行比较。

让我们更深入地研究一下数学原理。我们可以将 RBM 的操作分为两次传播:

前向传播:可见单元(V)的信息通过权重(W)和偏置(c)传递到隐藏单元(h[0])。隐藏单元是否激活取决于随机概率( 是随机概率),该概率基本上是一个 Sigmoid 函数:

反向传播:然后,隐藏单元表示(h[0])通过相同的权重 W 传回可见单元,但使用不同的偏置 c,此时模型重建输入。同样,输入会被采样:

这两次传播会重复 k 步骤,或者直到收敛[4]达到为止。根据研究人员的说法,k=1 已经能够得到良好的结果,所以我们将设置 k = 1

可见向量 V 和隐藏向量 h 的联合配置具有如下能量:

每个可见向量 V 还与自由能相关,自由能是指某一配置所需的能量,使其与所有包含 V 的配置具有相同的概率:

使用对比散度目标函数,即 Mean(F(V[original])) - Mean(F(V[reconstructed])),权重的变化由以下公式给出:

这里, 是学*率。对偏置 bc 也存在类似的表达式。

使用 RBM 进行图像重建

让我们在 TensorFlow 中构建一个 RBM。这个 RBM 将被设计用来重建手写数字。这是你学*的第一个生成模型;在接下来的章节中,我们还会学*一些其他的生成模型。我们导入 TensorFlow、NumPy 和 Matplotlib 库:

import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt 

我们定义了一个类 RBM。该类的 __init_() 函数初始化了可见层(input_size)和隐藏层(output_size)中的神经元数量。该函数初始化了隐藏层和可见层的权重和偏置。在下面的代码中,我们将它们初始化为零。你也可以尝试使用随机初始化:

#Class that defines the behavior of the RBM
class RBM(object):

    def __init__(self, input_size, output_size, lr=1.0, batchsize=100):
        """
        m: Number of neurons in visible layer
        n: number of neurons in hidden layer
        """
        # Defining the hyperparameters
        self._input_size = input_size # Size of Visible
        self._output_size = output_size # Size of outp
        self.learning_rate = lr # The step used in gradient descent
        self.batchsize = batchsize         # The size of how much data will be used for training per sub iteration

        # Initializing weights and biases as matrices full of zeroes
        self.w = tf.zeros([input_size, output_size], np.float32) # Creates and initializes the weights with 0
        self.hb = tf.zeros([output_size], np.float32) # Creates and initializes the hidden biases with 0
        self.vb = tf.zeros([input_size], np.float32) # Creates and initializes the visible biases with 0 

我们定义了前向和后向传播的函数:

 # Forward Pass
    def prob_h_given_v(self, visible, w, hb):
        # Sigmoid 
        return tf.nn.sigmoid(tf.matmul(visible, w) + hb)
    # Backward Pass
    def prob_v_given_h(self, hidden, w, vb):
        return tf.nn.sigmoid(tf.matmul(hidden, tf.transpose(w)) + vb) 

我们创建一个函数来生成随机二进制值。这是因为隐藏单元和可见单元的更新是通过随机概率进行的,具体取决于每个单元的输入(对于隐藏层是每个单元的输入,而对于可见层是自上而下的输入):

 # Generate the sample probability
    def sample_prob(self, probs):
        return tf.nn.relu(tf.sign(probs - tf.random.uniform(tf.shape(probs)))) 

我们需要一些函数来重建输入:

def rbm_reconstruct(self,X):
    h = tf.nn.sigmoid(tf.matmul(X, self.w) + self.hb)
    reconstruct = tf.nn.sigmoid(tf.matmul(h, tf.transpose(self.w)) + self.vb)
    return reconstruct 

为了训练创建的 RBM,我们定义了 train() 函数。该函数计算对比散度的正负梯度项,并使用权重更新公式来更新权重和偏置:

# Training method for the model
def train(self, X, epochs=10):

    loss = []
    for epoch in range(epochs):
        #For each step/batch
        for start, end in zip(range(0, len(X), self.batchsize),range(self.batchsize,len(X), self.batchsize)):
            batch = X[start:end]

            #Initialize with sample probabilities

            h0 = self.sample_prob(self.prob_h_given_v(batch, self.w, self.hb))
            v1 = self.sample_prob(self.prob_v_given_h(h0, self.w, self.vb))
            h1 = self.prob_h_given_v(v1, self.w, self.hb)

            #Create the Gradients
            positive_grad = tf.matmul(tf.transpose(batch), h0)
            negative_grad = tf.matmul(tf.transpose(v1), h1)

            #Update learning rates 
            self.w = self.w + self.learning_rate *(positive_grad - negative_grad) / tf.dtypes.cast(tf.shape(batch)[0],tf.float32)
            self.vb = self.vb +  self.learning_rate * tf.reduce_mean(batch - v1, 0)
            self.hb = self.hb +  self.learning_rate * tf.reduce_mean(h0 - h1, 0)

        #Find the error rate
        err = tf.reduce_mean(tf.square(batch - v1))
        print ('Epoch: %d' % epoch,'reconstruction error: %f' % err)
        loss.append(err)

    return loss 

现在我们的类已经准备好,我们实例化一个 RBM 对象,并在 MNIST 数据集上对其进行训练:

(train_data, _), (test_data, _) =  tf.keras.datasets.mnist.load_data()
train_data = train_data/np.float32(255)
train_data = np.reshape(train_data, (train_data.shape[0], 784))
test_data = test_data/np.float32(255)
test_data = np.reshape(test_data, (test_data.shape[0], 784))
#Size of inputs is the number of inputs in the training set
input_size = train_data.shape[1]
rbm = RBM(input_size, 200)
err = rbm.train(train_data,50) 

让我们绘制学*曲线:

plt.plot(err)
plt.xlabel('epochs')
plt.ylabel('cost') 

在下图中,你可以看到我们 RBM 的学*曲线:

图 7.10:RBM 模型的学*曲线

现在,我们展示了用于可视化重建图像的代码:

out = rbm.rbm_reconstruct(test_data)
# Plotting original and reconstructed images
row, col = 2, 8
idx = np.random.randint(0, 100, row * col // 2)
f, axarr = plt.subplots(row, col, sharex=True, sharey=True, figsize=(20,4))
for fig, row in zip([test_data,out], axarr):
    for i,ax in zip(idx,row):
        ax.imshow(tf.reshape(fig[i],[28, 28]), cmap='Greys_r')
        ax.get_xaxis().set_visible(False)
        ax.get_yaxis().set_visible(False) 

以及重建后的图像:

图 7.11:使用 RBM 进行图像重建

上排是输入的手写图像,下排是重建的图像。你可以看到这些图像与人类手写的数字非常相似。在接下来的章节中,你将学*可以生成更复杂图像的模型,例如人工人脸。

深度信念网络

现在我们已经对限制玻尔兹曼机(RBMs)有了深入了解,并知道如何通过对比散度进行训练,我们可以继续研究 2006 年 Hinton 及其团队提出的第一个成功的深度神经网络架构——深度信念网络DBNs),该内容见于论文深度信念网络的快速学*算法。在这个模型之前,训练深度架构是非常困难的,不仅仅是因为计算资源有限,还因为正如第八章自编码器》中所讨论的那样,存在消失梯度问题。在 DBNs 中,首次展示了如何通过贪心的逐层训练来训练深度架构。

用最简单的话来说,DBNs 就是堆叠的 RBM。每个 RBM 都是通过对比散度单独训练的。我们从第一个 RBM 层的训练开始,一旦它训练完成,我们就训练第二个 RBM 层。第二个 RBM 的可见单元现在接收到第一个 RBM 的隐藏单元的输出,当它接收到输入数据时。这个过程在每个 RBM 层增加时重复进行。

让我们尝试堆叠我们的RBM类。为了构建 DBN,我们需要在RBM类中定义一个函数;一个 RBM 的隐藏单元的输出需要传递给下一个 RBM:

 #Create expected output for our DBN
    def rbm_output(self, X):
        out = tf.nn.sigmoid(tf.matmul(X, self.w) + self.hb)
        return out 

现在我们可以直接使用RBM类来创建堆叠的 RBM 结构。在以下代码中,我们创建一个 RBM 堆叠:第一个 RBM 将有 500 个隐藏单元,第二个 RBM 有 200 个隐藏单元,第三个 RBM 有 50 个隐藏单元:

RBM_hidden_sizes = [500, 200 , 50 ] #create 2 layers of RBM with size 400 and 100
#Since we are training, set input as training data
inpX = train_data
#Create list to hold our RBMs
rbm_list = []
#Size of inputs is the number of inputs in the training set
input_size = train_data.shape[1]
#For each RBM we want to generate
for i, size in enumerate(RBM_hidden_sizes):
    print ('RBM: ',i,' ',input_size,'->', size)
    rbm_list.append(RBM(input_size, size))
    input_size = size 
---------------------------------------------------------------------
RBM:  0   784 -> 500
RBM:  1   500 -> 200
RBM:  2   200 -> 50 

对于第一个 RBM,MNIST 数据是输入。第一个 RBM 的输出被作为输入传递给第二个 RBM,依此类推,直到通过连续的 RBM 层:

#For each RBM in our list
for rbm in rbm_list:
    print ('Next RBM:')
    #Train a new one
    rbm.train(tf.cast(inpX,tf.float32))
    #Return the output layer
    inpX = rbm.rbm_output(inpX) 

我们的 DBN 已经准备好了。这三个堆叠的 RBM 现在通过无监督学*进行了训练。DBNs 也可以通过监督学*进行训练。为此,我们需要微调已训练 RBM 的权重,并在最后添加一个完全连接层。在他们的论文《使用深度信念网络进行分类》中,Hebbo 和 Kim 展示了他们如何使用 DBN 进行 MNIST 分类;这是一个很好的入门介绍。

总结

本章介绍了主要的无监督学*算法。我们探讨了最适合于降维、聚类和图像重建的算法。我们首先讲解了降维算法 PCA,然后使用 k-means 和自组织映射进行聚类。接着,我们研究了限制玻尔兹曼机,并看到了如何将其应用于降维和图像重建。接下来,我们深入探讨了堆叠 RBM,即深度信念网络,并在 MNIST 数据集上训练了一个由三层 RBM 组成的 DBN。

在下一章,我们将探索另一种使用无监督学*范式的模型——自编码器。

参考文献

  1. Smith, Lindsay. (2006). 主成分分析教程www.cs.otago.ac.nz/cosc453/student_tutorials/principal_components.pdf

  2. Movellan, J. R. 主成分分析教程mplab.ucsd.edu/tutorials/pca.pdf

  3. TensorFlow 投影器:projector.tensorflow.org/

  4. 奇异值分解SVD)教程。MIT:web.mit.edu/be.400/www/SVD/Singular_Value_Decomposition.htm

  5. Shlens, Jonathon. (2014). 主成分分析教程。arXiv 预印本 arXiv:1404.1100:arxiv.org/abs/1404.1100

  6. Goodfellow, I., Bengio, Y., 和 Courville, A. (2016). 深度学*。MIT 出版社:www.deeplearningbook.org

  7. Kohonen, T. (1982). 自组织形成拓扑正确的特征图。生物控制论 43,第 1 期:59-69。

  8. Kanungo, Tapas 等人 (2002). 一种高效的 k-均值聚类算法:分析与实现。IEEE 模式分析与机器智能学报 24.7:881-892。

  9. Ortega, Joaquín Pérez 等人。关于 K-均值算法的研究问题:使用 Matlab 的实验试验。CEUR 工作坊论文集:语义网与新技术。

  10. Chen, K. (2009). 关于度量空间和欧几里得空间中 k-中值和 k-均值聚类的核心集及其应用。SIAM 计算学报 39.3:923-947。

  11. 确定数据集中的聚类数目en.wikipedia.org/wiki/Determining_the_number_of_clusters_in_a_data_set

  12. Lloyd, S. P. (1982). PCM 中的最小二乘量化mlsp.cs.cmu.edu/courses/fall2010/class14/lloyd.pdf

  13. Dunn, J. C. (1973-01-01). ISODATA 过程的模糊相对物及其在检测紧凑且分离良好聚类中的应用。控制论学报。3(3):32-57。

  14. Bezdek, James C. (1981). 具有模糊目标函数算法的模式识别

  15. Peters, G., Crespo, F., Lingras, P., 和 Weber, R. (2013). 软聚类–模糊与粗糙方法及其扩展与派生。国际*似推理杂志 54,第 2 期:307-322。

  16. Sculley, D. (2010). Web 规模 k-均值聚类。第 19 届国际万维网大会论文集,pp. 1177-1178。ACM。

  17. Smolensky, P. (1986). 动态系统中的信息处理:和谐理论的基础。编号 CU-CS-321-86。科罗拉多大学博尔德分校计算机科学系。

  18. Salakhutdinov, R., Mnih, A., 和 Hinton, G. (2007). 限制玻尔兹曼机在协同过滤中的应用。第 24 届国际机器学*会议论文集。ACM。

  19. Hinton, G. (2010). 训练限制玻尔兹曼机的实用指南。Momentum 9.1:926。

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,与志同道合的人相遇,并与超过 2000 名成员一起学*: packt.link/keras

第八章:自编码器

自编码器是通过无监督学*(有时也称为半监督学*)来学*的,因为输入也被视为目标。在本章中,你将学*并实现不同类型的自编码器,最终学*如何堆叠自编码器。我们还将看到如何使用自编码器生成 MNIST 数字,最后,介绍构建长短期记忆自编码器生成句子向量的步骤。本章包含以下主题:

  • 普通自编码器

  • 稀疏自编码器

  • 去噪自编码器

  • 卷积自编码器

  • 堆叠自编码器

  • 使用 LSTM 自编码器生成句子

  • 变分自编码器生成图像

本章的所有代码文件可以在packt.link/dltfchp8找到

让我们开始吧!

自编码器简介

自编码器是一类神经网络,试图通过反向传播将输入重建为目标。自编码器由两部分组成:编码器和解码器。编码器读取输入并将其压缩成紧凑的表示,解码器则读取这个紧凑的表示并从中重建输入。换句话说,自编码器通过最小化重建误差来尝试学*恒等函数。

它们具有学*数据紧凑表示的固有能力。它们处于深度信念网络的核心,广泛应用于图像重建、聚类、机器翻译等多个领域。

你可能认为使用深度神经网络实现恒等函数很无聊;然而,之所以有趣,是因为实现的方式。自编码器中的隐藏单元数通常少于输入(和输出)单元的数量。这迫使编码器学*输入的压缩表示,而解码器则从中进行重建。如果输入数据中存在某种结构,比如输入特征之间的相关性,那么自编码器将会发现这些相关性,并最终学*到类似于主成分分析PCA)所学到的低维表示。

虽然 PCA 使用线性变换,但自编码器则使用非线性变换。

一旦自编码器训练完成,我们通常会丢弃解码器组件,只使用编码器组件来生成输入的紧凑表示。或者,我们也可以将编码器用作特征检测器,生成输入的紧凑且语义丰富的表示,并通过将 softmax 分类器附加到隐藏层来构建分类器。

自编码器的编码器和解码器组件可以使用稠密、卷积或递归网络来实现,这取决于所建模的数据类型。例如,稠密网络可能是用于构建协同过滤CF)模型的自编码器的不错选择,在这种模型中,我们基于实际的稀疏用户评分学*用户偏好的压缩模型。类似地,卷积神经网络可能适用于文章《iSee: Using Deep Learning to Remove Eyeglasses from Faces》中描述的用例,作者为 M. Runfeldt。另一方面,递归网络对于处理顺序或文本数据的自编码器是一个不错的选择,例如《深度患者:从电子健康记录中预测患者未来的无监督表示》(Deep Patient: An Unsupervised Representation to Predict the Future of Patients from the Electronic Health Records, Miotto 等)和跳跃思维向量(skip-thought vectors)。

我们可以将自编码器看作由两个级联网络组成。第一个网络是编码器,它接收输入x,并通过变换h将其编码为编码信号y,即:

y = h(x)

第二个网络使用编码信号y作为输入,执行另一个变换f,得到重建信号r,即:

r = f(y) = f(h(x))

我们将误差定义为e,即原始输入x与重建信号r之间的差异,e = x - r。然后,网络通过减少损失函数(例如,均方误差MSE))来学*,误差像多层感知机MLP)中那样向后传播到隐藏层。

根据编码层相对于输入的实际维度、损失函数和约束条件,存在多种类型的自编码器:变分自编码器、稀疏自编码器、去噪自编码器和卷积自编码器。

自编码器还可以通过依次堆叠编码器来堆叠,编码器将其输入压缩为越来越小的表示,然后按相反顺序堆叠解码器。堆叠自编码器具有更强的表达能力,连续层的表示捕捉了输入的层次化分组,类似于卷积神经网络中的卷积和池化操作。

堆叠自编码器曾经是按层训练的。例如,在图 8.1中的网络,我们首先训练X层,通过隐藏层H1重建X’层(忽略H2)。然后,我们训练H1层,通过隐藏层H2重建H1’层。最后,我们将所有层堆叠在一起,按所示配置进行微调,以便从X重建X’。然而,随着如今更好的激活函数和正则化函数的出现,训练这些网络时通常会一次性训练整个网络:

图示,形状 描述自动生成

图 8.1:堆叠自编码器的可视化

在本章中,我们将学*自编码器中的这些变化,并使用 TensorFlow 实现它们。

简单自编码器

简单自编码器,正如 Hinton 在他 2006 年的论文《使用神经网络减少数据维度》中提出的那样,仅包含一个隐藏层。隐藏层中的神经元数量少于输入层(或输出层)中的神经元数量。

这会导致信息流在网络中的瓶颈效应。编码器输入和解码器输出之间的隐藏层(y)也被称为“瓶颈层”。自编码器中的学*过程包括在隐藏层上开发输入信号的紧凑表示,以便输出层可以忠实地重建原始输入。

图 8.2中,您可以看到一个简单自编码器的架构:

图表,瀑布图 描述自动生成

图 8.2:简单自编码器架构

让我们尝试构建一个简单自编码器。在论文中,Hinton 使用它进行维度减少,而在接下来的代码中,我们将使用自编码器进行图像重建。我们将使用 MNIST 数据库训练自编码器,并用它来重建测试图像。在代码中,我们将使用 TensorFlow Keras 的Layers类来构建我们自己的编码器和解码器层,因此首先让我们了解一下Layers类。

TensorFlow Keras 层 ‒ 定义自定义层

TensorFlow 提供了一种简单的方法,允许您从零开始或将现有层组合起来定义自定义层。TensorFlow Keras 的layers包定义了一个Layers对象。我们可以通过将其作为Layers类的子类来创建自己的层。在定义层时,必须定义输出的维度。尽管输入维度是可选的,但如果不定义,系统将自动从数据中推断出来。要构建我们自己的层,我们需要实现三个方法:

  • __init__():在这里,您定义所有与输入无关的初始化。

  • build():在这里,我们定义输入张量的形状,并根据需要执行其他初始化。在我们的示例中,由于没有显式定义输入形状,我们无需定义build()方法。

  • call():这是执行前向计算的地方。

使用tensorflow.keras.layers.Layer类,我们现在定义编码器和解码器层。首先,从编码器层开始。我们导入tensorflow.kerasK,并创建一个Encoder类。Encoder接收输入并生成隐藏层或瓶颈层作为输出:

class Encoder(K.layers.Layer):
    def __init__(self, hidden_dim):
        super(Encoder, self).__init__()
        self.hidden_layer = K.layers.Dense(units=hidden_dim, activation=tf.nn.relu)
    def call(self, input_features):
        activation = self.hidden_layer(input_features)
        return activation 

接下来,我们定义Decoder类;该类接收来自Encoder的输出,并通过一个全连接神经网络传递。目标是能够重建Encoder的输入:

class Decoder(K.layers.Layer):
    def __init__(self, hidden_dim, original_dim):
        super(Decoder, self).__init__()
        self.output_layer = K.layers.Dense(units=original_dim, activation=tf.nn.relu)
    def call(self, encoded):
        activation = self.output_layer(encoded)
        return activation 

现在我们已经定义了编码器和解码器,我们使用tensorflow.keras.Model对象来构建自动编码器模型。你可以在以下代码中看到,在__init__()函数中我们实例化了编码器和解码器对象,在call()方法中我们定义了信号流。还请注意在_init__()中初始化的成员列表self.loss

class Autoencoder(K.Model):
    def __init__(self, hidden_dim, original_dim):
        super(Autoencoder, self).__init__()
        self.loss = []
        self.encoder = Encoder(hidden_dim=hidden_dim)
        self.decoder = Decoder(hidden_dim=hidden_dim, original_dim=original_dim)
    def call(self, input_features):
        encoded = self.encoder(input_features)
        reconstructed = self.decoder(encoded)
        return reconstructed 

在下一节中,我们将使用这里定义的自动编码器来重建手写数字。

使用自动编码器重建手写数字

现在我们已经准备好了包含编码器和解码器层的自动编码器模型,让我们尝试重建手写数字。完整的代码可以在本章的 GitHub 仓库中找到,文件名为VanillaAutoencoder.ipynb。代码将需要 NumPy、TensorFlow 和 Matplotlib 模块:

import numpy as np
import tensorflow as tf
import tensorflow.keras as K
import matplotlib.pyplot as plt 

在实际实现之前,我们还需要定义一些超参数。如果你尝试调整这些超参数,你会注意到,尽管模型的架构保持不变,但模型性能却有显著变化。超参数调优(有关更多细节,请参阅第一章使用 TF 的神经网络基础)是深度学*中的一个重要步骤。为了保证可重复性,我们为随机计算设置了种子:

np.random.seed(11)
tf.random.set_seed(11)
batch_size = 256
max_epochs = 50
learning_rate = 1e-3
momentum = 8e-1
hidden_dim = 128
original_dim = 784 

对于训练数据,我们使用的是 TensorFlow 数据集中的 MNIST 数据集。我们将数据归一化,使得像素值位于[0,1]之间;这通过将每个像素元素除以 255 来实现。

我们将张量从 2D 重塑为 1D。我们使用from_tensor_slices函数生成一个批量数据集,并沿着第一个维度对训练数据集进行切片(切片的张量)。另外请注意,我们没有使用独热编码标签;这是因为我们并没有使用标签来训练网络,因为自动编码器是通过无监督学*进行学*的:

(x_train, _), (x_test, _) = K.datasets.mnist.load_data()
x_train = x_train / 255.
x_test = x_test / 255.
x_train = x_train.astype(np.float32)
x_test = x_test.astype(np.float32)
x_train = np.reshape(x_train, (x_train.shape[0], 784))
x_test = np.reshape(x_test, (x_test.shape[0], 784))
training_dataset = tf.data.Dataset.from_tensor_slices(x_train).batch(batch_size) 

现在我们实例化我们的自动编码器模型对象,并定义训练时使用的损失函数和优化器。仔细观察损失函数的公式;它仅仅是原始图像与重建图像之间的差异。你可能会发现,重建损失这一术语也常在许多书籍和论文中用来描述它:

autoencoder = Autoencoder(hidden_dim=hidden_dim, original_dim=original_dim)
opt = tf.keras.optimizers.Adam(learning_rate=1e-2)
def loss(preds, real):
    return tf.reduce_mean(tf.square(tf.subtract(preds, real))) 

我们的自定义自动编码器模型将定义一个自定义训练过程,而不是使用自动训练循环。我们使用tf.GradientTape来记录梯度计算,并隐式地将梯度应用于模型的所有可训练变量:

def train(loss, model, opt, original):
    with tf.GradientTape() as tape:
        preds = model(original)
        reconstruction_error = loss(preds, original)
        gradients = tape.gradient(reconstruction_error, model.trainable_variables)
        gradient_variables = zip(gradients, model.trainable_variables)
    opt.apply_gradients(gradient_variables)
    return reconstruction_error 

上述的train()函数将在训练循环中调用,并将数据集以批次的形式输入到模型中:

def train_loop(model, opt, loss, dataset, epochs=20):
    for epoch in range(epochs):
        epoch_loss = 0
        for step, batch_features in enumerate(dataset):
            loss_values = train(loss, model, opt, batch_features)
            epoch_loss += loss_values
        model.loss.append(epoch_loss)
        print('Epoch {}/{}. Loss: {}'.format(epoch + 1, epochs, epoch_loss.numpy())) 

现在我们来训练我们的自动编码器:

train_loop(autoencoder, opt, loss, training_dataset, epochs=max_epochs) 

并绘制我们的训练图:

plt.plot(range(max_epochs), autoencoder.loss)
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.show() 

训练图如下所示。我们可以看到,随着网络的学*,损失/成本在减少,并且在 50 个训练周期后几乎保持不变,这意味着进一步增加训练周期将不会有帮助。如果我们希望进一步提高训练效果,我们应该改变像学*率和batch_size这样的超参数:

图表 描述自动生成

图 8.3:基础自编码器的损失曲线

图 8.4中,你可以看到原始图像(上)和重建图像(下);它们略显模糊,但仍然准确:

number = 10  # how many digits we will display
plt.figure(figsize=(20, 4))
for index in range(number):
    # display original
    ax = plt.subplot(2, number, index + 1)
    plt.imshow(x_test[index].reshape(28, 28), cmap='gray')
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    # display reconstruction
    ax = plt.subplot(2, number, index + 1 + number)
    plt.imshow(autoencoder(x_test)[index].numpy().reshape(28, 28), cmap='gray')
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show() 

文本描述自动生成,信心中等

图 8.4:使用基础自编码器的原始图像与重建图像

有趣的是,在前面的代码中,我们将输入的维度从 784 减少到 128,并且网络仍然能够重建原始图像。这应该能给你一些关于自编码器在降维方面强大功能的启示。自编码器相较于 PCA 在降维方面的一个优势是,PCA 只能表示线性变换,而我们可以在自编码器中使用非线性激活函数,从而在编码中引入非线性:

图表,散点图 描述自动生成

图 8.5:左侧图:通过取所有 60,000 个训练样本的前两个主成分得到的每个类别 500 个数字的二维编码。右侧图:784-500-2 自编码器找到的二维编码

图 8.5 比较了 PCA 和堆叠自编码器的结果,堆叠自编码器的结构为 784-500-2(这里的数字代表每个自编码器中编码器层的大小;这些自编码器具有对称的解码器)。

你可以看到右侧的彩色点被很好地分开,因此,堆叠自编码器相比 PCA 给出了更好的结果。现在你已经熟悉了基础自编码器,接下来让我们看看不同类型的自编码器及其实现细节。

稀疏自编码器

我们在上一节中讨论的自编码器更像是一个恒等网络;它只是简单地重建输入。重点在于在像素级别重建图像,唯一的约束是瓶颈层中的单元数。虽然像素级重建很有趣,但它主要是一个压缩机制,并不一定确保网络从数据集中学*到抽象特征。我们可以通过增加进一步的约束来确保网络从数据集中学*到抽象特征。

在稀疏自编码器中,增加了一个稀疏惩罚项到重建误差中。这会确保瓶颈层中在任何给定时间点只有较少的单元被激活。我们可以将稀疏惩罚项加入到编码器层中。

在以下代码中,你可以看到Encoder的稠密层现在增加了一个额外的参数activity_regularizer

class SparseEncoder(K.layers.Layer):
    def __init__(self, hidden_dim):
        # encoder initializer
        super(SparseEncoder, self).__init__()
        self.hidden_layer = K.layers.Dense(units=hidden_dim, activation=tf.nn.relu, activity_regularizer=regularizers.l1(10e-5))
    def call(self, input_features):
        # forward function
        activation = self.hidden_layer(input_features)
        return activation 

活动正则化器尝试减少层的输出(参考第一章TF 中的神经网络基础)。它将减少全连接层的权重和偏差,以确保输出尽可能小。TensorFlow 支持三种类型的activity_regularizer

  • l1:这里的活动计算为绝对值的和。

  • l2:这里的活动计算为平方值的和。

  • l1_l2:这包括 L1 和 L2 项。

保持代码其他部分不变,仅更改编码器,你可以从基础自编码器得到稀疏自编码器。稀疏自编码器的完整代码在 Jupyter 笔记本SparseAutoencoder.ipynb中。

或者,你也可以在损失函数中显式地添加一个稀疏正则化项。为此,你需要将稀疏项的正则化实现为一个函数。如果m是输入模式的总数,那么我们可以定义一个量(你可以在 Andrew Ng 的讲座中查看数学细节:web.stanford.edu/class/cs294a/sparseAutoencoder_2011new.pdf),它衡量每个隐藏层单元的净活动(即它平均多少次激活)。基本思想是设定一个约束,使其等于稀疏参数。这样就会在损失函数中添加一个稀疏正则化项,使得损失函数变为:

损失 = 均方误差 + 稀疏参数的正则化

这个正则化项会惩罚网络,如果偏离。一种标准的做法是使用Kullback-LeiberKL)散度(你可以通过这场有趣的讲座了解更多关于 KL 散度的内容:www.stat.cmu.edu/~cshalizi/754/2006/notes/lecture-28.pdf),来计算之间的差异。

让我们更深入地探讨一下 KL 散度,D[KL]。它是一个非对称的度量,用于衡量两个分布之间的差异,在我们这个例子中,指的是之间的差异。当相等时,差异为零;否则,当偏离时,差异会单调增加。数学表达式为:

我们将其添加到损失函数中,以隐式地包含稀疏项。我们需要为稀疏项设定一个常数值,并使用编码器输出计算

输入的紧凑表示存储在权重中。让我们可视化网络学*到的权重。以下是标准自编码器和稀疏自编码器的编码器层权重。

我们可以看到,在标准自编码器(a)中,许多隐藏单元具有非常大的权重(较亮),这表明它们被过度使用,而稀疏自编码器(b)的所有隐藏单元几乎均等地学*输入表示,我们看到颜色分布更加均匀:

A picture containing appliance, grate  Description automatically generated

图 8.6:编码器权重矩阵(a)标准自编码器和(b)稀疏自编码器

现在我们已经了解了稀疏自编码器,接下来我们转向自编码器能够从图像中去除噪声的案例。

去噪自编码器

我们在前面的部分中讨论的两个自编码器是欠完备自编码器的例子,因为它们的隐藏层相比输入(输出)层具有较低的维度。去噪自编码器属于完备自编码器类别,因为当隐藏层的维度大于输入层时,它们的效果更好。

去噪自编码器从受损(噪声)输入中学*;它将噪声输入传递给编码器网络,然后解码器重建的图像与原始输入进行比较。其目的是帮助网络学*如何去噪输入。它将不再仅仅进行像素级的比较,而是为了去噪,它将学*邻*像素的信息。

去噪自编码器与其他自编码器的主要区别有两点:首先,n_hidden,即瓶颈层中的隐藏单元数大于输入层中的单元数m,即n_hidden > m。其次,编码器的输入是受损的输入。

为了实现这一点,我们在测试和训练图像中添加噪声项:

noise = np.random.normal(loc=0.5, scale=0.5, size=x_train.shape)
x_train_noisy = x_train + noise
noise = np.random.normal(loc=0.5, scale=0.5, size=x_test.shape)
x_test_noisy = x_test + noise
x_train_noisy = np.clip(x_train_noisy, 0., 1.)
x_test_noisy = np.clip(x_test_noisy, 0., 1.) 

接下来让我们看看去噪自编码器的实际操作。

使用去噪自编码器清除图像

让我们使用去噪自编码器来清除手写的 MNIST 数字:

  1. 我们首先导入所需的模块:

    import numpy as np
    import tensorflow as tf
    import tensorflow.keras as K
    import matplotlib.pyplot as plt 
    
  2. 接下来,我们定义模型的超参数:

    np.random.seed(11)
    tf.random.set_seed(11)
    batch_size = 256
    max_epochs = 50
    learning_rate = 1e-3
    momentum = 8e-1
    hidden_dim = 128
    original_dim = 784 
    
  3. 我们读取 MNIST 数据集,对其进行归一化处理,并添加噪声:

    (x_train, _), (x_test, _) = K.datasets.mnist.load_data()
    x_train = x_train / 255.
    x_test = x_test / 255.
    x_train = x_train.astype(np.float32)
    x_test = x_test.astype(np.float32)
    x_train = np.reshape(x_train, (x_train.shape[0], 784))
    x_test = np.reshape(x_test, (x_test.shape[0], 784))
    # Generate corrupted MNIST images by adding noise with normal dist
    # centered at 0.5 and std=0.5
    noise = np.random.normal(loc=0.5, scale=0.5, size=x_train.shape)
    x_train_noisy = x_train + noise
    noise = np.random.normal(loc=0.5, scale=0.5, size=x_test.shape)
    x_test_noisy = x_test + noise 
    
  4. 我们使用与经典自编码器部分中定义的相同的编码器、解码器和自编码器类:

    # Encoder
    class Encoder(K.layers.Layer):
        def __init__(self, hidden_dim):
            super(Encoder, self).__init__()
            self.hidden_layer = K.layers.Dense(units=hidden_dim, activation=tf.nn.relu)
        def call(self, input_features):
            activation = self.hidden_layer(input_features)
            return activation
    # Decoder
    class Decoder(K.layers.Layer):
        def __init__(self, hidden_dim, original_dim):
            super(Decoder, self).__init__()
            self.output_layer = K.layers.Dense(units=original_dim, activation=tf.nn.relu)
        def call(self, encoded):
            activation = self.output_layer(encoded)
            return activation
    class Autoencoder(K.Model):
        def __init__(self, hidden_dim, original_dim):
            super(Autoencoder, self).__init__()
            self.loss = []
            self.encoder = Encoder(hidden_dim=hidden_dim)
            self.decoder = Decoder(hidden_dim=hidden_dim, original_dim=original_dim)
        def call(self, input_features):
            encoded = self.encoder(input_features)
            reconstructed = self.decoder(encoded)
            return reconstructed 
    
  5. 接下来,我们创建模型并定义损失函数和优化器。请注意,这次我们使用的是更简便的 Keras 内建 compile()fit() 方法,而不是编写自定义训练循环:

    model = Autoencoder(hidden_dim=hidden_dim, original_dim=original_dim)
    model.compile(loss='mse', optimizer='adam')
    loss = model.fit(x_train_noisy,
                x_train,
                validation_data=(x_test_noisy, x_test),
                epochs=max_epochs,
                batch_size=batch_size) 
    
  6. 现在让我们绘制训练损失图:

    plt.plot(range(max_epochs), loss.history['loss'])
    plt.xlabel('Epochs')
    plt.ylabel('Loss')
    plt.show() 
    

图 8.7 显示了各个时代的损失:

Chart, histogram  Description automatically generated

图 8.7:去噪自编码器的损失图

最后,让我们看看我们的模型实际操作:

number = 10  # how many digits we will display
plt.figure(figsize=(20, 4))
for index in range(number):
    # display original
    ax = plt.subplot(2, number, index + 1)
    plt.imshow(x_test_noisy[index].reshape(28, 28), cmap='gray')
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    # display reconstruction
    ax = plt.subplot(2, number, index + 1 + number)
    plt.imshow(model(x_test_noisy)[index].numpy().reshape(28, 28), cmap='gray')
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show() 

顶排显示的是输入的噪声图像,底排显示的是我们训练后的去噪自编码器生成的清晰图像:

A picture containing text  Description automatically generated

图 8.8:噪声输入图像和对应的去噪重建图像

从噪声图像中重建图像的效果令人印象深刻,相信你会同意。如果你想尝试一下,你可以在笔记本DenoisingAutoencoder.ipynb中访问代码。

堆叠自编码器

到目前为止,我们只限制自己使用仅有一个隐藏层的自编码器。我们可以通过堆叠多个编码器和解码器层来构建深度自编码器;这种自编码器称为堆叠自编码器。一个编码器提取的特征会作为输入传递给下一个编码器。堆叠自编码器可以作为一个整体网络进行训练,目标是最小化重建误差。或者,每个单独的编码器/解码器网络可以先使用你之前学过的无监督方法进行预训练,然后对整个网络进行微调。当深度自编码器网络是卷积网络时,我们称之为卷积自编码器。接下来,我们将在 TensorFlow 中实现一个卷积自编码器。

用于去除图像噪声的卷积自编码器

在上一部分,我们从噪声输入图像中重建了手写数字。我们使用了一个全连接网络作为编码器和解码器。然而,我们知道,对于图像来说,卷积网络能够提供更好的结果,所以在本节中,我们将使用卷积网络作为编码器和解码器。为了获得更好的结果,我们将在编码器和解码器网络中使用多个卷积层;也就是说,我们将堆叠卷积层(以及最大池化或上采样层)。我们还将训练整个自编码器作为一个整体:

  1. 我们从tensorflow.keras.layers导入所有必需的模块和特定层:

    import numpy as np
    import tensorflow as tf
    import tensorflow.keras as K
    import matplotlib.pyplot as plt
    from tensorflow.keras.layers import Dense, Conv2D, MaxPooling2D, UpSampling2D 
    
  2. 我们指定了超参数。如果你仔细观察,你会发现这个列表与早期的自编码器实现略有不同;这次我们关注的是卷积层的过滤器,而不是学*率和动量:

    np.random.seed(11)
    tf.random.set_seed(11)
    batch_size = 128
    max_epochs = 50
    filters = [32,32,16] 
    
  3. 在下一步中,我们读取数据并进行预处理。同样,你可能会注意到与之前的代码相比有一些微小的变化,尤其是在添加噪声和将值限制在[0-1]之间的方式上。我们这样做是因为在这种情况下,我们将使用二元交叉熵损失,而不是均方误差损失,并且解码器的最终输出将通过 sigmoid 激活,将其限制在[0-1]之间:

    (x_train, _), (x_test, _) = K.datasets.mnist.load_data()
    x_train = x_train / 255.
    x_test = x_test / 255.
    x_train = np.reshape(x_train, (len(x_train),28, 28, 1))
    x_test = np.reshape(x_test, (len(x_test), 28, 28, 1))
    noise = 0.5
    x_train_noisy = x_train + noise * np.random.normal(loc=0.0, scale=1.0, size=x_train.shape)
    x_test_noisy = x_test + noise * np.random.normal(loc=0.0, scale=1.0, size=x_test.shape)
    x_train_noisy = np.clip(x_train_noisy, 0, 1)
    x_test_noisy = np.clip(x_test_noisy, 0, 1)
    x_train_noisy = x_train_noisy.astype('float32')
    x_test_noisy = x_test_noisy.astype('float32')
    #print(x_test_noisy[1].dtype) 
    
  4. 现在我们来定义编码器。编码器由三层卷积层组成,每一层后跟一个最大池化层。由于我们使用的是 MNIST 数据集,输入图像的形状为 28 × 28(单通道),而输出图像的大小为 4 × 4(由于最后一层卷积层有 16 个滤波器,图像有 16 个通道):

    class Encoder(K.layers.Layer):
        def __init__(self, filters):
            super(Encoder, self).__init__()
            self.conv1 = Conv2D(filters=filters[0], kernel_size=3, strides=1, activation='relu', padding='same')
            self.conv2 = Conv2D(filters=filters[1], kernel_size=3, strides=1, activation='relu', padding='same')
            self.conv3 = Conv2D(filters=filters[2], kernel_size=3, strides=1, activation='relu', padding='same')
            self.pool = MaxPooling2D((2, 2), padding='same')
    
        def call(self, input_features):
            x = self.conv1(input_features)
            x = self.pool(x)
            x = self.conv2(x)
            x = self.pool(x)
            x = self.conv3(x)
            x = self.pool(x)
            return x 
    
  5. 接下来是解码器。它在设计上与编码器完全相反,且我们不使用最大池化,而是使用上采样来恢复尺寸。注意那些被注释掉的print语句;你可以通过它们来理解每一步之后形状是如何变化的。(或者,你也可以使用model.summary函数来获取完整的模型概述。)另外需要注意的是,编码器和解码器依然是基于 TensorFlow Keras 的Layers类,但现在它们内部有多个层。现在你知道如何构建一个复杂的自定义层了:

    class Decoder(K.layers.Layer):
        def __init__(self, filters):
            super(Decoder, self).__init__()
            self.conv1 = Conv2D(filters=filters[2], kernel_size=3, strides=1, activation='relu', padding='same')
            self.conv2 = Conv2D(filters=filters[1], kernel_size=3, strides=1, activation='relu', padding='same')
            self.conv3 = Conv2D(filters=filters[0], kernel_size=3, strides=1, activation='relu', padding='valid')
            self.conv4 = Conv2D(1, 3, 1, activation='sigmoid', padding='same')
            self.upsample = UpSampling2D((2, 2))
        def call(self, encoded):
            x = self.conv1(encoded)
            #print("dx1", x.shape)
            x = self.upsample(x)
            #print("dx2", x.shape)
            x = self.conv2(x)
            x = self.upsample(x)
            x = self.conv3(x)
            x = self.upsample(x)
            return self.conv4(x) 
    
  6. 我们将编码器和解码器结合起来,构建一个自动编码器模型。这与之前完全相同:

    class Autoencoder(K.Model):
        def __init__(self, filters):
            super(Autoencoder, self).__init__()
            self.encoder = Encoder(filters)
            self.decoder = Decoder(filters)
        def call(self, input_features):
            #print(input_features.shape)
            encoded = self.encoder(input_features)
            #print(encoded.shape)
            reconstructed = self.decoder(encoded)
            #print(reconstructed.shape)
            return reconstructed 
    
  7. 现在我们实例化我们的模型,然后在compile()方法中指定二元交叉熵作为损失函数,Adam 作为优化器。接着,使用训练数据集来训练模型:

    model = Autoencoder(filters)
    model.compile(loss='binary_crossentropy', optimizer='adam')
    loss = model.fit(x_train_noisy,
                x_train,
                validation_data=(x_test_noisy, x_test),
                epochs=max_epochs,
                batch_size=batch_size) 
    
  8. 绘制损失曲线:

    plt.plot(range(max_epochs), loss.history['loss'])
    plt.xlabel('Epochs')
    plt.ylabel('Loss')
    plt.show() 
    

    你可以看到训练过程中的损失曲线;在 50 个 epoch 后,损失降到了 0.0988:

    图表  描述自动生成

    图 8.9:卷积自动编码器的损失图

  9. 最后,你可以看到从噪声输入图像重建出的精彩图像:

    number = 10  # how many digits we will display
    plt.figure(figsize=(20, 4))
    for index in range(number):
        # display original
        ax = plt.subplot(2, number, index + 1)
        plt.imshow(x_test_noisy[index].reshape(28, 28), cmap='gray')
        ax.get_xaxis().set_visible(False)
        ax.get_yaxis().set_visible(False)
        # display reconstruction
        ax = plt.subplot(2, number, index + 1 + number)
        plt.imshow(tf.reshape(model(x_test_noisy)[index], (28, 28)), cmap='gray')
        ax.get_xaxis().set_visible(False)
        ax.get_yaxis().set_visible(False)
    plt.show() 
    

包含文本的图片  描述自动生成

图 8.10:输入的噪声图像和重建的去噪图像

你可以看到,与本章前面介绍的自动编码器相比,这些图像要清晰得多。魔法就在于卷积层的堆叠。该部分的代码可以在 Jupyter 笔记本ConvolutionAutoencoder.ipynb中找到。

一个 TensorFlow Keras 自动编码器示例 ‒ 句子向量

在这个示例中,我们将构建并训练一个基于 LSTM 的自动编码器,以为 Reuters-21578 语料库中的文档生成句子向量(archive.ics.uci.edu/ml/datasets/reuters-21578+text+categorization+collection)。我们已经在第四章词嵌入中看到过如何使用词嵌入表示一个单词,从而创建代表该单词在其出现的其他单词上下文中意义的向量。在这里,我们将看到如何为句子构建类似的向量。句子是单词的序列,因此句子向量表示句子的含义。

构建句子向量的最简单方法是将所有单词向量相加,并除以单词的数量。然而,这种方法将句子视为一个单词袋,并没有考虑单词的顺序。因此,句子The dog bit the manThe man bit the dog在这种情况下会被视为相同。LSTM 被设计为处理序列输入,并考虑单词顺序,因此可以提供更好且更自然的句子表示。

首先,我们导入必要的库:

from sklearn.model_selection import train_test_split
from tensorflow.keras.callbacks import ModelCheckpoint
from tensorflow.keras.layers import Input
from tensorflow.keras.layers import RepeatVector
from tensorflow.keras.layers import LSTM
from tensorflow.keras.layers import Bidirectional
from tensorflow.keras.models import Model
from tensorflow.keras.preprocessing import sequence
from scipy.stats import describe
import collections
import matplotlib.pyplot as plt
import nltk
import numpy as np
import os
from time import gmtime, strftime
from tensorflow.keras.callbacks import TensorBoard
import re
# Needed to run only once
nltk.download('punkt')
nltk.download('reuters')
from nltk.corpus import reuters 

如果你正在使用 Google 的 Colab 运行代码,还需要通过向代码中添加以下内容来解压 Reuters 语料库:

%%capture
!unzip /root/nltk_data/corpora/reuters.zip -d /root/nltk_data/corpora 

接下来,我们将使用 GloVe 嵌入,因此我们也需要下载它们:

!wget http://nlp.stanford.edu/data/glove.6B.zip
!unzip glove*.zip 

现在所有工具都已准备好,我们将首先将每个文本块(文档)转换为一个句子列表,每行一个句子。同时,每个句子的单词在添加时都会进行规范化处理。规范化过程包括去除所有数字并将其替换为数字9,然后将单词转换为小写。同时,我们也在同一段代码中计算单词的频率。结果是单词频率表word_freqs

def is_number(n):
    temp = re.sub("[.,-/]", "",n)
    return temp.isdigit()
# parsing sentences and building vocabulary
word_freqs = collections.Counter()
documents = reuters.fileids()
#ftext = open("text.tsv", "r")
sents = []
sent_lens = []
num_read = 0
for i in range(len(documents)):
    # periodic heartbeat report
    if num_read % 100 == 0:
        print("building features from {:d} docs".format(num_read))
    # skip docs without specified topic
    title_body = reuters.raw(documents[i]).lower()
    if len(title_body) == 0:
        continue
    num_read += 1
    # convert to list of word indexes
    title_body = re.sub("\n", "", title_body)
    for sent in nltk.sent_tokenize(title_body):
        for word in nltk.word_tokenize(sent):
            if is_number(word):
                word = "9"
            word = word.lower()
            word_freqs[word] += 1
        sents.append(sent)
        sent_lens.append(len(sent)) 

让我们使用之前生成的数组来获取一些关于语料库的信息,这将帮助我们确定 LSTM 网络的常数值:

print("Total number of sentences are: {:d} ".format(len(sents)))
print ("Sentence distribution min {:d}, max {:d} , mean {:3f}, median {:3f}".format(np.min(sent_lens), np.max(sent_lens), np.mean(sent_lens), np.median(sent_lens)))
print("Vocab size (full) {:d}".format(len(word_freqs))) 

这为我们提供了关于语料库的以下信息:

Total number of sentences are: 50470 
Sentence distribution min 1, max 3688 , mean 167.072657, median 155.000000
Vocab size (full) 33748 

基于这些信息,我们为 LSTM 模型设置了以下常数。我们将VOCAB_SIZE设为5000;也就是说,我们的词汇表覆盖了最常用的 5,000 个单词,涵盖了语料库中超过 93%的单词。其余的单词被视为词汇表外OOV)并替换为UNK标记。在预测时,任何模型未见过的单词也将被分配UNK标记。SEQUENCE_LEN大约设为训练集中文本句子的中位数长度的一半。长度小于SEQUENCE_LEN的句子将通过特殊的PAD字符进行填充,长度超过的句子将被截断以符合限制:

VOCAB_SIZE = 5000
SEQUENCE_LEN = 50 

由于我们的 LSTM 输入是数值型的,因此我们需要构建查找表,以便在单词和单词 ID 之间进行转换。由于我们将词汇表的大小限制为 5,000,并且需要添加两个伪单词PADUNK,因此我们的查找表包含了最常出现的 4,998 个单词以及PADUNK

word2id = {}
word2id["PAD"] = 0
word2id["UNK"] = 1
for v, (k, _) in enumerate(word_freqs.most_common(VOCAB_SIZE - 2)):
    word2id[k] = v + 2
id2word = {v:k for k, v in word2id.items()} 

我们网络的输入是一个单词序列,其中每个单词由一个向量表示。简单来说,我们可以使用每个单词的独热编码(one-hot encoding),但那样会使输入数据非常庞大。因此,我们使用每个单词的 50 维 GloVe 嵌入来编码每个单词。

嵌入向量被生成到一个形状为(VOCAB_SIZEEMBED_SIZE)的矩阵中,其中每一行表示我们词汇表中某个单词的 GloVe 嵌入向量。PADUNK行(分别为01)分别用零和随机均匀值填充:

EMBED_SIZE = 50
def lookup_word2id(word):
    try:
        return word2id[word]
    except KeyError:
        return word2id["UNK"]
def load_glove_vectors(glove_file, word2id, embed_size):
    embedding = np.zeros((len(word2id), embed_size))
    fglove = open(glove_file, "rb")
    for line in fglove:
        cols = line.strip().split()
        word = cols[0].decode('utf-8')
        if embed_size == 0:
            embed_size = len(cols) - 1
        if word in word2id:
            vec = np.array([float(v) for v in cols[1:]])
        embedding[lookup_word2id(word)] = vec
    embedding[word2id["PAD"]] = np.zeros((embed_size))
    embedding[word2id["UNK"]] = np.random.uniform(-1, 1, embed_size)
    return embedding 

接下来,我们使用这些函数生成嵌入向量:

sent_wids = [[lookup_word2id(w) for w in s.split()] for s in sents]
sent_wids = sequence.pad_sequences(sent_wids, SEQUENCE_LEN)
# load glove vectors into weight matrix
embeddings = load_glove_vectors("glove.6B.{:d}d.txt".format(EMBED_SIZE), word2id, EMBED_SIZE) 

我们的自动编码器模型接收一个 GloVe 词向量序列,并学*生成一个与输入序列相似的输出序列。编码器 LSTM 将序列压缩成一个固定大小的上下文向量,解码器 LSTM 则使用这个向量重建原始序列。

网络的示意图如下所示:

手机屏幕截图  描述自动生成

图 8.11:LSTM 网络的可视化

由于输入数据量相当大,我们将使用生成器来生成每一批次的输入。我们的生成器生成形状为(BATCH_SIZESEQUENCE_LENEMBED_SIZE)的张量批次。这里,BATCH_SIZE64,并且由于我们使用的是 50 维的 GloVe 向量,所以 EMBED_SIZE50。我们在每个训练周期开始时对句子进行洗牌,并返回 64 个句子的批次。每个句子都被表示为一个由 GloVe 词向量组成的向量。如果词汇表中的某个单词没有对应的 GloVe 嵌入向量,它将用零向量表示。我们构建了两个生成器实例,一个用于训练数据,一个用于测试数据,分别包含原始数据集的 70%和 30%:

BATCH_SIZE = 64
def sentence_generator(X, embeddings, batch_size):
    while True:
        # loop once per epoch
        num_recs = X.shape[0]
        indices = np.random.permutation(np.arange(num_recs))
        num_batches = num_recs // batch_size
        for bid in range(num_batches):
            sids = indices[bid * batch_size : (bid + 1) * batch_size]
            Xbatch = embeddings[X[sids, :]]
            yield Xbatch, Xbatch
train_size = 0.7
Xtrain, Xtest = train_test_split(sent_wids, train_size=train_size)
train_gen = sentence_generator(Xtrain, embeddings, BATCH_SIZE)
test_gen = sentence_generator(Xtest, embeddings, BATCH_SIZE) 

现在我们准备定义自动编码器。如图所示,它由一个编码器 LSTM 和一个解码器 LSTM 组成。编码器 LSTM 读取形状为(BATCH_SIZESEQUENCE_LENEMBED_SIZE)的张量,表示一批句子。每个句子表示为一个固定长度的填充序列,大小为 SEQUENCE_LEN。每个单词表示为一个 300 维的 GloVe 向量。编码器 LSTM 的输出维度是一个超参数,LATENT_SIZE,它是后续从训练好的自动编码器中获得的句子向量的大小。维度为 LATENT_SIZE 的向量空间表示编码句子意义的潜在空间。LSTM 的输出是每个句子的一个大小为(LATENT_SIZE)的向量,因此,对于整个批次,输出张量的形状为(BATCH_SIZELATENT_SIZE)。接下来,将这个输出传递给 RepeatVector 层,该层会将其复制到整个序列中;也就是说,该层的输出张量形状为(BATCH_SIZESEQUENCE_LENLATENT_SIZE)。这个张量接着输入到解码器 LSTM 中,解码器 LSTM 的输出维度是 EMBED_SIZE,所以输出张量的形状是(BATCH_SIZESEQUENCE_LENEMBED_SIZE),也就是与输入张量的形状相同。

我们使用 Adam 优化器和 MSE 损失函数来编译此模型。之所以使用 MSE,是因为我们希望重建一个意义相似的句子,即在维度为LATENT_SIZE的嵌入空间中,尽可能接*原始句子:

LATENT_SIZE = 512
EMBED_SIZE = 50
BATCH_SIZE = 64
NUM_EPOCHS = 20
inputs = Input(shape=(SEQUENCE_LEN, EMBED_SIZE), name="input")
encoded = Bidirectional(LSTM(LATENT_SIZE), merge_mode="sum", name="encoder_lstm")(inputs)
decoded = RepeatVector(SEQUENCE_LEN, name="repeater")(encoded)
decoded = Bidirectional(LSTM(EMBED_SIZE, return_sequences=True), merge_mode="sum", name="decoder_lstm")(decoded)
autoencoder = Model(inputs, decoded) 

我们将损失函数定义为均方误差,并选择 Adam 优化器:

autoencoder.compile(optimizer="adam", loss="mse") 

我们使用以下代码训练自编码器 20 个周期。选择 20 个周期是因为 MSE 损失在此时间内收敛:

num_train_steps = len(Xtrain) // BATCH_SIZE
num_test_steps = len(Xtest) // BATCH_SIZE
steps_per_epoch=num_train_steps,
epochs=NUM_EPOCHS,
validation_data=test_gen,
validation_steps=num_test_steps,
history = autoencoder.fit_generator(train_gen,
                                steps_per_epoch=num_train_steps,
                                epochs=NUM_EPOCHS,
                                validation_data=test_gen,
                                validation_steps=num_test_steps) 

训练结果如下所示。下图显示了训练和验证数据的损失图;我们可以看到,随着模型的学*,损失按预期减少:

图表,折线图  描述自动生成

图 8.12:LSTM 自编码器的损失图

由于我们输入的是一个嵌入矩阵,输出也将是一个词嵌入矩阵。由于嵌入空间是连续的,而我们的词汇表是离散的,并非每个输出嵌入都会对应一个词。我们能做的最好的事情是找到一个最接*输出嵌入的词,以便重建原始文本。这有点繁琐,所以我们将以不同的方式评估我们的自编码器。

由于自编码器的目标是产生良好的潜在表示,我们比较了使用原始输入与自编码器输出所产生的潜在向量。

首先,我们将编码器部分提取为独立的网络:

encoder = Model(autoencoder.input, autoencoder.get_layer("encoder_lstm").output) 

然后,我们在测试集上运行自编码器以返回预测的嵌入。我们将输入嵌入和预测嵌入通过编码器送入,以从每个向量生成句子向量,并使用余弦相似度比较这两个向量。接*“1”的余弦相似度表示高度相似,接*“0”的则表示相似度低。

以下代码在 500 个随机测试句子的子集上运行,并生成一些余弦相似度样本值,这些值表示源嵌入生成的句子向量与自编码器生成的目标嵌入之间的相似度:

def compute_cosine_similarity(x, y):
    return np.dot(x, y) / (np.linalg.norm(x, 2) * np.linalg.norm(y, 2))
k = 500
cosims = np.zeros((k))
i= 0
for bid in range(num_test_steps):
    xtest, ytest = next(test_gen)
    ytest_ = autoencoder.predict(xtest)
    Xvec = encoder.predict(xtest)
    Yvec = encoder.predict(ytest_)
    for rid in range(Xvec.shape[0]):
        if i >= k:
            break
        cosims[i] = compute_cosine_similarity(Xvec[rid], Yvec[rid])
        if i <= 10:
            print(cosims[i])
        i += 1
    if i >= k:
        break 

以下显示了前 10 个余弦相似度的值。正如我们所见,这些向量似乎非常相似:

0.9765363335609436
0.9862152338027954
0.9831727743148804
0.977733314037323
0.9851642847061157
0.9849132895469666
0.9831638932228088
0.9843543767929077
0.9825796484947205
0.9877195954322815
0.9820773601531982 

图 8.13 显示了来自前 500 个句子的句子向量的余弦相似度分布直方图。

如前所述,这证实了从自编码器的输入和输出生成的句子向量非常相似,显示出生成的句子向量是对句子的良好表示:

图表,直方图  描述自动生成

图 8.13:余弦相似度分布

到目前为止,我们一直专注于能够重建数据的自编码器;在接下来的部分,我们将介绍自编码器的一种稍微不同的变体——变分自编码器,它用于生成数据。

变分自编码器

像深度置信网络(DBNs,第七章无监督学*)和生成对抗网络(GANs,参见第九章生成模型,了解更多细节)一样,变分自编码器也是生成模型。变分自编码器VAEs)是最佳神经网络与贝叶斯推断的结合体。它们是最有趣的神经网络之一,已成为无监督学*的最流行方法之一。它们是自编码器的一个变种。除了常规的编码器和解码器网络外,VAE 还具有额外的随机层。随机层在编码器网络之后,使用高斯分布进行数据采样,而在解码器网络之后,使用伯努利分布进行数据采样。像 GANs 一样,VAE 也可以用来生成基于其训练分布的图像和图形。

变分自编码器(VAEs)允许在潜在空间中设置复杂的先验,从而学*强大的潜在表示。图 8.14 描述了一个 VAE:

图示说明自动生成

图 8.14:变分自编码器的架构

编码器网络!逼*真实但不可求解的后验分布!,其中 x 是 VAE 的输入,z 是潜在表示。解码器网络!d 维潜在变量(也叫潜在空间)作为输入,并生成符合 P(x) 分布的新图像。正如从前面的图示中可以看到的,潜在表示 z 是从!中采样的,而解码器网络的输出从! 中采样!。这里 N 表示具有均值! 和方差!的正态分布。

现在我们已经了解了 VAE 的基本架构,问题就来了,如何训练它们,因为训练数据的最大似然和后验密度是不可求解的。网络通过最大化数据对数似然的下界来进行训练。因此,损失项由两个部分组成:生成损失,通过解码器网络的采样获得,以及 Kullback-Leibler 散度项,也叫潜在损失。

生成损失确保解码器生成的图像与用于训练网络的图像相似,潜在损失确保后验分布!接*先验分布!。由于编码器使用高斯分布进行采样,潜在损失衡量潜在变量与该分布的匹配程度。

一旦 VAE 训练完成,我们可以仅使用解码器网络来生成新图像。让我们尝试编写一个 VAE。这次我们使用的是 Fashion-MNIST 数据集;该数据集包含了 Zalando 的(github.com/zalandoresearch/fashion-mnist)商品图像。训练和测试集的划分与 MNIST 完全相同,即 60,000 张训练图像和 10,000 张测试图像。每张图像的大小也是 28 × 28,因此我们可以轻松地将运行在 MNIST 数据集上的代码替换为 Fashion-MNIST 数据集的代码。

本节中的代码改编自github.com/dragen1860/TensorFlow-2.x-Tutorials。作为第一步,我们像往常一样导入所有必要的库:

import tensorflow as tf
import numpy as np
from matplotlib import pyplot as plt 

让我们固定随机数种子,以确保结果可复现。我们还可以添加一个assert语句,以确保我们的代码在 TensorFlow 2.0 或更高版本上运行:

np.random.seed(333)
tf.random.set_seed(333)
assert tf.__version__.startswith('2.'), "TensorFlow Version Below 2.0" 

在继续创建 VAE 之前,让我们先稍微了解一下 Fashion-MNIST 数据集。这个数据集可以在 TensorFlow Keras API 中找到:

(x_train, y_train), (x_test, y_test) = tf.keras.datasets.fashion_mnist.load_data()
x_train, x_test = x_train.astype(np.float32)/255., x_test.astype(np.float32)/255.
print(x_train.shape, y_train.shape)
print(x_test.shape, y_test.shape) 
--------------------------------------------------
(60000, 28, 28) (60000,)
(10000, 28, 28) (10000,) 

我们看到一些示例图像:

number = 10  # how many digits we will display
plt.figure(figsize=(20, 4))
for index in range(number):
    # display original
    ax = plt.subplot(2, number, index + 1)
    plt.imshow(x_train[index], cmap='gray')
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show() 

图 8.15:Fashion-MNIST 数据集中的示例图像

在开始之前,让我们声明一些超参数,如学*率、隐藏层和潜在空间的维度、批次大小、轮次等:

image_size = x_train.shape[1]*x_train.shape[2]
hidden_dim = 512
latent_dim = 10
num_epochs = 80
batch_size = 100
learning_rate = 0.001 

我们使用 TensorFlow Keras Model API 来构建 VAE 模型。__init__()函数定义了我们将使用的所有层:

class VAE(tf.keras.Model):
    def __init__(self,dim,**kwargs):
        h_dim = dim[0]
        z_dim = dim[1]
        super(VAE, self).__init__(**kwargs)
        self.fc1 = tf.keras.layers.Dense(h_dim)
        self.fc2 = tf.keras.layers.Dense(z_dim)
        self.fc3 = tf.keras.layers.Dense(z_dim)
        self.fc4 = tf.keras.layers.Dense(h_dim)
        self.fc5 = tf.keras.layers.Dense(image_size) 

我们定义了获取编码器输出、解码器输出和重新参数化的函数。编码器和解码器的实现都很直接;然而,我们需要深入探讨一下reparametrize函数。正如你所知道的,VAE 从随机节点z中采样,这个z由真实后验的来*似。现在,为了获得参数,我们需要使用反向传播。然而,反向传播无法作用于随机节点。通过重新参数化,我们可以使用一个新参数eps,它允许我们以一种能够通过确定性随机节点进行反向传播的方式重新参数化z (arxiv.org/pdf/1312.6114v10.pdf):

def encode(self, x):
    h = tf.nn.relu(self.fc1(x))
    return self.fc2(h), self.fc3(h)
def reparameterize(self, mu, log_var):
    std = tf.exp(log_var * 0.5)
    eps = tf.random.normal(std.shape)
    return mu + eps * std
def decode_logits(self, z):
    h = tf.nn.relu(self.fc4(z))
    return self.fc5(h)
def decode(self, z):
    return tf.nn.sigmoid(self.decode_logits(z)) 

最后,我们定义了call()函数,它将控制信号如何在 VAE 的不同层之间传播:

def call(self, inputs, training=None, mask=None):
    mu, log_var = self.encode(inputs)
    z = self.reparameterize(mu, log_var)
    x_reconstructed_logits = self.decode_logits(z)
    return x_reconstructed_logits, mu, log_var 

现在,我们创建 VAE 模型并声明其优化器。你可以看到模型的摘要:

model = VAE([hidden_dim, latent_dim])
model.build(input_shape=(4, image_size))
model.summary()
optimizer = tf.keras.optimizers.Adam(learning_rate) 
Model: "vae"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 dense (Dense)               multiple                  401920    

 dense_1 (Dense)             multiple                  5130      

 dense_2 (Dense)             multiple                  5130      

 dense_3 (Dense)             multiple                  5632      

 dense_4 (Dense)             multiple                  402192    

=================================================================
Total params: 820,004
Trainable params: 820,004
Non-trainable params: 0
_________________________________________________________________ 

现在,我们训练模型。我们定义了我们的损失函数,它是重构损失和 KL 散度损失的总和:

dataset = tf.data.Dataset.from_tensor_slices(x_train)
dataset = dataset.shuffle(batch_size * 5).batch(batch_size)
num_batches = x_train.shape[0] // batch_size
for epoch in range(num_epochs):
    for step, x in enumerate(dataset):
        x = tf.reshape(x, [-1, image_size])
        with tf.GradientTape() as tape:
            # Forward pass
            x_reconstruction_logits, mu, log_var = model(x)
            # Compute reconstruction loss and kl divergence
            # Scaled by 'image_size' for each individual pixel.
            reconstruction_loss = tf.nn.sigmoid_cross_entropy_with_logits(labels=x, logits=x_reconstruction_logits)
            reconstruction_loss = tf.reduce_sum(reconstruction_loss) / batch_size

            kl_div = - 0.5 * tf.reduce_sum(1\. + log_var - tf.square(mu) - tf.exp(log_var), axis=-1)
            kl_div = tf.reduce_mean(kl_div)
            # Backprop and optimize
            loss = tf.reduce_mean(reconstruction_loss) + kl_div
        gradients = tape.gradient(loss, model.trainable_variables)
        for g in gradients:
            tf.clip_by_norm(g, 15)
        optimizer.apply_gradients(zip(gradients, model.trainable_variables))
        if (step + 1) % 50 == 0:
            print("Epoch[{}/{}], Step [{}/{}], Reconst Loss: {:.4f}, KL Div: {:.4f}"
            .format(epoch + 1, num_epochs, step + 1, num_batches, float(reconstruction_loss), float(kl_div))) 

一旦模型训练完成,它应该能够生成与原始 Fashion-MNIST 图像相似的图像。为此,我们只需要使用解码器网络,并向其传递一个随机生成的z输入:

z = tf.random.normal((batch_size, latent_dim))
out = model.decode(z)  # decode with sigmoid
out = tf.reshape(out, [-1, 28, 28]).numpy() * 255
out = out.astype(np.uint8) 

图 8.16 显示了 80 轮训练后的结果:

图 8.16:80 轮训练后的结果

生成的图像与输入空间相似。生成的图像与原始 Fashion-MNIST 图像相似,符合预期。

总结

在这一章中,我们详细探讨了新一代深度学*模型:自编码器。我们从基础的自编码器开始,随后讨论了其变种:稀疏自编码器、去噪自编码器、堆叠自编码器和卷积自编码器。我们使用自编码器重建了图像,并演示了它们如何用来清除图像中的噪声。最后,本章展示了自编码器如何用于生成句子向量和图像。自编码器通过无监督学*进行学*。

在下一章,我们将深入探讨生成对抗网络,这是一种通过无监督学*范式学*的有趣深度学*模型。

参考文献

  1. Rumelhart, D. E., Hinton, G. E., 和 Williams, R. J. (1985). 通过误差传播学*内部表示。No. ICS-8506。加利福尼亚大学圣地亚哥分校,认知科学研究所:www.cs.toronto.edu/~fritz/absps/pdp8.pdf

  2. Hinton, G. E. 和 Salakhutdinov, R. R. (2016). 利用神经网络减少数据的维度。Science 313.5786: 504–507:www.cs.toronto.edu/~hinton/science.pdf

  3. Masci, J. 等 (2011). 堆叠卷积自编码器用于层次化特征提取。人工神经网络与机器学*–ICANN 2011: 52–59:www.semanticscholar.org/paper/Reducing-the-dimensionality-of-data-with-neural-Hinton-Salakhutdinov/46eb79e5eec8a4e2b2f5652b66441e8a4c921c3e

  4. Japkowicz, N., Myers, C., 和 Gluck, M. (1995). 一种用于分类的新颖性检测方法。IJCAI。卷:www.ijcai.org/Proceedings/95-1/Papers/068.pdf

  5. Sedhain, S. (2015). AutoRec: 自编码器遇上协同过滤。第 24 届国际万维网大会论文集,ACM。

  6. Cheng, H. (2016). 宽深学*推荐系统。第一届推荐系统深度学*研讨会论文集,ACM。

  7. Runfeldt, M. 使用深度学*从人脸中去除眼镜

  8. Miotto, R. (2016). 深度病人:一种无监督表示方法,用于预测电子健康记录中病人的未来。Scientific Reports。

  9. Kiros, R. (2015). 跳跃思想向量,神经信息处理系统进展。

  10. Kullback-Leibler 散度:hanj.cs.illinois.edu/cs412/bk3/KL-divergence.pdf

  11. 去噪自编码器:cs.stanford.edu/people/karpathy/convnetjs/demo/autoencoder.xhtml

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,结识志同道合的人,并与超过 2000 名成员一起学*,网址:packt.link/keras

第九章:生成模型

生成模型是一种机器学*算法,用于生成数据。它们用于生成与训练模型时使用的数据相似的新数据。这些模型可以用于生成新数据以供测试,或填补缺失的数据。生成模型被广泛应用于许多领域,例如密度估计、图像合成和自然语言处理。在第八章《自编码器》中讨论的 VAE 就是一种生成模型;在本章中,我们将讨论各种生成模型,包括生成对抗网络GANs)及其变种、基于流的模型和扩散模型。

GANs 被 Yann LeCun(深度学*的奠基人之一)定义为过去 10 年中机器学*领域最有趣的想法www.quora.com/What-are-some-recent-and-potentially-upcoming-breakthroughs-in-deep-learning)。GANs 能够学*如何重现看似真实的合成数据。例如,计算机可以学*如何绘画并创建逼真的图像。这个想法最初由 Ian Goodfellow 提出(有关更多信息,请参见2016 年 NIPS 教程:生成对抗网络,I. Goodfellow,2016);他曾与蒙特利尔大学、Google Brain 和 OpenAI 合作,现在在 Apple Inc.担任机器学*总监。

在本章中,我们将介绍不同类型的 GANs;本章将向你介绍基于流的模型和扩散模型,此外,你还将看到它们在 TensorFlow 中的一些实现。总体来说,我们将涵盖以下主题:

  • 什么是 GAN?

  • 深度卷积 GANs

  • InfoGAN

  • SRGAN

  • CycleGAN

  • GAN 的应用

  • 基于流的生成模型

  • 用于数据生成的扩散模型

本章的所有代码文件可以在packt.link/dltfchp9找到

让我们开始吧!

什么是 GAN?

GANs 能够学*高维复杂的数据分布,这使它们在*年来受到研究人员的广泛关注。从 2016 年 Ian Goodfellow 首次提出 GANs,到 2022 年 3 月,关于 GANs 的研究论文已超过 10 万篇,仅在短短 6 年的时间里!

GANs 的应用包括创建图像、视频、音乐,甚至自然语言。它们已被应用于图像到图像的转换、图像超分辨率、药物发现,甚至视频中的下一帧预测任务。在合成数据生成任务中,GANs 特别成功——无论是在训练深度学*模型,还是评估对抗性攻击中。

GAN 的关键思想可以通过将其类比为“艺术伪造”来轻松理解,艺术伪造是指创造被错误归于其他通常更著名艺术家的艺术作品的过程。GAN 同时训练两个神经网络。生成器G(Z)负责进行伪造,判别器D(Y)则负责根据其对真实艺术品和复制品的观察判断复制作品的真实性。D(Y)接收输入Y(例如一张图像),并通过表达一个投票来判断输入的真实性。一般来说,接* 1 的值表示“真实”,而接* 0 的值表示“伪造”。G(Z)接收来自随机噪声Z的输入,并通过训练自己来欺骗D,让其认为G(Z)生成的内容是真实的。

训练判别器D(Y)的目标是最大化D(Y)对于真实数据分布中的每一张图像,同时最小化D(Y)对于每一张非真实数据分布的图像。因此,GD进行对立的博弈,这也是对抗训练这一名称的由来。需要注意的是,我们以交替的方式训练GD,它们的每个目标都通过一个梯度下降优化的损失函数来表达。生成模型不断提高其伪造能力,而判别模型则不断提高其伪造识别能力。判别网络(通常是标准的卷积神经网络)尝试分类输入的图像是否真实。重要的新想法是通过判别器和生成器反向传播,从而调整生成器的参数,使得生成器能够学会如何更频繁地欺骗判别器。最终,生成器将学会生成与真实图像无法区分的图像:

一块黑色的标牌,白色文字  描述自动生成

图 9.1: GAN 的基本架构

当然,GAN 涉及在两个玩家之间的博弈中朝着平衡发展。让我们首先理解这里的“平衡”是什么意思。当我们开始时,希望其中一个玩家优于另一个,这样会推动另一个玩家进步,从而使生成器和判别器互相推动着彼此改进。

最终,我们达到了一个状态,在这个状态下,无论是哪一方,改进都不再显著。我们通过绘制损失函数来检查这一点,查看两个损失(梯度损失和判别器损失)何时达到平台期。我们不希望游戏向某一方过于倾斜;如果伪造者每次都能立即学会如何欺骗判别器,那么伪造者就再也没有什么可以学*的了。实际上,训练 GAN 是非常困难的,很多研究都在分析 GAN 的收敛性;可以查看这个网站:avg.is.tuebingen.mpg.de/projects/convergence-and-stability-of-gan-training 了解不同类型 GAN 的收敛性和稳定性的详细信息。在 GAN 的生成应用中,我们希望生成器的学*效果略好于判别器。

现在让我们深入探讨 GAN 是如何学*的。判别器和生成器轮流进行学*。学*过程可以分为两个步骤:

  1. 这里是判别器,D(x),进行学*。生成器,G(z),用于从随机噪声z(遵循某个先验分布P(z))中生成假的图像。生成器产生的假图像和训练数据集中的真实图像都被输入到判别器中,判别器进行监督学*,尝试将真假图像分开。如果P[数据] (x) 是训练数据集分布,那么判别器网络会最大化其目标,使得D(x)在输入数据为真实时接* 1,在输入数据为虚假时接* 0。

  2. 在下一步中,生成器网络进行学*。它的目标是欺骗判别器网络,使其认为生成的G(z)是真实的,即强迫D(G(z))接* 1。

这两个步骤会依次重复。一旦训练结束,判别器就不再能够区分真实和虚假的数据,生成器则变得非常擅长生成与训练数据非常相似的数据。判别器和生成器之间的稳定性是一个活跃的研究问题。

现在你已经对 GAN 有了基本了解,让我们看一个实际应用,其中使用 GAN 生成“手写”数字。

在 TensorFlow 中使用 GAN 生成 MNIST 数据

让我们构建一个简单的 GAN,能够生成手写数字。我们将使用 MNIST 手写数字来训练网络。我们需要导入 TensorFlow 模块;为了保持代码的简洁,我们将从 TensorFlow 框架中导出所有需要的类:

from tensorflow.keras.datasets import mnist
from tensorflow.keras.layers import Input, Dense, Reshape, Flatten, Dropout
from tensorflow.keras.layers import BatchNormalization, Activation, ZeroPadding2D
from tensorflow.keras.layers import LeakyReLU
from tensorflow.keras.layers import UpSampling2D, Conv2D
from tensorflow.keras.models import Sequential, Model
from tensorflow.keras.optimizers import Adam
from tensorflow.keras import initializers
import matplotlib.pyplot as plt
import numpy as np 

我们使用 TensorFlow Keras 数据集来访问 MNIST 数据。数据包含 60,000 张手写数字的训练图像,每张图像的大小为 28 × 28。数字的像素值范围为 0-255;我们将输入值标准化,使得每个像素的值在 [-1, 1] 范围内:

randomDim = 10
(X_train, _), (_,  _) = mnist.load_data()
X_train = (X_train.astype(np.float32) - 127.5)/127.5 

我们将使用一个简单的多层感知器MLP),并将其输入一个大小为 784 的平面向量图像,因此我们需要重塑训练数据:

X_train = X_train.reshape(60000, 784) 

现在我们需要构建生成器和鉴别器。生成器的目的是接收一个噪声输入,并生成一张与训练数据集相似的图像。噪声输入的大小由变量randomDim决定;你可以将其初始化为任何整数值。通常人们将其设置为 100。对于我们的实现,我们尝试了一个值为 10。这个输入会传入一个有256个神经元并使用 LeakyReLU 激活函数的全连接层。接下来,我们添加另一个有512个隐藏神经元的全连接层,紧接着是第三个有1024个神经元的隐藏层,最后是输出层,包含784个神经元。你可以改变隐藏层中神经元的数量,看看性能如何变化;然而,输出层神经元的数量必须与训练图像的像素数量匹配。对应的生成器如下:

generator = Sequential()
generator.add(Dense(256, input_dim=randomDim))
generator.add(LeakyReLU(0.2))
generator.add(Dense(512))
generator.add(LeakyReLU(0.2))
generator.add(Dense(1024))
generator.add(LeakyReLU(0.2))
generator.add(Dense(784, activation='tanh')) 

同样,我们构建了一个鉴别器。现在注意(图 9.1)鉴别器接收的图像,可能来自训练集,也可能是由生成器生成的图像,因此其输入大小为784。此外,在这里我们使用了 TensorFlow 的初始化器来初始化全连接层的权重,使用的是标准差为 0.02,均值为 0 的正态分布。如第一章:TensorFlow 神经网络基础中所述,TensorFlow 框架中提供了许多初始化器。鉴别器的输出是一个单一的比特,0表示假图像(由生成器生成),1表示图像来自训练数据集:

discriminator = Sequential()
discriminator.add(Dense(1024, input_dim=784, kernel_initializer=initializers.RandomNormal(stddev=0.02))
)
discriminator.add(LeakyReLU(0.2))
discriminator.add(Dropout(0.3))
discriminator.add(Dense(512))
discriminator.add(LeakyReLU(0.2))
discriminator.add(Dropout(0.3))
discriminator.add(Dense(256))
discriminator.add(LeakyReLU(0.2))
discriminator.add(Dropout(0.3))
discriminator.add(Dense(1, activation='sigmoid')) 

接下来,我们将生成器和鉴别器结合在一起,形成一个 GAN(生成对抗网络)。在 GAN 中,我们确保鉴别器的权重是固定的,通过将trainable参数设置为False来实现:

discriminator.trainable = False
ganInput = Input(shape=(randomDim,))
x = generator(ganInput)
ganOutput = discriminator(x)
gan = Model(inputs=ganInput, outputs=ganOutput) 

训练这两个网络的技巧是,我们首先单独训练鉴别器;我们对鉴别器使用二元交叉熵损失函数。随后,我们冻结鉴别器的权重,并训练组合的 GAN;这时的训练目标是生成器。此时的损失函数仍然是二元交叉熵:

discriminator.compile(loss='binary_crossentropy', optimizer='adam')
gan.compile(loss='binary_crossentropy', optimizer='adam') 

现在我们开始进行训练。对于每一个训练轮次(epoch),我们首先取一个随机噪声样本,输入到生成器中,生成器会产生一张假图像。然后,我们将生成的假图像与实际的训练图像以及它们对应的标签一起放入一个批次,并用这些数据先对鉴别器进行训练:

def train(epochs=1, batchSize=128):
    batchCount = int(X_train.shape[0] / batchSize)
    print ('Epochs:', epochs)
    print ('Batch size:', batchSize)
    print ('Batches per epoch:', batchCount)
    for e in range(1, epochs+1):
        print ('-'*15, 'Epoch %d' % e, '-'*15)
        for _ in range(batchCount):
            # Get a random set of input noise and images
            noise = np.random.normal(0, 1, size=[batchSize,
            randomDim])
            imageBatch = X_train[np.random.randint(0,
            X_train.shape[0], size=batchSize)]
            # Generate fake MNIST images
            generatedImages = generator.predict(noise)
            # print np.shape(imageBatch), np.shape(generatedImages)
            X = np.concatenate([imageBatch, generatedImages])
            # Labels for generated and real data
            yDis = np.zeros(2*batchSize)
            # One-sided label smoothing
            yDis[:batchSize] = 0.9
            # Train discriminator
            discriminator.trainable = True
            dloss = discriminator.train_on_batch(X, yDis) 

如果你注意到,在分配标签时,我们并没有使用0/1,而是使用了0/0.9 —— 这叫做标签平滑。研究发现,使用软目标有助于提高模型的泛化能力和学*速度(标签平滑何时有用?,Muller 等,NeurIPS 2019)。

现在,在同一个for循环中,我们将训练生成器。我们希望生成器生成的图像被鉴别器判断为真实的,因此我们使用一个随机向量(噪声)作为输入给生成器;生成器生成一张假图像,然后训练 GAN,使得鉴别器将这张图像判断为真实(输出为1):

 # Train generator
            noise = np.random.normal(0, 1, size=[batchSize,
            randomDim])
            yGen = np.ones(batchSize)
            discriminator.trainable = False
            gloss = gan.train_on_batch(noise, yGen) 

很酷的技巧,对吧?如果你愿意,你还可以保存生成器和判别器的损失,以及生成的图像。接下来,我们将保存每个 epoch 的损失,并在每 20 个 epoch 后生成图像:

 # Store loss of most recent batch from this epoch
        dLosses.append(dloss)
        gLosses.append(gloss)
        if e == 1 or e % 20 == 0:
               saveGeneratedImages(e) 

我们现在可以通过调用train函数来训练 GAN。在下图中,你可以看到生成损失和判别损失的曲线图,随着 GAN 的学*进行:

图表,折线图 说明自动生成

图 9.2:判别器和生成器的损失图

由我们 GAN 生成的手写数字:

日历 说明自动生成一张包含文字的图片,擦菜板,厨房用品 说明自动生成

图 9.3:生成的手写数字

从之前的图中可以看出,随着训练轮次的增加,GAN 生成的手写数字越来越逼真。

为了绘制损失和生成的手写数字图像,我们定义了两个辅助函数,plotLoss()saveGeneratedImages()。它们的代码如下:

# Plot the loss from each batch
def plotLoss(epoch):
    plt.figure(figsize=(10, 8))
    plt.plot(dLosses, label='Discriminitive loss')
    plt.plot(gLosses, label='Generative loss')
    plt.xlabel('Epoch')
    plt.ylabel('Loss')
    plt.legend()
    plt.savefig('images/gan_loss_epoch_%d.png' % epoch)
# Create a wall of generated MNIST images
def saveGeneratedImages(epoch, examples=100, dim=(10, 10), figsize=(10, 10)):
    noise = np.random.normal(0, 1, size=[examples, randomDim])
    generatedImages = generator.predict(noise)
    generatedImages = generatedImages.reshape(examples, 28, 28)
    plt.figure(figsize=figsize)
    for i in range(generatedImages.shape[0]):
        plt.subplot(dim[0], dim[1], i+1)
        plt.imshow(generatedImages[i], interpolation='nearest',
        cmap='gray_r')
        plt.axis('off')
    plt.tight_layout()
    plt.savefig('images/gan_generated_image_epoch_%d.png' % epoch) 

saveGeneratedImages函数将图像保存在images文件夹中,因此请确保你已在当前工作目录中创建了该文件夹。完整代码可以在本章节的 GitHub 仓库中的笔记本VanillaGAN.ipynb中找到。在接下来的部分,我们将介绍一些*期的 GAN 架构,并在 TensorFlow 中实现它们。

深度卷积生成对抗网络(DCGAN)

DCGAN 于 2016 年提出,已成为最流行且成功的 GAN 架构之一。设计的主要思想是使用卷积层,而不使用池化层或最终的分类层。卷积步幅和转置卷积被用来进行下采样(即减少维度)和上采样(即增加维度。在 GAN 中,我们通过转置卷积层来实现这一点。有关转置卷积层的更多信息,请参考 Dumoulin 和 Visin 的论文《深度学*卷积算术指南》)。

在深入了解 DCGAN 架构及其功能之前,让我们先指出该论文中介绍的主要变化:

  • 网络由所有卷积层组成。池化层被替换为判别器中的步幅卷积(即,在使用卷积层时,我们将步幅数从 1 增加到 2),而生成器中使用转置卷积。

  • 卷积层后的全连接分类层已被移除。

  • 为了帮助梯度流动,每个卷积层后都进行了批归一化。

DCGAN 的基本理念与基础 GAN 相同:我们有一个生成器,它接受 100 维的噪声;噪声被投影并重新塑形,然后通过卷积层。图 9.4显示了生成器的架构:

图表 描述自动生成

图 9.4:可视化生成器的架构

判别器网络接受图像(无论是由生成器生成的还是来自真实数据集的),这些图像会经过卷积和批量归一化处理。在每个卷积步骤中,图像会通过步幅进行下采样。卷积层的最终输出被展平并传递到一个单神经元分类器层。

图 9.5中,您可以看到判别器:

图表 描述自动生成

图 9.5:可视化判别器的架构

生成器和判别器结合在一起形成了 DCGAN。训练过程与之前相同;也就是说,我们首先在一个小批量上训练判别器,然后冻结判别器并训练生成器。这个过程会反复进行几千个周期。作者发现,使用 Adam 优化器和学*率 0.002 可以获得更稳定的结果。

接下来,我们将实现一个 DCGAN,用于生成手写数字。

用于 MNIST 数字的 DCGAN

现在让我们构建一个用于生成手写数字的 DCGAN。我们首先来看生成器的代码。生成器是通过按顺序添加层来构建的。第一层是一个密集层,输入是 100 维的噪声。这个 100 维的输入被扩展为一个大小为 128 × 7 × 7 的平坦向量。这样做是为了最终得到一个 28 × 28 的输出,这是 MNIST 手写数字的标准大小。该向量被重塑为大小为 7 × 7 × 128 的张量。然后,使用 TensorFlow Keras 的UpSampling2D层对该张量进行上采样。请注意,这一层仅通过将行和列加倍来缩放图像。该层没有权重,因此计算开销很小。

Upsampling2D 层现在将 7 × 7 × 128(行 × 列 × 通道)图像的行和列加倍,得到 14 × 14 × 128 的输出。上采样后的图像将传递到一个卷积层,该卷积层学*填充上采样图像中的细节。卷积的输出传递到批量归一化层,以改善梯度流。批量归一化后的输出在所有中间层中会经过 ReLU 激活。我们重复这种结构,即上采样 | 卷积 | 批量归一化 | ReLU。在接下来的生成器中,我们有两个这样的结构,第一个卷积操作使用 128 个滤波器,第二个使用 64 个滤波器。最终的输出通过一个纯卷积层得到,该层使用 3 个滤波器和双曲正切激活,输出大小为 28 × 28 × 1 的图像:

def build_generator(self):
    model = Sequential()
    model.add(Dense(128 * 7 * 7, activation="relu",
    input_dim=self.latent_dim))
    model.add(Reshape((7, 7, 128)))
    model.add(UpSampling2D())
    model.add(Conv2D(128, kernel_size=3, padding="same"))
    model.add(BatchNormalization(momentum=0.8))
    model.add(Activation("relu"))
    model.add(UpSampling2D())
    model.add(Conv2D(64, kernel_size=3, padding="same"))
    model.add(BatchNormalization(momentum=0.8))
    model.add(Activation("relu"))
    model.add(Conv2D(self.channels, kernel_size=3, padding="same"))
    model.add(Activation("tanh"))
    model.summary()
    noise = Input(shape=(self.latent_dim,))
    img = model(noise)
    return Model(noise, img) 

结果生成器模型如下:

Model: "sequential_1"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 conv2d_3 (Conv2D)           (None, 14, 14, 32)        320       

 leaky_re_lu (LeakyReLU)     (None, 14, 14, 32)        0         

 dropout (Dropout)           (None, 14, 14, 32)        0         

 conv2d_4 (Conv2D)           (None, 7, 7, 64)          18496     

 zero_padding2d (ZeroPadding  (None, 8, 8, 64)         0         
 2D)                                                             

 batch_normalization_2 (Batc  (None, 8, 8, 64)         256       
 hNormalization)                                                 

 leaky_re_lu_1 (LeakyReLU)   (None, 8, 8, 64)          0         

 dropout_1 (Dropout)         (None, 8, 8, 64)          0         

 conv2d_5 (Conv2D)           (None, 4, 4, 128)         73856     

 batch_normalization_3 (Batc  (None, 4, 4, 128)        512       
 hNormalization)                                                 

 leaky_re_lu_2 (LeakyReLU)   (None, 4, 4, 128)         0         

 dropout_2 (Dropout)         (None, 4, 4, 128)         0         

 conv2d_6 (Conv2D)           (None, 4, 4, 256)         295168    

 batch_normalization_4 (Batc  (None, 4, 4, 256)        1024      
 hNormalization)                                                 

 leaky_re_lu_3 (LeakyReLU)   (None, 4, 4, 256)         0         

 dropout_3 (Dropout)         (None, 4, 4, 256)         0         

 flatten (Flatten)           (None, 4096)              0         

 dense_1 (Dense)             (None, 1)                 4097      

=================================================================
Total params: 393,729
Trainable params: 392,833
Non-trainable params: 896 

你还可以尝试使用转置卷积层。这个层不仅能上采样输入图像,还能在训练过程中学*如何填充细节。因此,你可以用一个转置卷积层替换上采样和卷积层。转置卷积层执行的是反向卷积操作。你可以在论文中阅读更详细的内容:深度学*卷积算术指南arxiv.org/abs/1603.07285)。

现在我们有了生成器,让我们看看构建判别器的代码。判别器与标准卷积神经网络相似,但有一个重大变化:我们使用步长为 2 的卷积层代替最大池化。我们还加入了 dropout 层以避免过拟合,并使用批归一化来提高准确性和加速收敛。激活层使用的是 leaky ReLU。在接下来的网络中,我们使用了三个这样的卷积层,过滤器分别为 32、64 和 128。第三个卷积层的输出被展平并输入到一个带有单个单元的全连接层。

该单元的输出将图像分类为伪造或真实:

def build_discriminator(self):
    model = Sequential()
    model.add(Conv2D(32, kernel_size=3, strides=2,
    input_shape=self.img_shape, padding="same"))
    model.add(LeakyReLU(alpha=0.2))
    model.add(Dropout(0.25))
    model.add(Conv2D(64, kernel_size=3, strides=2, padding="same"))
    model.add(ZeroPadding2D(padding=((0,1),(0,1))))
    model.add(BatchNormalization(momentum=0.8))
    model.add(LeakyReLU(alpha=0.2))
    model.add(Dropout(0.25))
    model.add(Conv2D(128, kernel_size=3, strides=2, padding="same"))
    model.add(BatchNormalization(momentum=0.8))
    model.add(LeakyReLU(alpha=0.2))
    model.add(Dropout(0.25))
    model.add(Conv2D(256, kernel_size=3, strides=1, padding="same"))
    model.add(BatchNormalization(momentum=0.8))
    model.add(LeakyReLU(alpha=0.2))
    model.add(Dropout(0.25))
    model.add(Flatten())
    model.add(Dense(1, activation='sigmoid'))
    model.summary()
    img = Input(shape=self.img_shape)
    validity = model(img)
    return Model(img, validity) 

结果判别器网络是:

Model: "sequential"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 dense (Dense)               (None, 6272)              633472    

 reshape (Reshape)           (None, 7, 7, 128)         0         

 up_sampling2d (UpSampling2D  (None, 14, 14, 128)      0         
 )                                                               

 conv2d (Conv2D)             (None, 14, 14, 128)       147584    

 batch_normalization (BatchN  (None, 14, 14, 128)      512       
 ormalization)                                                   

 activation (Activation)     (None, 14, 14, 128)       0         

 up_sampling2d_1 (UpSampling  (None, 28, 28, 128)      0         
 2D)                                                             

 conv2d_1 (Conv2D)           (None, 28, 28, 64)        73792     

 batch_normalization_1 (Batc  (None, 28, 28, 64)       256       
 hNormalization)                                                 

 activation_1 (Activation)   (None, 28, 28, 64)        0         

 conv2d_2 (Conv2D)           (None, 28, 28, 1)         577       

 activation_2 (Activation)   (None, 28, 28, 1)         0         

=================================================================
Total params: 856,193
Trainable params: 855,809
Non-trainable params: 384
_________________________________________________________________ 

完整的 GAN 是通过将两者结合而成的:

class DCGAN():
    def __init__(self, rows, cols, channels, z = 10):
        # Input shape
        self.img_rows = rows
        self.img_cols = cols
        self.channels = channels
        self.img_shape = (self.img_rows, self.img_cols, self.channels)
        self.latent_dim = z
        optimizer = Adam(0.0002, 0.5)
        # Build and compile the discriminator
        self.discriminator = self.build_discriminator()
        self.discriminator.compile(loss=’binary_crossentropy’,
            optimizer=optimizer,
            metrics=[‘accuracy’])
        # Build the generator
        self.generator = self.build_generator()
        # The generator takes noise as input and generates imgs
        z = Input(shape=(self.latent_dim,))
        img = self.generator(z)
        # For the combined model we will only train the generator
        self.discriminator.trainable = False
        # The discriminator takes generated images as input and determines validity
        valid = self.discriminator(img)
        # The combined model  (stacked generator and discriminator)
        # Trains the generator to fool the discriminator
        self.combined = Model(z, valid)
        self.combined.compile(loss=’binary_crossentropy’, optimizer=optimizer) 

如你所见,我们在这里定义了binary_crossentropy损失对象,稍后我们将用它来定义生成器和判别器的损失。生成器和判别器的优化器在此init方法中定义。最后,我们定义了一个 TensorFlow 检查点,用于在模型训练过程中保存两个模型(生成器和判别器)。

GAN 的训练方式与之前相同;每一步,首先将随机噪声输入生成器。生成器的输出与真实图像结合,初步训练判别器,然后训练生成器使其生成能够欺骗判别器的图像。

这一过程会对下一批图像重复进行。GAN 的训练通常需要几百到几千轮:

 def train(self, epochs, batch_size=256, save_interval=50):
        # Load the dataset
        (X_train, _), (_, _) = mnist.load_data()
        # Rescale -1 to 1
        X_train = X_train / 127.5 - 1.
        X_train = np.expand_dims(X_train, axis=3)
        # Adversarial ground truths
        valid = np.ones((batch_size, 1))
        fake = np.zeros((batch_size, 1))
        for epoch in range(epochs):
            # ---------------------
            #  Train Discriminator
            # ---------------------
            # Select a random half of images
            idx = np.random.randint(0, X_train.shape[0], batch_size)
            imgs = X_train[idx]
            # Sample noise and generate a batch of new images
            noise = np.random.normal(0, 1, (batch_size, self.latent_dim))
            gen_imgs = self.generator.predict(noise)
            # Train the discriminator (real classified as ones and generated as zeros)
            d_loss_real = self.discriminator.train_on_batch(imgs, valid)
            d_loss_fake = self.discriminator.train_on_batch(gen_imgs, fake)
            d_loss = 0.5 * np.add(d_loss_real, d_loss_fake)
            # ---------------------
            #  Train Generator
            # ---------------------
            # Train the generator (wants discriminator to mistake images as real)
            g_loss = self.combined.train_on_batch(noise, valid)
            # Plot the progress
            print ("%d [D loss: %f, acc.: %.2f%%] [G loss: %f]" % (epoch, d_loss[0], 100*d_loss[1], g_loss))
            # If at save interval => save generated image samples
            if epoch % save_interval == 0:
                self.save_imgs(epoch) 

最后,我们需要一个辅助函数来保存图像:

 def save_imgs(self, epoch):
        r, c = 5, 5
        noise = np.random.normal(0, 1, (r * c, self.latent_dim))
        gen_imgs = self.generator.predict(noise)
        # Rescale images 0 - 1
        gen_imgs = 0.5 * gen_imgs + 0.5
        fig, axs = plt.subplots(r, c)
        cnt = 0
        for i in range(r):
            for j in range(c):
                axs[i,j].imshow(gen_imgs[cnt, :,:,0], cmap='gray')
                axs[i,j].axis('off')
                cnt += 1
        fig.savefig("images/dcgan_mnist_%d.png" % epoch)
        plt.close() 

现在让我们开始训练我们的 GAN:

dcgan = DCGAN(28,28,1)
dcgan.train(epochs=5000, batch_size=256, save_interval=50) 

我们的 GAN 在学*如何伪造手写数字后生成的图像是:

图 9.6:GAN 生成的图像——初步尝试

前面的图像是 GAN 的初步尝试。随着它在接下来的 10 轮训练中学*,生成的数字质量大幅提升:

图 9.7:GAN 在 6、8 和 10 轮训练后的生成图像

完整的代码可以在 GitHub 仓库中的DCGAN.ipynb找到。我们可以将这里讨论的概念应用到其他领域的图像上。关于图像的一个有趣研究工作发表在论文 无监督表示学*与深度卷积生成对抗网络 中,作者是 Alec Radford、Luke Metz 和 Soumith Chintala,2015 年。以下是论文摘要:

*年来,卷积网络(CNN)在计算机视觉应用中的监督学*得到了广泛应用。相比之下,CNN 的无监督学*关注较少。在这项工作中,我们希望帮助弥合 CNN 在监督学*和无监督学*上的成功之间的差距。我们介绍了一类称为深度卷积生成对抗网络(DCGAN)的 CNN,它们具有某些架构限制,并证明它们是无监督学*的有力候选。通过在各种图像数据集上进行训练,我们展示了令人信服的证据,表明我们的深度卷积对抗对可以在生成器和判别器中学*从物体部分到场景的层次化表示。此外,我们还使用学*到的特征进行新的任务,展示它们作为通用图像表示的适用性。

—Radford 等人,2015 年

以下是将 DCGAN 应用于名人图像数据集的一些有趣结果:

一个人的面部拼贴图,描述自动生成一个人的面部拼贴图,描述自动生成

图 9.8:使用 DCGAN 生成的名人图像

另一篇有趣的论文是由 Raymond A. Yeh 等人于 2016 年发表的《基于感知和上下文损失的语义图像修复》。就像摄影师使用内容感知填充工具来填补图像中不需要或缺失的部分一样,本文中他们使用了 DCGAN 进行图像补全。

如前所述,围绕 GAN 的研究正在进行大量探索。在下一节中,我们将探讨*年来提出的一些有趣的 GAN 架构。

一些有趣的 GAN 架构

自从它们问世以来,GAN(生成对抗网络)引起了大量关注,因此我们看到在 GAN 的训练、架构和应用方面进行了许多修改和实验。在这一节中,我们将探讨*年来提出的一些有趣的 GAN。

SRGAN

你是否记得在某部犯罪惊悚片中,主角让电脑专家放大模糊的犯罪现场图像?通过放大,我们可以详细看到犯罪嫌疑人的面孔,包括使用的武器以及上面刻的任何东西!好吧,超分辨率生成对抗网络SRGANs)可以执行类似的魔法。魔法的意思是,GAN 表明通过它可以获得高分辨率图像,而最终的结果取决于所使用的相机分辨率。在这里,GAN 被训练成在给定低分辨率图像时,可以生成逼真的高分辨率图像。SRGAN 架构由三个神经网络组成:一个非常深的生成器网络(使用残差模块;参见第二十章高级卷积神经网络),一个判别器网络,以及一个预训练的 VGG-16 网络。

SRGAN 使用感知损失函数(由 Johnson 等人开发;你可以在参考文献部分找到该论文的链接)。在 SRGAN 中,作者首先对高分辨率图像进行了下采样,并使用生成器生成其“高分辨率”版本。判别器被训练来区分真实的高分辨率图像和生成的高分辨率图像。在 VGG 网络的高层中,网络输出与高分辨率部分之间的特征图激活差异构成了感知损失函数。除了感知损失外,作者进一步添加了内容损失和对抗损失,以使得生成的图像看起来更自然,细节更具艺术感。感知损失定义为内容损失和对抗损失的加权和:

右边第一个项是内容损失,通过使用预训练的 VGG 19 生成的特征图来获得。从数学角度来看,它是重建图像(即由生成器生成的图像)和原始高分辨率参考图像之间的欧几里得距离。右侧的第二项是对抗损失。它是标准的生成损失项,旨在确保生成器生成的图像能够欺骗判别器。你可以从下面的图像中看到,由 SRGAN 生成的图像在 PSNR 值为 37.61 时,已经非常接*原始高分辨率图像:

图 9.9:来自论文《使用生成对抗网络的照片级单图像超分辨率》中的示例,Ledig 等人。

另一个值得注意的架构是 CycleGAN;它在 2017 年提出,能够执行图像翻译任务。经过训练后,你可以将一张图像从一个领域翻译到另一个领域。例如,在训练了马和斑马的数据集后,如果你给它一张前景是马的图像,CycleGAN 可以将马转化为斑马,并保持相同的背景。我们接下来将进一步探讨它。

CycleGAN

你有没有想过如果梵高或马奈画了某个景象,它会是什么样子?我们有很多梵高/马奈画的景象和风景,但没有任何输入输出对的集合。CycleGAN 执行图像翻译,即在没有训练样本的情况下,将给定领域(例如景象)的图像转换为另一个领域(例如同一场景的梵高画作)。CycleGAN 在没有训练对的情况下进行图像翻译的能力使它独特。

为了实现图像翻译,作者使用了一种非常简单但有效的过程。他们利用了两个 GAN,每个 GAN 的生成器执行图像从一个领域到另一个领域的翻译。

具体来说,假设输入为 X,则第一个 GAN 的生成器执行映射 ;因此,其输出为 Y = G(X)。第二个 GAN 的生成器执行反向映射 ,得到 X = F(Y)。每个判别器的训练目标是区分真实图像与合成图像。其原理如下所示:

自动生成的图片,包含文本、时钟、手表

图 9.10:循环一致性损失

为了训练组合的 GAN,作者除了常规的 GAN 对抗损失外,还加入了正向循环一致性损失(左图)和反向循环一致性损失(右图)。这确保了如果输入图像为X,则经过两次转换 F(G(X)) ~ X 后,得到的图像与原图像相同,X(同样,反向循环一致性损失确保 G(F(Y)) ~ Y)。

以下是一些由 CycleGAN 成功实现的图像转换示例 [7]:

自动生成的图片,包含文本

图 9.11:一些成功的 CycleGAN 图像转换示例

以下是更多示例;你可以看到季节的转换(夏季 冬季),照片 绘画及其反向转换,马 斑马及其反向转换 [7]:

自动生成的图片,包含文本、截图、显示器、不同内容

图 9.12:CycleGAN 转换的更多示例

在本章后面,我们还将探索 CycleGAN 的 TensorFlow 实现。接下来,我们将讨论 InfoGAN,它是一种条件 GAN,其中 GAN 不仅生成图像,还可以通过控制变量来控制生成的图像。

InfoGAN

到目前为止,我们考虑的 GAN 架构几乎没有或完全没有对生成图像的控制。InfoGAN 改变了这一点;它提供了对生成图像各种属性的控制。InfoGAN 使用信息理论的概念,将噪声项转化为潜在编码,从而对输出进行可预测且系统化的控制。

InfoGAN 中的生成器接受两个输入:潜在空间 Z 和潜在编码 c,因此生成器的输出是 G(Z,c)。GAN 的训练目标是最大化潜在编码 c 和生成图像 G(Z,c) 之间的互信息。以下图展示了 InfoGAN 的架构:

自动生成的图表

图 9.13:InfoGAN 的架构,已可视化

拼接向量(Z,c)被输入到生成器中。Q(c|X) 也是一个神经网络。与生成器结合后,它工作于形成随机噪声 Z 和其潜在编码 c_hat 之间的映射。目标是给定 X 来估计 c。这通过在传统 GAN 的目标函数中添加正则化项来实现:

术语 V[G](D,G) 是传统 GAN 的损失函数,第二项是正则化项,其中 是一个常数。论文中将其值设为 1,I(c;G(Z,c)) 是潜在编码 c 和生成器生成的图像 G(Z,c) 之间的互信息。

以下是 InfoGAN 在 MNIST 数据集上的一些令人兴奋的结果:

图 9.14:使用 InfoGAN 在 MNIST 数据集上的结果。这里,不同的行对应于不同的固定潜在编码和噪声的随机样本

现在,既然我们已经看过一些令人兴奋的 GAN 架构,让我们来探索一些 GAN 的酷应用。

GAN 的酷应用

我们已经看到生成器能够学会如何伪造数据。这意味着它学会了如何创建由网络生成的看似真实且人工制作的新合成数据。在深入探讨一些 GAN 代码的细节之前,我们想分享论文 [6] 的结果(代码可以在 github.com/hanzhanggit/StackGAN 在线获得),该论文使用 GAN 从文本描述中合成伪造图像。结果令人印象深刻:第一列是测试集中真实的图像,所有其他列是由 StackGAN 的第一阶段和第二阶段从相同的文本描述生成的图像。更多例子可以在 YouTube 上找到(www.youtube.com/watch?v=SuRyL5vhCIM&feature=youtu.be):

A picture containing text, bird, outdoor, standing  Description automatically generated

图 9.15:使用 GAN 生成的鸟类图像

A group of flowers  Description automatically generated with low confidence

图 9.16:使用 GAN 生成的花卉图像

现在让我们看看 GAN 如何学会“伪造”MNIST 数据集。在这种情况下,生成器和判别器网络使用的是 GAN 和 CNN 的结合。在开始时,生成器生成的内容没有任何可理解的形式,但经过几轮迭代后,合成的伪造数字逐渐变得越来越清晰。在这张图中,面板按训练轮次递增排序,你可以看到面板之间的质量在不断提高:

A picture containing text, furniture  Description automatically generated

图 9.17:GAN 的初始输出难以辨认

随着训练的进行,你可以在 图 9.17 中看到数字开始变得更具可识别性:

一张包含文本、计算机、键盘、电子设备的图片  描述自动生成

图 9.18:GAN 的改进输出,经过进一步迭代后

一张包含键盘、计算机、桌子、电子设备的图片  描述自动生成

图 9.19:GAN 的最终输出,显示出比之前迭代显著的改进

经过 10,000 次训练周期后,你可以看到手写数字变得更加真实。

GAN 最酷的应用之一是在生成器的向量 Z 中对人脸进行运算。换句话说,如果我们停留在合成伪造图像的空间中,就可以看到类似这样的事情: [微笑的女人] - [中立的女人] + [中立的男人] = [微笑的男人],或者像这样: [戴眼镜的男人] - [不戴眼镜的男人] + [不戴眼镜的女人] = [戴眼镜的女人]。这一点在 Alec Radford 及其同事于 2015 年发布的论文 《使用深度卷积生成对抗网络的无监督表示学*》 中得到了展示。本文中的所有图像都是通过 GAN 的一个版本生成的。它们并不是真实的。完整论文请访问:arxiv.org/abs/1511.06434。以下是论文中的一些示例。作者还在这个 GitHub 仓库中分享了他们的代码:github.com/Newmu/dcgan_code

一张包含人脸的拼贴图  描述自动生成,置信度较高

图 9.20:使用 GAN 进行图像运算

卧室:经过五次训练周期后生成的卧室:

一张房子的拼贴图  描述自动生成,置信度较低

图 9.21:使用 GAN 在经过 5 次训练周期后生成的卧室

专辑封面:这些图像是通过 GAN 生成的,但看起来像真实的专辑封面:

一张包含文本、不同物体、摊位的图片  描述自动生成

图 9.22:使用 DCGAN 生成的专辑封面

GAN 的另一个酷应用是生成虚拟人脸。NVIDIA 于 2018 年推出了一种名为 StyleGAN 的模型(第二版 StyleGAN2 于 2020 年 2 月发布,第三版于 2021 年发布),该模型展示了可以用来生成逼真的人类图像。下面是通过 StyleGAN 生成的一些逼真的虚假人物面孔,这些面孔是在经过 1,000 次训练周期后获得的;为了获得更好的效果,你需要进行更多的训练:

图 9.23:通过 StyleGAN 生成的虚假面孔

它不仅生成假图像,像 InfoGAN 一样,你可以从粗到细地控制特征。这是 NVIDIA 发布的官方视频,展示了特征如何影响结果:www.youtube.com/watch?v=kSLJriaOumA。他们通过在潜变量Z之后添加一个非线性映射网络来实现这一点。映射网络将潜变量转换为相同大小的映射;映射向量的输出被馈送到生成器网络的不同层,从而允许 StyleGAN 控制不同的视觉特征。要了解更多关于 StyleGAN 的内容,您应该阅读 NVIDIA 实验室的论文《A style-based generator architecture for Generative Adversarial Networks》[10]。

TensorFlow 中的 CycleGAN

在本节中,我们将实现一个基于 TensorFlow 的 CycleGAN。CycleGAN 需要一个特殊的数据集,一个成对数据集,来自一个图像领域到另一个领域。因此,除了必要的模块外,我们还将使用tensorflow_datasets。另外,我们将使用tensorflow_examples库,直接使用tensorflow_examples中定义的pix2pix模型中的生成器和判别器。这里的代码来自于这里的github.com/tensorflow/docs/blob/master/site/en/tutorials/generative/cyclegan.ipynb

import tensorflow_datasets as tfds
from tensorflow_examples.models.pix2pix import pix2pix
import os
import time
import matplotlib.pyplot as plt
from IPython.display import clear_output
import tensorflow as tf 

TensorFlow 的Dataset API 包含了一个数据集列表。它有许多适用于 CycleGAN 的成对数据集,例如从马到斑马、从苹果到橘子等等。你可以在这里访问完整的列表:www.tensorflow.org/datasets/catalog/cycle_gan。在我们的代码中,我们将使用summer2winter_yosemite,它包含了约塞米蒂(美国)在夏季(数据集 A)和冬季(数据集 B)的图像。我们将训练 CycleGAN 将夏季图像转换为冬季图像,反之亦然。

让我们加载数据并获取训练和测试图像:

dataset, metadata = tfds.load('cycle_gan/summer2winter_yosemite',
                              with_info=True, as_supervised=True)
train_summer, train_winter = dataset['trainA'], dataset['trainB']
test_summer, test_winter = dataset['testA'], dataset['testB'] 

我们需要设置一些超参数:

BUFFER_SIZE = 1000
BATCH_SIZE = 1
IMG_WIDTH = 256
IMG_HEIGHT = 256
EPOCHS = 100
LAMBDA = 10
AUTOTUNE = tf.data.AUTOTUNE 

在我们训练网络之前,图像需要进行归一化。为了更好的性能,我们还会对训练图像添加随机抖动;这些图像首先被调整为 286x286 的大小,然后我们随机裁剪回 256x256 的大小,最后应用随机抖动:

def normalize(input_image, label):
    input_image = tf.cast(input_image, tf.float32)
    input_image = (input_image / 127.5) - 1
    return input_image
def random_crop(image):
    cropped_image = tf.image.random_crop(image, size=[IMG_HEIGHT,
    IMG_WIDTH, 3])
    return cropped_image
def random_jitter(image):
    # resizing to 286 x 286 x 3
    image = tf.image.resize(image, [286, 286],
    method=tf.image.ResizeMethod.NEAREST_NEIGHBOR)
    # randomly cropping to 256 x 256 x 3
    image = random_crop(image)
    # random mirroring
    image = tf.image.random_flip_left_right(image)
    return image 

数据增强(随机裁剪和抖动)仅应用于训练图像;因此,我们需要为图像预处理分离出两个函数,一个用于训练数据,另一个用于测试数据:

def preprocess_image_train(image, label):
    image = random_jitter(image)
    image = normalize(image)
    return image
def preprocess_image_test(image, label):
    image = normalize(image)
    return image 

之前的函数应用于图像时,将会将其归一化到[-1,1]的范围内,并对训练图像应用增强。让我们将这些应用到我们的训练和测试数据集,并创建一个数据生成器,它将批量提供训练图像:

train_summer = train_summer.cache().map(
    preprocess_image_train, num_parallel_calls=AUTOTUNE).shuffle(
    BUFFER_SIZE).batch(BATCH_SIZE)
train_winter = train_winter.cache().map(
    preprocess_image_train, num_parallel_calls=AUTOTUNE).shuffle(
    BUFFER_SIZE).batch(BATCH_SIZE)
test_summer = test_summer.map(
    preprocess_image_test,
    num_parallel_calls=AUTOTUNE).cache().shuffle(
    BUFFER_SIZE).batch(BATCH_SIZE)
test_winter = test_winter.map(
    preprocess_image_test,
    num_parallel_calls=AUTOTUNE).cache().shuffle(
    BUFFER_SIZE).batch(BATCH_SIZE) 

在前面的代码中,参数num_parallel_calls使得可以利用系统中的多个 CPU 核心;应将其值设置为系统中 CPU 核心的数量。如果不确定,请使用AUTOTUNE = tf.data.AUTOTUNE,让 TensorFlow 动态地为你确定合适的数量。

如前所述,我们使用的是从pix2pix模型中提取的生成器和判别器,定义在tensorflow_examples模块中。我们将有两个生成器和两个判别器:

OUTPUT_CHANNELS = 3
generator_g = pix2pix.unet_generator(OUTPUT_CHANNELS, norm_type='instancenorm')
generator_f = pix2pix.unet_generator(OUTPUT_CHANNELS, norm_type='instancenorm')
discriminator_x = pix2pix.discriminator(norm_type='instancenorm', target=False)
discriminator_y = pix2pix.discriminator(norm_type='instancenorm', target=False) 

在继续进行模型定义之前,让我们看看图像。每张图像在绘制之前都会进行处理,以确保其强度是正常的:

to_winter = generator_g(sample_summer)
to_summer = generator_f(sample_winter)
plt.figure(figsize=(8, 8))
contrast = 8
imgs = [sample_summer, to_winter, sample_winter, to_summer]
title = ['Summer', 'To Winter', 'Winter', 'To Summer']
for i in range(len(imgs)):
  plt.subplot(2, 2, i+1)
  plt.title(title[i])
  if i % 2 == 0:
    plt.imshow(imgs[i][0] * 0.5 + 0.5)
  else:
    plt.imshow(imgs[i][0] * 0.5 * contrast + 0.5)
plt.show() 

A picture containing painted  Description automatically generated

图 9.24:训练前 CycleGAN 架构中 GAN 1 的输入和 GAN 2 的输出

接下来我们定义损失和优化器。我们保留与 DCGAN 相同的生成器和判别器的损失函数:

loss_obj = tf.keras.losses.BinaryCrossentropy(from_logits=True)
def discriminator_loss(real, generated):
    real_loss = loss_obj(tf.ones_like(real), real)
    generated_loss = loss_obj(tf.zeros_like(generated), generated)
    total_disc_loss = real_loss + generated_loss
    return total_disc_loss * 0.5
def generator_loss(generated):
    return loss_obj(tf.ones_like(generated), generated) 

由于现在有四个模型,两个生成器和两个判别器,我们需要定义四个优化器:

generator_g_optimizer = tf.keras.optimizers.Adam(2e-4, beta_1=0.5)
generator_f_optimizer = tf.keras.optimizers.Adam(2e-4, beta_1=0.5)
discriminator_x_optimizer = tf.keras.optimizers.Adam(2e-4, beta_1=0.5)
discriminator_y_optimizer = tf.keras.optimizers.Adam(2e-4, beta_1=0.5) 

此外,在 CycleGAN 中,我们需要定义两个额外的损失函数,第一个是循环一致性损失;我们可以使用相同的函数来计算前向和反向的循环一致性损失。循环一致性损失确保结果接*原始输入:

def calc_cycle_loss(real_image, cycled_image):
    loss1 = tf.reduce_mean(tf.abs(real_image - cycled_image))
    return LAMBDA * loss1 

我们还需要定义一个身份损失,确保如果输入给生成器一个图像Y,它将输出真实图像Y或与Y相似的图像。因此,如果我们给夏季图像生成器输入一张夏季图像,它不应该有太大变化:

def identity_loss(real_image, same_image):
    loss = tf.reduce_mean(tf.abs(real_image - same_image))
    return LAMBDA * 0.5 * loss 

现在我们定义一个函数,在批次中训练生成器和判别器,每次处理一对图像。这两个判别器和两个生成器将通过该函数以及带有梯度带的帮助进行训练。训练步骤可以分为四个部分:

  1. 获取两个生成器的输出图像。

  2. 计算损失。

  3. 计算梯度。

  4. 最后,应用梯度:

    @tf.function
    def train_step(real_x, real_y):
        # persistent is set to True because the tape is used
        # more than once to calculate the gradients.
      with tf.GradientTape(persistent=True) as tape:
        # Generator G translates X -> Y
        # Generator F translates Y -> X.
    
        fake_y = generator_g(real_x, training=True)
        cycled_x = generator_f(fake_y, training=True)
        fake_x = generator_f(real_y, training=True)
        cycled_y = generator_g(fake_x, training=True)
        # same_x and same_y are used for identity loss.
        same_x = generator_f(real_x, training=True)
        same_y = generator_g(real_y, training=True)
        disc_real_x = discriminator_x(real_x, training=True)
        disc_real_y = discriminator_y(real_y, training=True)
        disc_fake_x = discriminator_x(fake_x, training=True)
        disc_fake_y = discriminator_y(fake_y, training=True)
        # calculate the loss
        gen_g_loss = generator_loss(disc_fake_y)
        gen_f_loss = generator_loss(disc_fake_x)
    
        total_cycle_loss = calc_cycle_loss(real_x, cycled_x) + \
        calc_cycle_loss(real_y, cycled_y)
    
        # Total generator loss = adversarial loss + cycle loss
        total_gen_g_loss = gen_g_loss + total_cycle_loss + \
        identity_loss(real_y, same_y)
        total_gen_f_loss = gen_f_loss + total_cycle_loss + \
        identity_loss(real_x, same_x)
        disc_x_loss = discriminator_loss(disc_real_x,
        disc_fake_x)
        disc_y_loss = discriminator_loss(disc_real_y,
        disc_fake_y)
        # Calculate the gradients for generator and discriminator
        generator_g_gradients = tape.gradient(total_gen_g_loss,
        generator_g.trainable_variables)
        generator_f_gradients = tape.gradient(total_gen_f_loss,
        generator_f.trainable_variables)
        discriminator_x_gradients = tape.gradient(disc_x_loss,
        discriminator_x.trainable_variables)
        discriminator_y_gradients = tape.gradient(disc_y_loss,
        discriminator_y.trainable_variables)
        # Apply the gradients to the optimizer
        generator_g_optimizer.apply_gradients(zip(generator_g_gradients, generator_g.trainable_variables))
        generator_f_optimizer.apply_gradients(zip(generator_f_gradients, generator_f.trainable_variables))
        discriminator_x_optimizer.apply_gradients(zip(discriminator_x_gradients, discriminator_x.trainable_variables))
        discriminator_y_optimizer.apply_gradients(zip(discriminator_y_gradients, discriminator_y.trainable_variables)) 
    

我们定义了检查点来保存模型权重。由于训练一个足够好的 CycleGAN 可能需要一些时间,因此我们保存检查点,如果我们下次开始时,可以加载现有的检查点——这将确保模型从上次停止的地方继续学*:

checkpoint_path = "./checkpoints/train"
ckpt = tf.train.Checkpoint(generator_g=generator_g,
                           generator_f=generator_f,
                           discriminator_x=discriminator_x,
                           discriminator_y=discriminator_y,
                           generator_g_optimizer=generator_g_optimizer,
generator_f_optimizer=generator_f_optimizer,
discriminator_x_optimizer=discriminator_x_optimizer,
discriminator_y_optimizer=discriminator_y_optimizer)
ckpt_manager = tf.train.CheckpointManager(ckpt, checkpoint_path, max_to_keep=5)
# if a checkpoint exists, restore the latest checkpoint.
if ckpt_manager.latest_checkpoint:
    ckpt.restore(ckpt_manager.latest_checkpoint)
    print ('Latest checkpoint restored!!') 

现在让我们将所有部分结合起来,并训练网络 100 轮。请记住,在论文中,测试网络被训练了 200 轮,因此我们的结果不会那么好:

for epoch in range(EPOCHS):
    start = time.time()
    n = 0
    for image_x, image_y in tf.data.Dataset.zip((train_summer, train_winter)):
        train_step(image_x, image_y)
        if n % 10 == 0:
            print ('.', end='')
        n += 1
    clear_output(wait=True)
    # Using a consistent image (sample_summer) so that the progress of
    # the model is clearly visible.
    generate_images(generator_g, sample_summer)
    if (epoch + 1) % 5 == 0:
        ckpt_save_path = ckpt_manager.save()
        print ('Saving checkpoint for epoch {} at {}'.format(epoch+1,
                                                             ckpt_save_path))
    print ('Time taken for epoch {} is {} sec\n'.format(epoch + 1,
                                                        time.time()-start)) 

你可以看到一些由我们的 CycleGAN 生成的图像。生成器A接收夏季照片并将其转换为冬季,而生成器B接收冬季照片并将其转换为夏季:

A picture containing window, indoor, different  Description automatically generated

图 9.25:训练后使用 CycleGAN 生成的图像

我们建议您在 TensorFlow CycleGAN 数据集中尝试其他数据集。一些数据集会像苹果和橙子一样容易,但一些数据集则需要更多的训练。作者还维护了一个 GitHub 仓库,分享了他们在 PyTorch 中的实现,并提供了其他框架(包括 TensorFlow)中实现的链接:github.com/junyanz/CycleGAN

流基模型用于数据生成

虽然 VAE(第八章自编码器)和 GAN 在数据生成方面做得很好,但它们并未显式地学*输入数据的概率密度函数。GAN 通过将无监督问题转化为有监督学*问题来进行学*。

VAE 通过最大化证据下界ELBO)来优化数据的最大对数似然。流基模型与这两者的不同之处在于,它们显式地学*数据分布。这一点优于 VAE 和 GAN,因为它使得流基模型能够用于填补缺失数据、采样数据,甚至识别数据分布中的偏差。流基模型通过最大化对数似然估计来实现这一点。为了理解如何实现这一点,我们稍微深入一些数学内容。

表示数据D的概率密度,表示由我们的模型M*似的概率密度。流基模型的目标是找到模型参数,使得两者之间的距离最小,即:

如果我们使用 KL 散度作为距离度量,上面的表达式可以简化为:

这个方程表示最小化负对数似然NLL)(等同于最大化对数似然估计)。

流基模型的基本架构由一系列可逆函数组成,如下图所示。挑战在于找到函数f(x),使得其逆函数f^(-1)(x)能够生成x’,即输入x的重构版本:

图 9.26:流基模型的架构

流基模型的实现主要有两种方式:

  • 归一化流:这里的基本思想是使用一系列简单的可逆函数来转换复杂的输入。当我们通过一系列变换时,我们根据变量变化定理(archive.lib.msu.edu/crcmath/math/math/c/c210.htm)反复地用新变量替代旧变量,最终得到目标变量的概率分布。变量 z[i]所经过的路径即为流,而由连续分布形成的完整链条称为归一化流。

    RealNVPReal-valued Non-Volume Preserving)模型由 Dinh 等人(2017 年)提出,NICENon-linear Independent Components Estimation)由 Dinh 等人(2015 年)提出,Glow 由 Knigma 和 Dhariwal(2018 年)提出,使用了归一化流技巧:

    自动生成的图示

    图 9.27:归一化流模型:lilianweng.github.io/posts/2018-10-13-flow-models/

  • 自回归流:像MADEMasked Autoencoder for Distribution Estimation)、PixelRNN 和 wavenet 这样的模型基于自回归模型。在这里,向量变量中的每一维都依赖于之前的维度。因此,观察 的概率只依赖于 ,因此这些条件概率的乘积给出了整个序列的概率。

Lilian Weng 的博客 (lilianweng.github.io/posts/2018-10-13-flow-models/) 提供了关于基于流的模型的非常好的描述。

数据生成的扩散模型

2021 年,OpenAI 的两位研究科学家 Prafulla Dhariwal 和 Alex Nichol 发表的论文 扩散模型在图像合成上超越 GAN 引起了对扩散模型用于数据生成的极大关注。

使用Frechet Inception DistanceFID)作为生成图像评估的度量标准,他们在一个基于 ImageNet 数据训练的扩散模型上达到了 3.85 的 FID 分数:

自动生成的动物拼图

图 9.28:从 ImageNet 生成的图像的选定样本(FID 3.85)。图像来源:Dhariwal, Prafulla 和 Alexander Nichol. “扩散模型在图像合成上超越 GAN。” 神经信息处理系统进展 34(2021)

扩散模型背后的想法非常简单。我们从输入图像 开始,在每一个时间步(正向步)中向其添加高斯噪声(噪声扩散),使得在 步后,原始图像无法再被解读。然后找到一个模型,它可以从一个噪声输入开始,执行反向扩散并生成清晰图像:

自动生成的图示

图 9.29:作为马尔可夫链的图模型,用于正向和反向扩散过程

唯一的问题是,虽然可以使用重新参数化技巧来获得条件概率 ,但反向条件概率 是未知的。我们训练一个神经网络模型 来*似这些条件概率。以下是 Ho 等人(2020 年)在他们的 去噪扩散概率模型 论文中使用的训练和采样算法:

算法 1 训练 算法 2 采样
1. 重复 2. 3. 4. 5. 在 上执行梯度下降步骤 6. 直到 收敛 1. 2. 对于 t = T, ..., 1 执行 3. 4. 5. 结束 6. 返回 x[0]

表 9.1:Ho 等人(2020 年)使用的训练和采样步骤

扩散模型提供了可处理性和灵活性这两个相互矛盾的目标,这在生成模型中是非常难得的。然而,它们依赖于一长串的扩散步骤的马尔可夫链,因此计算开销较大。扩散模型正受到广泛关注,我们希望在不久的将来会出现能够像 GAN 一样快速采样的算法。

总结

本章探讨了我们这个时代最令人兴奋的深度神经网络之一:GAN。与判别网络不同,GAN 有能力基于输入空间的概率分布生成图像。我们从 Ian Goodfellow 提出的第一个 GAN 模型开始,并用它生成手写数字。接着,我们介绍了 DCGAN,其中使用卷积神经网络生成图像,我们看到了由 DCGAN 生成的名人肖像、卧室甚至专辑封面等令人惊叹的图片。最后,本章深入探讨了一些令人惊叹的 GAN 架构:SRGAN、CycleGAN、InfoGAN 和 StyleGAN。本章还包括了在 TensorFlow 2.0 中实现 CycleGAN 的代码。

在这一章以及之前的章节中,我们一直在继续探讨不同的无监督学*模型,其中自编码器和 GAN 是自监督学*的例子;下一章将进一步详细阐述自监督学*、联合学*和对比学*之间的区别。

参考文献

  1. Goodfellow, Ian J. (2014). 生成模型估计的可区分性标准. arXiv 预印本 arXiv:1412.6515: arxiv.org/pdf/1412.6515.pdf

  2. Dumoulin, Vincent, 和 Visin, Francesco. (2016). 深度学*的卷积算术指南. arXiv 预印本 arXiv:1603.07285: arxiv.org/abs/1603.07285

  3. Salimans, Tim 等人. (2016). 改进的 GAN 训练技术. 神经信息处理系统进展: papers.nips.cc/paper/6125-improved-techniques-for-training-gans.pdf

  4. Johnson, Justin, Alahi, Alexandre, 和 Fei-Fei, Li. (2016). 实时风格迁移与超分辨率的感知损失. 欧洲计算机视觉会议. Springer, Cham: arxiv.org/abs/1603.08155

  5. Radford, Alec, Metz, Luke., 和 Chintala, Soumith. (2015). 使用深度卷积生成对抗网络进行无监督表示学*. arXiv 预印本 arXiv:1511.06434: arxiv.org/abs/1511.06434

  6. Ledig, Christian, 等人。(2017)。使用生成对抗网络进行逼真的单幅图像超分辨率。IEEE 计算机视觉与模式识别会议论文集:openaccess.thecvf.com/content_cvpr_2017/papers/Ledig_Photo-Realistic_Single_Image_CVPR_2017_paper.pdf

  7. Zhu, Jun-Yan, 等人。(2017)。使用循环一致对抗网络进行未配对的图像到图像翻译。IEEE 国际计算机视觉会议论文集:openaccess.thecvf.com/content_ICCV_2017/papers/Zhu_Unpaired_Image-To-Image_Translation_ICCV_2017_paper.pdf

  8. Karras, Tero, Laine, Samuli 和 Aila, Timo。(2019)。一种基于风格的生成对抗网络生成器架构。IEEE/CVF 计算机视觉与模式识别会议论文集,页码 4401-4410。

  9. Chen, Xi, 等人。(2016)。InfoGAN:通过信息最大化生成对抗网络进行可解释的表示学*。神经信息处理系统进展:arxiv.org/abs/1606.03657

  10. StyleGAN 的 TensorFlow 实现:github.com/NVlabs/stylegan

加入我们书籍的 Discord 社区

加入我们的 Discord 社区,和志同道合的人一起学*,和超过 2000 名成员一起成长,链接:packt.link/keras

第十章:自监督学*

想象一下你处于海洋中央,渴得无法忍受。周围是水,但你不能喝其中任何一滴。但是,如果你有能力将水中的盐分煮掉,使它变得可以饮用呢?当然,这个过程的能源成本可能相当高,因此你可能会适度使用这一过程。然而,如果你的能源成本变得几乎为零,例如,你利用太阳能,这个过程可能会变得更具吸引力,甚至可以在更大规模上进行。

在我们上面描述的这个较为简化的情况中,第一个场景大致类似于监督学*,而第二个场景则与我们将在本章中讲解的无监督/半监督学*技术类别相似。监督学*技术的最大问题是与收集标签训练数据相关的时间和费用。因此,带标签的数据集通常相对较小。

深度学*在计算与手动特征工程之间进行权衡,虽然这种方法非常有效,但深度学*模型通常需要比传统(非深度学*)模型更多的数据来进行训练。深度学*模型通常更加复杂,具有更多可学*的参数,这使得它们在各种任务中表现得更好。然而,更复杂的模型也需要更多的数据来训练。由于训练数据的创建成本较高,这实际上限制了我们使用监督学*来扩展深度学*模型。

不幸的是,完全不需要标签数据的无监督学*技术至今尚未取得太大成功。自监督技术通过利用野外数据的结构来创建标签数据,以供监督学*模型使用,从而提供了一种折衷方案。在本章中,我们将学*各种自监督技术及其在自然语言处理、计算机视觉和音频信号处理等领域的应用。

本章将涵盖以下主题:

  • 之前的工作

  • 自监督学*

  • 自预测

  • 对比学*

  • 前置任务

本章的所有代码文件可以在 https://packt.link/dltfchp10 找到

自监督学*是通过富有创意地重新利用数据中已经隐含存在的标签的过程。在本章中,我们将学*一些常见的自监督学*策略,并举例说明它们在解决现实问题中的应用。让我们开始吧。

之前的工作

自监督学*并不是一个新概念。然而,随着基于变换器的模型(如 BERT 和 GPT-2)的出现,这一术语变得广为人知,这些模型通过半监督的方式在大量未标记的文本上进行训练。过去,自监督学*通常被归类为无监督学*。然而,早期有许多模型尝试利用输入数据中的规律性来生成与使用监督学*相当的结果。你在前面的章节中已经接触过其中的一些,但我们将在本节中简要介绍它们。

限制玻尔兹曼机RBM)是一个生成神经网络模型,可以学*输入的概率分布。它于 1986 年发明,并在 2000 年代中期得到了改进。它可以在监督或无监督模式下进行训练,并可以应用于许多下游任务,如降维、分类等。

自编码器AEs)是无监督学*模型,旨在通过学*重建输入数据,来学*其有效的潜在表示。潜在表示可用于对输入进行编码,以便用于下游任务。该模型有多种变体。稀疏、自噪声和对比 AEs 在学*下游分类任务的表示方面非常有效,而变分 AEs 作为生成模型更为有用。

Word2Vec 模型是另一个很好的例子,我们现在称之为自监督学*。CBOW 和 skip-gram 模型用于构建词语的潜在表示,分别尝试学*邻居到词语和词语到邻居的映射。然而,潜在表示现在可以作为词嵌入,应用于各种下游任务。同样,GloVe 模型也是一个自监督模型,利用词汇共现和矩阵分解来生成用于下游任务的词嵌入。

自回归AR)模型根据过去的行为预测未来的行为。我们在本章的自我预测部分进行了讨论。然而,AR 模型源自统计学中的时间序列分析,来自神经(但在变换器之前的)自然语言处理中的隐马尔可夫模型,以及递归神经网络RNNs)。

对比学*CL)模型试图学*表示,使得相似的项目对聚集在一起,而不相似的项目对被推得很远。

本章的对比学*部分也介绍了 CL 模型。然而,自组织映射SOMs)和孪生网络使用非常相似的思想,可能是当前 CL 模型的前身。

自监督学*

在自监督学*中,网络通过监督学*进行训练,但标签是通过利用数据的某些属性以自动化方式获得的,而无需人工标注。通常,这种自动化是通过利用数据样本的不同部分如何相互作用并学*预测这种关系来实现的。换句话说,数据本身为学*过程提供了监督。

一类技术涉及利用同一数据样本内部的共现或在不同时间点的同一数据样本之间的共现。这些技术将在自预测部分中更详细地讨论。

另一类技术涉及利用给定数据样本中的共现模态,例如文本与其相关的音频流,或图像与其标题之间的关系。此技术的示例将在联合学*部分讨论。

另一类自监督学*技术涉及利用数据样本对之间的关系。这些对是根据某些领域级启发式从数据集中选择的。这些技术的示例在对比学*部分中有详细介绍。

这些技术可以直接用来训练模型解决业务任务(例如情感分析、分类等),也可以用来学*数据的潜在(嵌入)表示,然后用于生成特征,学*解决下游业务任务。后一类任务是用来间接学*数据的潜在表示的,称为前置任务。前置任务部分将更详细地讨论这一主题,并提供示例。

自监督学*的优势有两个方面。首先,正如前面所提到的,监督学*涉及数据的人工标注,这种标注过程成本非常高,因此很难获得高质量的标注数据。其次,自监督任务可能不会直接解决业务任务,但可以用来学*数据的良好表示,然后可以将此信息转移到实际的下游业务任务中。

自预测

自预测的思路是给定数据样本的一部分,预测另一部分。为了进行预测,我们假装要预测的部分是隐藏的或缺失的,并学*如何预测它。显然,两个部分都是已知的,而要预测的部分作为数据标签。模型以监督的方式进行训练,使用非隐藏部分作为输入,隐藏部分作为标签,学*准确地预测隐藏部分。本质上,就是假装输入的某个部分是未知的,并进行预测。

这个思路也可以扩展到反向管道,例如故意向图像中添加噪声,并使用原始图像作为标签,将损坏的图像作为输入。

自回归生成

自回归AR)模型试图根据过去的事件、行为或特性来预测未来的事件、行为或特性。任何具有固有顺序的数据都可以使用 AR 生成进行建模。与变分自编码器(VAE)或生成对抗网络(GAN)等潜在变量模型不同,AR 模型不假设独立性。

PixelRNN

PixelRNN [1] AR 模型使用二维循环神经网络RNNs)对图像进行大规模建模。其思想是通过依赖左边和上方所有像素来学*生成一个像素。通过卷积操作可以一次性计算每个维度上的所有状态。PixelRNN 中使用的 LSTM 层有两种类型——行 LSTM 和对角双向 LSTM。在行 LSTM 中,卷积操作沿每一行应用;而在对角双向 LSTM 中,卷积操作则沿图像的对角线应用:

图 10.1:PixelRNN 通过依赖左侧和上方的所有像素来预测一个像素。来自论文《像素递归神经网络》[1]

图像 GPT(IPT)

图像 GPT(IPT)[14]与 PixelRNN 类似,只是它在图像块上工作,并且每个图像块被视为一个词。图像 GPT 基于变换器模型,使用 ImageNet 数据集上的图像进行训练。这些图像通过多种方式(超分辨率、双三次插值、添加噪声等)被损坏,然后进行预训练以预测原始图像。IPT 模型的核心包括变换器的编码器-解码器对,但具有多个头部和尾部,分别用于从损坏的输入图像中提取特征,并将解码器输出格式化为输出图像。这些多个头部和尾部专门用于 IPT 训练的不同任务(去噪、去雨、x2 和 x4 超分辨率等):

图 10.2:图像 GPT(IPT)AR 模型的架构。来自论文《预训练图像处理变换器》[14]

GPT-3

GPT-3(或称生成预训练变换器)[9]是 OpenAI 推出的一种自回归(AR)语言模型,能够生成类似人类的文本。它根据人类提供的提示生成单词、代码和其他数据的序列。GPT 的第一个版本使用了 1.1 亿个学*参数,GPT-2 使用了 15 亿个,GPT-3 使用了 1750 亿个参数。该模型使用了互联网上现成的无标签文本进行训练,如维基百科,最初是英文的,后来也包括了其他语言。GPT-3 模型有广泛的应用场景,包括摘要生成、翻译、语法修正、问答、聊天机器人和电子邮件写作等。

GPT-3 的流行催生了一种新职业,称为提示工程 [39],其基本任务是为 GPT-3 创建最有效的提示,以便启动它执行各种任务。GPT-3 的一些可能应用可以在 OpenAI GPT-3 示例页面找到(beta.openai.com/examples/)。

XLNet

XLNet [38] 类似于 GPT-3,都是一种广义的自回归模型。然而,它同时利用了自回归语言建模和自编码,并避免了它们的局限性。它不是仅使用来自左侧或右侧上下文的标记来预测下一个标记,而是使用左侧和右侧上下文中所有可能的标记排列,从而使用来自左右两侧上下文的标记进行预测。其次,不同于像 BERT 这样的自编码方法,它不依赖于输入的损坏(如掩码语言建模中那样),因为它是一个广义的自回归语言模型。经验上,在可比较的实验设置下,XLNet 在广泛的任务上始终优于 BERT。

WaveNet

WaveNet [3] 是一种基于 PixelCNN 架构的自回归生成模型,但它在原始音频波形上进行操作。与 PixelCNN 一样,WaveNet 在特定时间点的音频样本依赖于所有前一个时间步的样本。条件概率分布被建模为一堆卷积层。WaveNet 的核心成分是因果卷积。模型在某一时间步产生的预测不能依赖于任何未来的时间步。应用于语音合成时,WaveNet 展现出最先进的性能,人工听众评定其在英语和普通话语音合成方面,比其他类似的文本到语音模型自然得多。

WaveRNN

WaveRNN [28] 是一种自回归生成模型,通过将数据的分布分解为每个样本的条件概率的乘积来学*数据的联合概率。WaveNet 架构中的卷积层被单层 RNN 替代。它还使用了更高效的采样技术,整体上减少了需要执行的操作次数,并使得 WaveRNN 比 WaveNet 提速约 4 倍。

掩码生成

掩码生成模型会随机遮蔽部分自身,并假装这些部分是缺失的,模型通过利用未遮蔽的信息来预测这些遮蔽的信息。与自回归模型不同,掩码生成模型中,遮蔽的信息不需要位于未遮蔽信息的前后,它可以出现在输入的任何位置。

BERT

BERT [16],即双向编码器表示模型,是一种基于 Transformer 的语言模型,由谷歌的团队使用互联网文本进行训练。在预训练阶段,BERT 使用两个目标——掩码语言建模MLM)和下一个句子预测NSP)。在训练过程中,15%的输入标记会被掩码,模型需要学*预测被掩码的标记。由于 BERT 是基于 Transformer 的,它可以使用句子中任何位置的上下文信息来帮助预测被掩码的标记。BERT 模型在预训练完成后,可以通过较小的有监督数据集进行微调,用于各种下游任务,如分类、情感分析、文本蕴含等。BERT将在第六章Transformer中详细介绍。

[MASK] in the sentence "The capital of France is [MASK].":
from transformers import BertTokenizer, TFBertForMaskedLM
import tensorflow as tf
tokenizer = BertTokenizer.from_pretrained("bert-base-cased")
model = TFBertForMaskedLM.from_pretrained("bert-base-cased")
inputs = tokenizer("The capital of France is [MASK].", return_tensors="tf")
logits = model(**inputs).logits
mask_token_index = tf.where(inputs.input_ids == tokenizer.mask_token_id)[0][1]
predicted_token_id = tf.math.argmax(logits[:, mask_token_index], axis=-1)
print(tokenizer.convert_ids_to_tokens(predicted_token_id)[0]) 

有些时候可以预测,这段代码块的输出是"Paris"

堆叠去噪自编码器

堆叠去噪自编码器(AE)[29]向图像中添加随机噪声,并使用它们作为去噪自编码器的输入,来预测原始图像。多个去噪自编码器层分别进行训练并堆叠。这样形成了多个非线性组合,并且是实现困难图像识别任务更好泛化性能的关键。通过这种纯无监督方式学*的更高层次的表示,可以作为图像特征,提升下游基于 SVM 的图像分类器的性能。每一层的功能类似于普通的自编码器,即它以图像为输入,并在经过一个“瓶颈”层后尝试重建图像。瓶颈层学*输入图像的紧凑特征表示。不幸的是,自编码器通常只会学*如何压缩图像,而不会学*语义上有意义的表示。去噪自编码器通过破坏输入并要求网络恢复破坏,从而学会更好的语义表示输入图像。

上下文自编码器

上下文自编码器[12]将图像的一个区域进行遮掩,并使用它来训练卷积神经网络(上下文自编码器,Context AE),以回归缺失的像素值,进而预测原始图像。上下文自编码器的任务比去噪自编码器的任务更难,因为它需要填补更大范围的缺失区域,而且不能使用来自相邻像素的信息。这需要对图像有更深层次的语义理解,并且能够在大范围的空间区域上生成高级特征。从某种意义上说,上下文自编码器是一种更强大的生成模型,因为它不仅需要填补缺失的区域,还需要与已提供的上下文保持一致。

因此,上下文自编码器的训练目标是重建损失和对抗损失的结合。这比仅仅训练重建(L2)损失的模型产生更锐利的预测:

图 10.3:上下文编码器任务的定性说明(来自《上下文编码器:通过修复学*特征》[10])

上下文不一定是图像特征,也可以是颜色,正如我们将在下一节看到的那样。

色彩化

论文《色彩化作为视觉理解的代理任务》 [12] 使用色彩化作为学*图像表示的一种方式。彩色图像被转换为其灰度等效图像,然后作为输入来预测原始的彩色图像。该模型可以自动为灰度图像上色,同时学*一种表示方式,能够帮助进行下游任务,如图像分类和分割。从功能上讲,该模型根据L(灰度)通道预测其Lab编码中的ab(颜色信息)通道。本文作者在 ImageNet 数据集上进行的实验结果表明,该模型在没有使用 ImageNet 标签的情况下,能够在语义分割和图像分类数据集上产生最先进的结果,甚至超过一些早期的、在 ImageNet 上使用监督学*训练的模型。

固有关系预测

使用该技术的模型尝试通过利用输入图像各部分之间的固有关系来学*视觉常识任务。这些学*到的模型的权重可以用来为其他下游任务生成图像的语义表示。

相对位置

论文《通过上下文预测进行无监督视觉表示学*》 [8] 预测图像中一个区域相对于另一个区域的相对位置。实际上,这种方法使用空间上下文作为自监督的来源来训练视觉表示。给定一个大型未标记的图像集合,从每张图像中提取出随机的区域对,如图 10.4所示。每对区域根据第二个区域相对于中心区域的方向进行标注。训练一个卷积神经网络来预测第二个区域相对于第一个区域的位置。学*到的特征表示能够捕捉到图像间的视觉相似性概念。利用这种表示,已证明它有助于视觉数据挖掘,即发现描绘相同语义对象的图像片段,在 Pascal VOC 2007 数据集上效果尤为显著:

图 10.4:相对位置预测的说明。模型必须预测第二个区域相对于(中心)第一个区域的配置。来自论文《通过上下文预测进行无监督视觉表示学*》[8]

解谜拼图

论文《通过解拼图实现无监督视觉表征学*》[26]描述了一种与之前的相对位置预测方法相似的方法。该方法尝试通过解决自然图像的拼图来学*图像的视觉表征。首先从输入图像中提取小块并打乱,形成拼图。网络学*从拼图中重建原始图像,即解开拼图。所用的网络是上下文自由网络CFN),一种 n 路并行的孪生网络。每个小块对应于 n 路 CFN 中的一列。每列中的共享层与 AlexNet 中的实现完全相同。分类头预测小块的原始索引(即打乱前的位置)。在 Pascal VOC 数据集上,它在图像分类和物体检测任务中超越了所有先前的自监督模型:

图 10.5:图像被分割成多个小块并打乱,模型学*将这些打乱的小块重新排列回正确的顺序。摘自论文《无监督视觉表征学*》[26]

旋转

RotNet 模型 [34] 通过使用旋转作为自监督信号来学*图像表征。输入图像分别旋转 0、90、180 和 270 度,并训练一个卷积网络(RotNet)来预测旋转角度,作为 4 个目标类之一。事实证明,这个看似简单的任务为语义特征学*提供了一个非常强大的监督信号。RotNet 特征被用作图像分类的输入,针对 CIFAR-10 数据集,分类精度仅比使用监督学*获得的最先进结果低 1.6%。在当时,它还在一些分类任务中取得了 ImageNet 的最先进结果,并且在一些分类和物体检测任务中也取得了 Pascal VOC 的优秀表现。

混合自预测

使用混合自预测模型,利用多个自预测策略实现自预测,而不是只使用一种策略。例如,我们的前两个示例,Jukebox 和 DALL-E,通过首先使用一种自监督技术(VQ-VAE 或向量量化变分自编码器 [35])将输入数据简化为更易处理的格式,然后使用另一种(AR)在简化后的图像上生成最终预测,从而实现自预测。在我们的第三个示例中,VQ-VAE 组件的预测通过使用对抗训练的判别器进一步精细化。

VQ-VAE

由于 VQ-VAE 是我们所有混合自预测模型的共同组成部分,我们可以尝试从高层次理解它的工作原理。你已经在 第八章《自编码器》中阅读过自编码器和变分自编码器。自编码器试图通过首先将输入编码为更小的维度,然后解码该较小维度的输出,来学*重构其输入。然而,自编码器通常最终只是压缩输入,而未能学*到好的语义表示。

变分自编码器VAE)在这方面可以做得更好,因为它通过强制使用概率先验,通常采用标准高斯分布的形式,并通过最小化重构损失以及先验分布与后验分布(潜在空间中的实际分布)之间的 KL 散度来进行优化。

虽然 VAE 学*的是连续的潜在分布,但 VQ-VAE 学*的是离散的潜在分布。这是有用的,因为 transformers 被设计为接受离散数据作为输入。VQ-VAE 通过向网络中添加一个离散代码本组件来扩展 VAE,该组件用于通过选择与每个潜在向量在欧几里得距离上最接*的代码本向量来量化编码器输出的潜在向量。VQ-VAE 解码器的任务是从离散化的潜在向量重构输入。

Jukebox

我们的第一个示例是 Jukebox 论文[32],这是一个音乐生成模型,类似于 GPT-3 是一个文本生成模型,Image-GPT 是一个图像生成模型。也就是说,给定一个音乐(声音和音乐)提示,Jukebox 可以生成可能跟随此提示的音乐。早期的音频生成模型尝试通过钢琴卷轴的形式生成符号化的音乐,因为直接生成原始音频的问题在于其包含的信息量极大,因此需要建模极长的依赖关系。VQ-VAE 通过学*音频的低维编码来解决这个问题,目的是尽量减少丢失不重要的信息,同时保留大部分有用信息。

Jukebox 使用层次化的 VQ-VAE 将输入信号离散化为不同的时间分辨率,然后在每个分辨率下生成一个新的序列,最后将每个层级生成的序列组合成最终的预测。

DALL-E

我们的第二个混合预测模型示例是 OpenAI 的 DALL-E 模型[5]。DALL-E 也可以归类为联合学*(多模态)模型,因为它试图从文本描述中学*生成图像,使用文本和图像的配对作为训练输入。然而,我们在这里将其归类为混合预测模型,因为与 Jukebox 一样,它试图通过使用 VQ-VAE 来解决图像信息的高维度问题(与相关文本的维度相比)。

DALL-E 将文本和图像作为单一数据流接收。DALL-E 使用了两阶段的训练方案。在第一阶段,训练了一个 VQ-VAE 将每个输入的大小为 (256, 256, 3) 的 RGB 图像压缩成大小为 (32, 32) 的图像令牌网格,其中的每个元素可以取 8,192 个可能的离散值之一。这使得图像输入的大小减小了 192 倍,而图像质量没有相应的损失。

在第二阶段,文本被 BPE 编码并截断为 256 个令牌。字节对编码BPE)是一种混合字符/词编码,可以使用相对较小的词汇表来表示大语料库,通过编码常见的字节对。然后,此编码与扁平化的 1,024 个(32 x 32)图像令牌序列串联。使用这个联合序列来训练自回归变换器,以建模文本和图像令牌的联合分布。第一阶段学*了 VQ-VAE 中的视觉码书,第二阶段学*了文本和图像令牌离散潜在分布的先验。训练好的 DALL-E 模型可以根据文本提示生成图像。

文本生成图像正变得相当流行。最*,OpenAI 发布了名为 DALL-E 2 的更新版本,它拥有 350 亿个参数,而原版 DALL-E 只有 120 亿个参数。尽管它们的命名相似,DALL-E 是 GPT-3 的一个版本,专门用于根据文本描述生成图像,而 DALL-E 2 则是一个编码器-解码器管道,使用 CLIP 将文本描述编码为 CLIP 嵌入,然后使用你在《第九章》《生成模型》中学到的扩散模型将嵌入解码为图像。不出所料,DALL-E 2 生成的图像比 DALL-E 更真实、更准确。

更*期,Google Research 发布了 Imagen,这是这一领域的另一个与 DALL-E 2 竞争的模型。与 DALL-E 2 类似,Imagen 使用了 T5-XXL 编码器将输入文本映射到嵌入,并使用扩散模型将嵌入解码为图像。

VQ-GAN

VQ-GAN [30] 使用了一个编码器-解码器框架,其中编码器使用了 VQ-VAE 风格的编码器,学*了一个离散潜在表示,但解码器是一个 生成对抗网络GAN)的鉴别器组件。VQ-GAN 不使用 VQ-VAE 中使用的 L2 损失,而是使用感知损失和鉴别器损失的组合,这有助于在增加压缩率时保持良好的感知质量。与传统的 VAE 解码器相比,使用 GAN 结构有助于训练效率。

类似于 VQ-VAE,VQ-GAN 学*了一个上下文丰富的视觉组件的码书,这些组件用于组合序列以训练自回归组件。使用 Fréchet Inception DistanceFID)指标发现,VQ-GAN 在使用 ImageNet 图像上优于 VQ-VAE-2 模型,尽管它使用的参数大约少了 10 倍:

图 10.6:VQ-GAN 架构。来自论文:《驯服变换器以进行高分辨率图像合成》[30]

接下来,我们将了解另一种流行的自监督技术——对比学*。

对比学*

对比学*CL)试图预测一对输入样本之间的关系。CL 的目标是学*一个嵌入空间,使得相似的样本对被拉*,而不相似的样本对被推远。训练 CL 模型的输入是以数据点对的形式出现的。CL 可以在有监督和无监督的设置中使用。

在无监督设置中使用时,它可以成为一种非常强大的自监督学*方法。通过自监督的方式从现有数据中找到相似对,从相似对的数据对中找到不相似的对。模型学*预测一对数据点是否相似或不同。

可以通过考虑用于生成对比样本的技术来推导出 CL 的分类法。在我们这么做之前,我们将简单地探索一下在 CL 中流行的各种训练目标。

训练目标

早期的 CL 模型使用由一个正样本和一个负样本组成的数据点来学*。然而,最*的 CL 模型趋势是从单个批次中的多个正负样本中学*。在本节中,我们将介绍一些常用于训练 CL 模型的训练目标(也叫损失函数)。

对比损失

对比损失[35]是使用 CL 技术进行学*的最早训练目标之一。它试图将数据编码到嵌入空间中,使得同一类别的样本具有相似的嵌入,而不同类别的样本具有不相似的嵌入。因此,给定两个数据对(x[i], y[i])和(x[j], y[j]),对比损失目标可以通过以下公式来描述:

当对数据对i 和j相似时,第一个项被激活,当数据对不相似时,第二个项被激活。目标是最大化第一个项中的差异的平方,并最小化第二项中的差异的平方(从而在不相似的样本对情况下最大化第二项)。!是一个超参数,表示不同类别样本之间允许的最小距离的边界。

三元组损失

三元组损失 [11] 是对比损失的增强版,它使用三个数据点而不是两个——锚点、正样本和负样本。因此,给定一个锚点 x,我们选择一个正样本 和一个负样本 ,其中 x 属于同一类,而 x 属于不同类。三元组损失学*最小化锚点 x 与正样本 之间的距离,并最大化 x 与负样本 之间的距离。这个过程在 图 10.7 中有示意图:

示意图 说明自动生成

图 10.7:三元组损失的示意图。基于论文:FaceNet: A Unified Embedding for Face Recognition and Clustering [11]

三元组损失的公式如下所示。与对比损失一样, 是一个超参数,表示相似对与不相似对之间的最小允许差异。基于三元组损失的模型通常需要具有挑战性的值,如 ,即所谓的硬负样本,以提供良好的表示:

N-pair 损失

N-pair 损失 [21] 是三元组损失的泛化,它将与多个负样本的比较引入,而不仅仅是与一个负样本进行比较。因此,给定一个 (N+1) 元组的训练样本,{x, x^+, x[1]^-, x[2]^-, …, x[N+1]^-},其中有一个正样本和 N-1 个负样本,N-pair 损失使用以下公式定义:

提升的结构损失

提升的结构损失 [15] 是三元组损失的另一种泛化,它使用训练批次中的所有成对边。这可以带来更好的训练效果。图 10.8 说明了提升结构损失的原理,以及它如何从对比损失和三元组损失演变而来。红色边连接相似的对,蓝色边连接不相似的对:

图表、图示、示意图 说明自动生成

图 10.8:提升结构损失的原理示意图。基于论文:Deep Metric Learning via Lifted Structured Feature Embedding [15]

NCE 损失

噪声对比估计 (NCE) 损失 [27] 使用逻辑回归来区分正例和负例(噪声)。NCE 损失试图最大化正例 x 的对数几率(logits),并最小化负例的对数几率 。NCE 损失的公式如下所示:

InfoNCE 损失

InfoNCE 损失[2]受到 NCE 损失(在前一节中描述)的启发,使用分类交叉熵损失从无关噪声样本的集合中识别正样本。给定一些上下文向量c,正样本应从条件概率分布p(x|c)中抽取,而N-1个负样本则可以从与上下文c无关的分布p(x)中抽取。InfoNCE 损失优化的是正确分类正样本的负对数概率。

InfoNCE 损失通过以下方程给出,其中f(x, c)估计密度比p(x|c) / p(x)

Soft 最*邻损失

Soft 最*邻损失[33]进一步扩展了对比损失的思想,包含了已知标签下的多个正样本。给定一批样本,,其中y[i]是x[i]的类别标签,以及一个相似性函数f,该函数衡量两个输入之间的相似性,Soft 最*邻损失由以下方程给出:

温度是一个超参数,用于调整表示空间中特征的集中程度。因此,在低温度下,表示空间中远离点对 Soft 最*邻损失的贡献也较低。

实例转换

使用实例转换的 CL 模型通常依赖数据增强技术生成正样本对,并通过负样本挖掘技术从正样本对中生成负样本对。许多这样的模型依赖于生成批次内的负样本和创新的硬负样本挖掘技术。

数据增强技术用于创建原始数据点及其噪声版本的样本对。这将非本质的变化引入样本中,而不修改语义意义,模型随后在训练过程中学*这些变化。

批内负采样是一种通过结合单个批次内示例的信息来生成负样本的技术。对于批次中的每个正样本对(x[i], y[i]),所有对(x[i], y[j])和(x[j], y[i])都可以视为负样本对。实际上,负样本对是通过在同一批次内将两个随机正样本对的元素组合来创建的。该技术实用且能够在 GPU 上高效实现,因此广泛使用。

一些模型需要硬负样本来学*如何执行任务。硬负样本是标签不同但嵌入特征非常接*的样本对。你可以将它们视为在嵌入空间中非常接*但位于决策边界两侧的点。对于有监督学*,识别硬负样本相对简单。对于无监督学*,一种方法是增加批量大小,从而引入更多硬负样本。另一种技术[19]是根据候选负样本与锚点样本的相似性来增加其采样概率。

SimCLR

SimCLR 模型[36]提供了一个用于对比学*视觉表征的简单框架。每个输入图像(x)通过使用相同的图像增强策略家族,以两种不同的方式(x[i]和x[j])进行增强,从而产生2N个正样本。

使用批内负样本采样,因此对于每个正样本,我们有(2N-1)个负样本。对于每个示例中的数据点对,应用一个基础编码器(f),然后一个投影头(g)尝试最大化正样本对的相似度,并最小化负样本对的相似度。为了获得良好的性能,SimCLR 需要使用大批量大小,以便在训练过程中包含足够的负样本。SimCLR 在 ImageNet 上实现了自监督和半监督模型的最新成果,并与监督学*的 ResNet-50 表现相匹配。图 10.9展示了 SimCLR 模型的架构:

图示 说明自动生成

图 10.9:SimCLR 模型的架构。摘自论文:A Simple Framework for Contrastive Learning of Visual Representations [36]

Barlow Twins

Barlow Twins 模型[20]的思想源自神经科学,即感知处理的目标是将高度冗余的感官输入重新编码为因子化代码,或具有统计独立成分的代码。在此模型中,图像被扭曲成两个版本。将这两个扭曲版本输入相同的网络以提取特征,并学*使这两个特征之间的交叉相关矩阵尽可能接*单位矩阵。与神经科学的理念一致,该模型的目标是通过减少这些向量之间的冗余,来减少样本的两个扭曲版本之间的冗余。这体现在其独特的损失函数中——在第一个公式中,第一项表示身份矩阵与交叉相关矩阵之间的差异,第二项表示冗余减少项。第二个公式定义了交叉相关矩阵C的每个元素:

Barlow Twins 模型与该领域其他模型的一些显著区别在于,Barlow Twins 模型不需要大量负样本,因此可以在较小的批次上运行,并且受益于高维嵌入。Barlow Twins 模型在 ImageNet 上超越了一些先前的半监督模型,并与一些监督学*的 ImageNet 模型持平。

BYOL

Bootstrap Your Own LatentBYOL)模型 [17] 的独特之处在于它完全不使用负样本。它依赖于两个相互作用并彼此学*的神经网络——在线网络和目标网络。BYOL 的目标是学*一个可以用于下游任务的表示 。在线网络由一组权重 参数化,并包括三个阶段——编码器 、投影器 和预测器 。目标网络的架构与在线网络相同,但使用一组不同的权重 。目标网络提供回归目标来训练在线网络,其参数 是在线网络参数 的指数移动平均。在每次训练步骤之后,执行以下更新:

BYOL 为每个图像生成两个增强视图。对于第一个增强视图,在线网络输出一个表示 和一个投影 。同样,目标网络输出一个表示 和一个投影 。BYOL 尝试最小化 L2 归一化的在线投影和目标投影之间的误差 。在训练结束时,我们只保留在线网络(编码器)。

BYOL 在 ImageNet 上与半监督或迁移学*模型相比,取得了竞争性的结果。与该领域的其他模型相比,它对批量大小和图像增强类型的变化不太敏感。然而,后续研究 [4] 表明,BYOL 中的批归一化组件可能通过隐式创建负样本,作为数据重分布的结果,从而隐式地引发了一种对比学*形式。

特征聚类

特征聚类涉及通过聚类找到相似的数据样本。当数据增强技术不可行时,这非常有用。这里的想法是使用聚类算法为样本分配伪标签,以便我们可以进行样本内对比学*(CL)。尽管相似,特征聚类与对比学*不同,它放宽了实例区分问题——与其学*区分单一输入图像的多个变换,特征聚类则学*区分具有相似特征的图像组。

DeepCluster

DeepCluster [24]论文的前提是,像 ImageNet 这样的监督学*数据集“过于小”,无法考虑超出图像分类的一般目的特征。为了学*一般目的特征,需要在互联网规模的数十亿图像上进行训练。然而,标注如此大规模的数据集并不可行,因此 DeepCluster 提出了一种聚类方法,联合学*神经网络的参数和生成特征的聚类分配。DeepCluster 通过 K-Means 聚类算法迭代地对这些特征进行分组,并使用聚类分配作为伪标签来学* ConvNet 的参数。训练的最终产物是 ConvNet 的权重。这些权重已被证明是有用的通用视觉特征,并且在许多下游任务中超越了在不同数据集上发布的最佳结果。

SwAV

SwAVSWapping Assignments between multiple Views)[25]模型通过预测来自另一个视图的表示来学*特征,从而预测某一视图的聚类分配(伪标签)。SwAV 使用了与 CL 模型中相似的架构变体。图像x[1]和x[2]是同一输入图像x的变换,经过编码器 生成表示z[1]和z[2]。在 SwAV 的情况下,z[1]和z[2]用于通过将其特征与一组K原型向量{c[1], …, c[K]}进行匹配,从而计算q[1]和q[2],这些向量随后用于分别预测x[2]和x[1]的聚类分配。

与 DeepCluster 不同,SwAV 进行在线聚类(即对持续流式到达的数据进行聚类,这些数据在聚类过程开始之前并不为人所知),因此能够扩展到潜在的无限数据量。SwAV 还适用于大批量和小批量大小。SwAV 论文还提出了一种新的多裁剪策略,可以在没有计算或内存开销的情况下增加图像的视图数量。它在 ImageNet 上使用 ResNet50(监督学*方法)取得了 75%的 Top-1 准确率,并且在所有考虑的迁移任务中超越了监督预训练的结果。

InterCLR

InterCLR [18]是一个混合模型,通过利用图像内和图像间的不变性来共同学*视觉表示。它在其流程中有两个不变性学*分支,一个用于图像内,一个用于图像间。图像内分支通过标准 CL 方法构造对比对,例如从输入图像生成一对变换。图像间分支使用通过聚类获得的伪标签构造对比对——同一聚类中的两个项构成正对,来自不同聚类的两项构成负对。

使用 InfoNCE 损失函数的变体来计算对比损失,并通过反向传播训练网络:

图示  描述自动生成

图 10.10:InterCLR 模型的架构。摘自论文:深入探讨无监督视觉表示的图像间不变性 [18]

InterCLR 论文还讨论了伪标签维护、采样策略和图像间分支的决策边界设计等一些特别考虑因素,但为了节省篇幅,我们在此略过。InterCLR 模型在多个标准基准测试中,相较于现有的图像内不变性学*方法,展示了许多改进。

多视图编码

多视图编码*年来已成为主流的 CL 方法,涉及使用同一物体的两个或多个视角构建正对比样本。目标是最大化多个视角的表示之间的互信息,对于正例,负例则最小化这一互信息。这要求模型学*跨越多个视角的高阶特征。

AMDIM

增强多尺度深度信息最大化AMDIM)[31] 是一个基于早期局部深度信息最大化方法的自监督表征学*模型,旨在最大化一个全局摘要特征与一组从编码器中间层提取的局部特征之间的互信息。全局摘要特征依赖于整个输入,局部特征则来自中间层。AMDIM 通过预测每个输入的独立增强特征,以及跨多个尺度的特征,扩展了 DIM,并使用了更强大的编码器。

论文还考虑了生成对比对的其他方法,如实例变换和多模态(将在下一节讨论),但在此描述是因为它也考虑了使用多视图编码构建对比对。这一模型在多个自监督学*目标的基准测试中超越了若干标准。

CMC

对比多视图编码CMC)[37] 模型基于这样一个理念:当一个物体由多个视角表示时,这些视角每一个都带有噪声且不完整,但物体的物理、几何和语义等重要因素通常在所有视角中是共享的。CMC 的目标是学*物体的紧凑表示,捕捉这些重要因素。CMC 通过使用 CL 来学*一个表示,使得同一场景的视角映射到相*的点,而不同场景的视角则映射到远离的点。

多模态模型

本节涵盖的模型类别包括使用来自两个或更多模态的配对输入的模型。这些模型的输入可以是图像和字幕、视频和文本、音频片段及其转录本等。这些模型学*跨多个模态的联合嵌入。在这一类模型中,我们将以 CLIP [6] 和 CodeSearchNet [13] 模型为例进行讲解。

另一类多模态模型是可以用于跨多个模态进行自监督学*的框架。Data2Vec [7] 模型就是这样一个模型的例子。

CLIP

CLIP 模型 [6] 通过学*预测哪些图像与哪些字幕匹配来学*图像表示。它在互联网上预训练了 4 亿对图像-文本数据。预训练之后,该模型可以使用自然语言查询来引用已学*的视觉概念。CLIP 可以在零-shot 模式下用于下游任务,如图像分类、文本到图像生成以及图像到图像的搜索。该模型在自然图像的表现上与完全监督基准具有竞争力,且无需额外的微调。例如,CLIP 在零-shot 模式下可以与原始 ResNet50 在 ImageNet 上的准确度相匹配,即无需额外微调。CLIP 还可以通过使用特定的图像数据集进行微调,来应对特定的下游任务,例如学*卫星图像的视觉表示或肿瘤检测。

图 10.11 展示了 CLIP 模型的训练与推理架构。图像和文本编码器都是基于 Transformer 的编码器。预训练的目标是解决预测哪些文本与哪些图像整体匹配的任务。因此,给定一批 N 对图像-文本对,CLIP 学*预测在 N x N 的所有可能图像-文本对中,实际发生的图像-文本对是哪一对。CLIP 通过最大化批次中 N 对真实图像-文本嵌入的余弦相似度,同时最小化剩余的 N² - N 错误对的余弦相似度,来学*一个多模态联合嵌入空间。

在推理时,一个模态的输入可以用来预测另一个模态的输出,即,给定一个图像,它可以预测该图像的类别为文本:

图 10.11:CLIP 模型的架构。来自论文:《从自然语言监督中学*可迁移的视觉模型》 [34x]

 the CLIP model’s ability to compare images and text. Here, we take an image of two cats side by side and compare it to two text strings: "a photo of a cat" and "a photo of a dog". CLIP can compare the image with the two text strings and correctly determine that the probability that the image is similar to the string "a photo of a cat" is 0.995 as opposed to a probability of 0.005 for the image being similar to the string "a photo of a dog":
import tensorflow as tf
from PIL import Image
import requests
from transformers import CLIPProcessor, TFCLIPModel
model = TFCLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
url = "http://images.cocodataset.org/val2017/000000039769.jpg"
image = Image.open(requests.get(url, stream=True).raw)
texts = ["a photo of a cat", "a photo of a dog"]
inputs = processor(text=texts, images=image, return_tensors="tf", padding=True)
outputs = model(**inputs)
logits_per_image = outputs.logits_per_image
probs = tf.nn.softmax(logits_per_image, axis=1)
print(probs.numpy()) 

CLIP 模型通过将文本和图像投影到单一的嵌入空间来实现这一点。通过这种共同嵌入的方法,CLIP 还能够计算两张图像和一段文本之间的相似度。它还提供了提取文本和图像编码的能力。

CodeSearchNet

 and query encodings for positive pairs and minimizes it for negative pairs.

Data2Vec

Data2Vec [7] 有些不同,它提出了一个通用框架,用于跨多种模态进行自监督学*。它使用掩蔽预测,将相同的学*方法应用于语音、语言或计算机视觉。核心思想是基于输入的掩蔽视图预测整个输入的潜在表示。它不是预测特定模态的目标,如单词、视觉标记等,而是预测包含整个输入信息的上下文化潜在表示。它采用教师-学生架构——首先,构建完整输入数据的表示,作为学*任务的目标(教师模式)。然后对输入样本进行掩蔽编码,并预测完整的数据表示(学生模式)。教师的参数通过使用学生的指数衰减平均权重来更新。在训练结束时,教师的权重作为学*到的嵌入被使用。

使用该框架在语音识别、图像分类和自然语言理解等主要基准上的实验显示,无论是达到最先进的性能,还是与流行方法竞争,结果都非常出色:

图示 描述自动生成

图 10.12:Data2Vec 模型的架构。来自论文:data2vec: A General Framework for Self-supervised Learning in Speech, Vision and Language [7]

前置任务

前置任务是自监督学*模型试图通过利用无标签数据中固有的某些模式来解决的任务。这些任务本身不一定有用,但它们帮助系统学*一个有用的潜在表示,或者嵌入,这些表示可以直接使用,或在微调后用于其他下游任务。训练以解决前置任务通常作为构建实际模型的前奏,因此它也被称为预训练。

我们在本章中讨论的几乎所有技术都是前置任务。虽然一些任务本身可能最终会变得有用,例如色彩化或超分辨率,但它们也会产生嵌入,这些嵌入会学*所训练的无标签数据分布的语义,以学*到的权重的形式存在。这些权重随后可以应用于下游任务。

这不是一个新概念——例如,广泛用于寻找“同义词”的 Word2Vec 算法,是基于一个嵌入空间,在该空间中,语境相似的词汇聚在一起。它使用 skip-gram 或 CBOW 算法进行训练,这两种算法试图在给定一个词的情况下预测上下文词,反之亦然。这些目标本身没有直接的实用价值,但在这个过程中,网络最终会学*到输入数据中单词的良好潜在表示。这个表示可以直接用于寻找单词的“同义词”或进行单词类比,也可以用来生成有用的单词和单词序列(例如句子和文档)的向量表示,进而用于下游任务,例如文本分类或情感分析。

前置任务的最大优势在于,使用较少量的标注数据即可训练下游任务的模型。模型通过解决前置任务,利用大量现成的未标注数据,学*到有关领域的知识(大致的概况)。它只需要较少量的标注数据就能根据已经掌握的领域知识,学*解决更具体的下游任务。由于标注数据难以获得且成本较高,这种两步法通常能够使某些机器学*模型成为可能,甚至更具实用性。

小结

在本章中,我们了解了多种自监督策略,如何利用数据学*数据分布,并以专门的嵌入空间形式展示,这些嵌入空间又可以用来解决下游任务。我们讨论了自预测、对比学*和前置任务作为自监督的具体方法。

在下一章中,我们将讨论强化学*,这是一种通过奖励作为反馈机制来训练特定任务模型的方法。

参考文献

  1. Aaron van den Oord, Nal Kalchbrenner 和 Koray Kavucuoglu(2016)。像素递归神经网络 会议论文 MLR Press: proceedings.mlr.press/v48/oord16.pdf

  2. Aaron van den Oord, Yazhe Li, 和 Oriol Vinyals. 对比预测编码的表示学*。Arxiv 预印本,arXiv 1807.03748 [cs.LG]: arxiv.org/pdf/1807.03748.pdf

  3. Aaron van den Oord 等人(2016)。WaveNet:一种原始音频的生成模型。Arxiv 预印本,arXiv:1609.03499v2 [cs.SD]: arxiv.org/pdf/1609.03499.pdf

  4. Abe Fetterman 和 Josh Albrecht(2020)。理解自监督和对比学*与“自举你的潜在表示”(BYOL)。博客文章:generallyintelligent.ai/blog/2020-08-24-understanding-self-supervised-contrastive-learning/

  5. Aditya Ramesh 等人。零样本文本到图像生成。Arxiv 预印本,arXiv 2102.12092v2 [cs.CV]:arxiv.org/pdf/2102.12092.pdf

  6. Alec Radford 等人(2021)。从自然语言监督中学*可迁移的视觉模型。机器学*研究论文集(PMLR):proceedings.mlr.press/v139/radford21a/radford21a.pdf

  7. Alexei Baevsky 等人(2022)。data2vec:一个通用框架,用于语音、视觉和语言的自监督学*。Arxiv 预印本,arXiv 2202.03555v1 [cs.LG]:arxiv.org/pdf/2202.03555.pdf

  8. Carl Doersch、Abhinav Gupta 和 Alexei Efros(2015)。通过上下文预测进行无监督视觉表示学*。计算机视觉国际会议(ICCV):www.cv-foundation.org/openaccess/content_iccv_2015/papers/Doersch_Unsupervised_Visual_Representation_ICCV_2015_paper.pdf

  9. Chuan Li(2020)。OpenAI 的 GPT-3 语言模型——技术概述。LambdaLabs 博客文章:lambdalabs.com/blog/demystifying-gpt-3/

  10. Deepak Pathak 等人(2016)。上下文编码器:通过填充学*特征openaccess.thecvf.com/content_cvpr_2016/papers/Pathak_Context_Encoders_Feature_CVPR_2016_paper.pdf

  11. Florian Schroff、Dmitry Kalenichenko 和 James Philbin(2025)。FaceNet:一种统一的面部识别与聚类嵌入。ArXiv 预印本,arXiv 1503.03832 [cs.CV]:arxiv.org/pdf/1503.03832.pdf

  12. Gustav Larsson、Michael Maire 和 Gregory Shakhnarovich(2017)。将图像上色作为视觉理解的代理任务openaccess.thecvf.com/content_cvpr_2017/papers/Larsson_Colorization_as_a_CVPR_2017_paper.pdf

  13. Hamel Husain 等人(2020)。CodeSearchNet 挑战:评估语义代码搜索的现状。Arxiv 预印本,arXiv: 1909.09436 [cs.LG]:arxiv.org/pdf/1909.09436.pdf

  14. Hanting Chen 等人(2021)。预训练图像处理变换器。计算机视觉与模式识别会议(CVPR):openaccess.thecvf.com/content/CVPR2021/papers/Chen_Pre-Trained_Image_Processing_Transformer_CVPR_2021_paper.pdf

  15. Hyun Oh Song, Yu Xiang, Stefanie Jegelka 和 Silvio Savarese. (2015). 通过提升结构化特征嵌入进行深度度量学*. Arxiv 预印本, arXiv 1511.06452 [cs.CV]: arxiv.org/pdf/1511.06452.pdf

  16. Jacob Devlin 等. (2019). BERT:用于语言理解的深度双向变换器预训练. Arxiv 预印本, arXiv: 1810.04805v2 [cs.CL]: arxiv.org/pdf/1810.04805.pdf

  17. Jean-Bastien Grill 等. (2020). Bootstrap 你的潜在空间:一种新的自监督学*方法. Arxiv 预印本, arXiv 2006.07733 [cs.LG]: arxiv.org/pdf/2006.07733.pdf

  18. Jiahao Xie 等. (2021). 深入探讨无监督视觉表示的图像间不变性. Arxiv 预印本, arXiv: 2008.11702 [cs.CV]: arxiv.org/pdf/2008.11702.pdf

  19. Joshua Robinson, Ching-Yao Chuang, Suvrit Sra 和 Stefanie Jegelka. (2021). 使用困难负样本进行对比学*. Arxiv 预印本, arXiv 2010.04592 [cs.LG]: arxiv.org/pdf/2010.04592.pdf

  20. Jure Zobontar 等. (2021). Barlow Twins:通过冗余减少进行自监督学*. Arxiv 预印本, arXiv 2103.03230 [cs.CV]: arxiv.org/pdf/2103.03230.pdf

  21. Kihyuk Sohn. (2016). 改进的深度度量学*与多类 N-pair 损失目标. 神经信息处理系统进展: proceedings.neurips.cc/paper/2016/file/6b180037abbebea991d8b1232f8a8ca9-Paper.pdf

  22. Lilian Weng 和 Jong Wook Kim. (2021). 自监督学*:自预测与对比学*. NeurIPS 教程: neurips.cc/media/neurips-2021/Slides/21895.pdf

  23. Lilian Weng. (博客文章 2021). 对比表示学*: lilianweng.github.io/posts/2021-05-31-contrastive/

  24. Mathilde Caron, Piotr Bojanowsky, Armand Joulin 和 Matthijs Douze. (2019). 深度聚类:用于无监督学*视觉特征. Arxiv 预印本, arXiv: 1807.05520 [cs.CV]: arxiv.org/pdf/1807.05520.pdf

  25. Mathilde Caron 等. (2020). 通过对比聚类分配进行无监督的视觉特征学*. Arxiv 预印本, arXiv: 2006.099882 [cs.CV]: arxiv.org/pdf/2006.09882.pdf

  26. Mehdi Noroozi 和 Paolo Favaro. (2016). 通过解拼图来进行无监督的视觉表示学*. 欧洲计算机视觉大会: link.springer.com/chapter/10.1007/978-3-319-46466-4_5

  27. Michael Gutmann 和 Aapo Hyvarinen(2010)。噪声对比估计:一种新的无标准化统计模型估计原理。机器学*研究会议论文集(PMLR):proceedings.mlr.press/v9/gutmann10a/gutmann10a.pdf

  28. Nal Kalchbrenner 等人(2018)。高效的神经音频合成。机器学*研究会议论文集(MLR):proceedings.mlr.press/v80/kalchbrenner18a/kalchbrenner18a.pdf

  29. Pascal Vincent 等人(2010)。堆叠去噪自编码器:通过局部去噪标准在深度网络中学*有用表示。机器学*研究期刊(JMLR):www.jmlr.org/papers/volume11/vincent10a/vincent10a.pdf?ref=https://githubhelp.com

  30. Patrick Esser、Robin Rombach 和 Bjorn Ommer(2021)。为高分辨率图像合成驯服变换器。计算机视觉与模式识别(CVPR):openaccess.thecvf.com/content/CVPR2021/papers/Esser_Taming_Transformers_for_High-Resolution_Image_Synthesis_CVPR_2021_paper.pdf

  31. Philip Bachman、R Devon Hjelm 和 William Buchwalter(2019)。通过最大化不同视图之间的互信息学*表示。神经信息处理系统进展(NeurIPS):proceedings.neurips.cc/paper/2019/file/ddf354219aac374f1d40b7e760ee5bb7-Paper.pdf

  32. Prafulla Dhariwal 等人(2020)。Jukebox:一种生成音乐的模型。Arxiv 预印本,arXiv 2005.00341v1 [eess.AS]:arxiv.org/pdf/2005.00341.pdf

  33. Ruslan Salakhutdinov 和 Geoff Hinton(2007)。通过保持类别邻域结构学*非线性嵌入。机器学*研究会议论文集(PMLR):proceedings.mlr.press/v2/salakhutdinov07a/salakhutdinov07a.pdf

  34. Spyros Gidaris、Praveer Singh 和 Nicos Komodakis(2018)。通过预测图像旋转进行无监督表示学*。Arxiv 预印本,arXiv 1803.07728v1 [cs.CV]:arxiv.org/pdf/1803.07728.pdf

  35. Sumit Chopra 等人(2005)。通过判别学*相似度度量,应用于人脸验证。IEEE 计算机学会:www.cs.utoronto.ca/~hinton/csc2535_06/readings/chopra-05.pdf

  36. Ting Chen, Simon Kornblith, Mohammed Norouzi 和 Geoffrey Hinton. (2020). 对比学*的简单框架。Arxiv 预印本,arXiv 2002.05709 [cs.LG]:arxiv.org/pdf/2002.05709.pdf

  37. Yonglong Tian, Dilip Krishnan 和 Philip Isola. (2020). 对比多视角编码。Arxiv 预印本,arXiv: 1906.05849 [cs.CV]:arxiv.org/pdf/1906.05849.pdf?ref=https://githubhelp.com

  38. Zhilin Yang 等. (2019). XLNet:用于语言理解的广义自回归预训练proceedings.neurips.cc/paper/2019/file/dc6a7e655d7e5840e66733e9ee67cc69-Paper.pdf

  39. 提示工程。(2022 年 7 月 7 日)。维基百科,维基媒体基金会:en.wikipedia.org/wiki/Prompt_engineering

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,与志同道合的人一起学*,和超过 2000 名成员共同成长:packt.link/keras

第十一章:强化学*

本章介绍了强化学*RL)——一种最不被探索但最有前景的学*范式。强化学*与我们在之前章节中讨论的监督学*和无监督学*模型大不相同。从一张白纸开始(也就是没有先前的信息),RL 代理可以通过多次试错的阶段,并学会实现一个目标,在此过程中唯一的输入是来自环境的反馈。OpenAI 在强化学*方面的研究似乎表明,持续的竞争可能是智力进化的一个原因。许多深度学*从业者认为,RL 将在大规模人工智能梦想中扮演重要角色:人工通用智能AGI)。本章将深入探讨不同的 RL 算法。以下主题将被涵盖:

  • 什么是 RL 及其术语

  • 学*如何使用 OpenAI Gym 接口

  • RL 的应用

  • 深度 Q 网络

  • 策略梯度

本章的所有代码文件可以在packt.link/dltfchp11找到。

RL 简介

学*走路的婴儿、学*飞行的鸟和学*玩 Atari 游戏的强化学*(RL)代理之间有什么共同点?嗯,三者都有:

  • 试错法:孩子(或鸟)尝试多种方式,失败多次,最终在某些方式上成功,才能真正学会走路(或飞行)。RL 代理玩许多游戏,赢得一些,输掉许多,直到它能够可靠地成功。

  • 目标:孩子的目标是走路,鸟的目标是飞行,而 RL 代理的目标是赢得游戏。

  • 与环境的互动:它们唯一的反馈来自于环境。

那么,首先产生的问题是,什么是 RL,它与监督学*和无监督学*有什么区别?任何养宠物的人都知道,训练宠物的最佳策略是奖励它的良好行为,并惩罚它的不良行为。RL,也叫做带有评论员的学*,是一种学*范式,在这种范式中,代理以相同的方式进行学*。这里的代理对应于我们的网络(程序);它可以执行一组动作a),这会导致环境的状态s)发生变化,进而代理从环境中获得奖励或惩罚。

例如,考虑训练一只狗去捡球的情况:在这里,狗是我们的智能体,狗做出的自愿肌肉运动是行动,而地面(以及人和球)是环境;狗通过给予它奖励的反应来感知我们的行动。强化学*(RL)可以定义为一种通过与环境互动,在一些理想化条件下,进行目标导向学*和决策的计算方法。智能体可以感知环境的状态,并且可以对环境执行特定的、明确定义的动作。这会导致两件事:首先,环境的状态发生变化;其次,生成奖励(在理想条件下)。这个循环持续进行,理论上智能体随着时间的推移学*如何更频繁地生成奖励:

计算机截图,描述自动生成的图片,信心较高

图 11.1:强化学*:智能体与环境的互动

与监督学*不同,智能体没有被提供任何训练示例;它不知道正确的行动是什么。

与无监督学*不同,智能体的目标不是在输入中找到某种内在的结构(虽然学*过程可能会发现一些结构,但那并不是目标);相反,它唯一的目标是最大化奖励(从长远来看)并减少惩罚。

强化学*术语

在学*各种强化学*算法之前,了解一些重要术语非常重要。我们将通过两个例子来说明这些术语,第一个是迷宫中的机器人,第二个是控制自动驾驶汽车SDC)轮子的智能体。两个强化学*智能体如下所示:

包含图表的图片,描述自动生成的图片

图 11.2:机器人在迷宫中寻找路径的状态(左侧)。智能体控制自动驾驶汽车方向盘的状态(右侧)

图 11.2 显示了我们将要考虑的两个例子。让我们从术语开始:

  • 状态S:状态是一组可以定义环境可能处于的所有状态的标记(或表示)。它可以是连续的,也可以是离散的。在机器人通过迷宫寻找路径的例子中,状态可以用一个 4×4 的矩阵来表示,矩阵元素表示该块是空的、被占用的,还是被阻挡的。值为 1 的块表示被机器人占据,值为 0 表示空的,X 表示该块不可通行。这个数组中的每个元素,S,可以有这三种离散值之一,因此状态在本质上是离散的。接下来,考虑智能体控制自动驾驶汽车的方向盘。智能体以前视图图像作为输入。图像包含连续值的像素,因此在这里状态是连续的。

  • 动作A(S):动作是代理在某一特定状态下可以采取的所有可能行为的集合。可能的动作集合A取决于当前状态S。动作可能会导致状态的变化,也可能不会。与状态一样,动作可以是离散的或连续的。比如,机器人在迷宫中寻找路径时,可以执行五个离散的动作 [, , , , 不变]。而自动驾驶代理则可以在一个连续的角度范围内旋转方向盘。

  • 奖励 R(S,A,S’):奖励是环境根据代理的动作返回的标量值。这里S是当前状态,S’是执行动作A后环境的状态。奖励由目标决定;如果动作将代理带*目标,代理将获得较高的奖励,否则会得到较低(甚至负)的奖励。我们如何定义奖励完全由我们决定——以迷宫为例,我们可以定义奖励为代理当前位置与目标之间的欧几里得距离。自动驾驶代理的奖励可以是车在路上(正奖励)或偏离道路(负奖励)。

  • 策略 :策略定义了每个状态与在该状态下应该采取的动作之间的映射。策略可以是确定性的,即对于每个状态,都有一个明确的策略。例如,对于迷宫机器人,如果上方的方块为空,就向上移动。策略也可以是随机的,即动作是按一定概率采取的。它可以实现为一个简单的查找表,或者作为一个依赖于当前状态的函数。策略是强化学*代理的核心。在本章中,我们将学*帮助代理学*策略的不同算法。

  • 回报 G[t]:这是从当前时刻开始,所有未来奖励的折扣总和,数学定义如下:

  • 这里 R[t] 是时刻 t 的奖励,且 是折扣因子;它的值介于 0 和 1 之间。折扣因子决定了在决策策略时未来奖励的重要性。如果它接*零,代理会更加注重即时奖励。然而,高折扣因子意味着代理会更长远地考虑未来。它可能会放弃即时奖励,以换取较高的未来奖励,就像在国际象棋中,你可能会牺牲一个兵,以换取后续的将死对方。

  • 价值函数 V(S):它定义了一个状态在长远来看“好坏”的程度。可以把它看作是代理从状态S出发,在一段时间内可以期望积累的奖励总量。你可以把它看作是长远的好处,而不是短期且短暂的好处。你认为哪个更重要,是最大化即时奖励还是价值函数?你可能猜对了:就像在国际象棋中,我们有时会牺牲一个兵,以便在几步后赢得比赛,因此代理应该尝试最大化价值函数。

  • 通常,值定义为状态价值函数 动作价值函数 ,其中 是所遵循的策略。状态价值函数是遵循策略 后,从状态 S 得到的期望回报:

  • 这里的 E 是期望,S[t]=s 是时间 t 时的状态。动作值函数是从状态 S 开始,采取动作 A=a 并遵循策略 所得到的期望回报:

  • 环境模型:这是一个可选元素。它模拟环境的行为,并包含环境的物理特性;换句话说,它表示环境将如何表现。环境模型通过转移到下一个状态的转移概率来定义。这是一个可选组件;我们也可以采用无模型的强化学*,在这种情况下,不需要转移概率来定义 RL 过程。

在 RL 中,我们假设环境的状态遵循马尔可夫性质,即每个状态仅依赖于前一个状态、从动作空间中采取的动作以及相应的奖励。

即,如果 St(+1) 是时间 t+1 时环境的状态,那么它是时间 tS^t 状态、时间 t 时采取的动作 A^t 和相应的奖励 R^t 的函数,不需要前置历史。如果 P(St(+1)|S^t) 是转移概率,从数学上讲,马尔可夫性质可以写成:

因此,RL 可以被认为是一个马尔可夫决策过程MDP)。

深度强化学*算法

深度强化学*DRL)的基本思想是,我们可以使用深度神经网络来逼*策略函数或价值函数。在本章中,我们将研究一些流行的 DRL 算法。这些算法可以根据它们逼*的内容分为两类:

  • 基于价值的方法:在这些方法中,算法会采取最大化价值函数的动作。这里的智能体学*预测给定状态或动作的好坏。基于价值的方法的一个例子是深度 Q 网络。例如,考虑我们的迷宫中的机器人:假设每个状态的值是从该位置到目标所需步数的负数,那么,在每个时间步,智能体将选择采取能将其带到具有最佳值的状态的动作,如下图所示。因此,从 -6 的值开始,它将移动到 -5-4-3-2-1,最终到达目标,值为 0

表格 描述自动生成

图 11.3:迷宫寻路机器人的值函数值演示

  • 基于策略的方法:在这些方法中,算法预测最优策略(即最大化期望回报的策略),而不维护价值函数的估计。目标是找到最优策略,而不是最优动作。基于策略的方法的一个例子是策略梯度。在这里,我们*似策略函数,从而将每个状态映射到最佳的相应动作。基于策略的方法相对于基于价值的方法的一个优势是,它们甚至可以用于连续动作空间。

除了*似策略或价值的算法外,我们还需要回答一些问题,以便让强化学*有效地工作。

代理如何选择其动作,尤其是在未训练的情况下?

当代理开始学*时,它并不知道如何确定一个动作是最好的,或者哪个动作将提供最佳的Q值。那么我们该如何做呢?我们借鉴自然界的做法。就像蜜蜂和蚂蚁一样,代理在探索新动作和利用已学得动作之间做出平衡。最初,当代理开始时,它不知道在所有可能的动作中哪个更好,因此它会做出随机选择,但随着学*的进行,它开始利用已学得的策略。这被称为探索与利用[2]的权衡。通过探索,代理收集更多的信息,之后利用这些收集到的信息做出最佳决策。

代理如何在探索与利用之间保持平衡?

有多种策略,其中最常用的一种是epsilon-贪心 () 策略。在这里,代理持续探索,并且根据的值,在每一步,代理以概率选择一个随机动作,并以概率选择一个最大化价值函数的动作。通常,的值会渐进下降。在 Python 中,策略可以实现为:

 if np.random.rand() <= epsilon:
        a = random.randrange(action_size)
  else:
        a = np.argmax(model.predict(s)) 

其中,model是*似价值/策略函数的深度神经网络,a是从大小为action_size的动作空间中选择的动作,s是状态。另一种进行探索的方式是使用噪声;研究人员成功地实验了高斯噪声和奥恩斯坦-乌伦贝克噪声。

如何处理高度相关的输入状态空间?

我们的强化学*模型的输入是环境的当前状态。每个动作都会引起环境的某些变化;然而,两个连续状态之间的相关性非常高。如果我们让网络基于连续的状态进行学*,两个连续输入之间的高相关性就会导致所谓的灾难性遗忘。为了缓解灾难性遗忘的影响,David Isele 和 Akansel Cosgun 在 2018 年提出了经验回放方法。

简单来说,学*算法首先将 MDP 元组——状态、动作、奖励和下一个状态 <S, A, R, S’>——存储在缓冲区/记忆中。一旦积累了足够的记忆,就会随机选择一批数据来训练代理。记忆会不断地通过新的添加和旧的删除来刷新。使用经验回放提供了三个好处:

  • 首先,它允许相同的经验在多个权重更新中被潜在地使用,从而提高了数据效率。

  • 其次,随机选择经验批次可以去除连续状态之间的相关性,从而避免网络训练时出现偏差。

  • 第三,它可以防止任何不希望出现的反馈循环,这些循环可能导致网络陷入局部最小值或发散。

经验回放的一个修改版本是优先经验回放PER)。该方法由 Tom Schaul 等人于 2015 年提出[4],其理念来源于并非所有经验(或者说,尝试)都同等重要的观点。有些尝试比其他尝试能提供更好的教训。因此,与其随机选择经验,不如为更多教育意义的经验分配更高的优先级,从而提高选择训练的效率。在 Schaul 的论文中,提出应该优先选择那些预测和目标之间差异较大的经验,因为在这些情况下,代理能够学到更多。

如何处理移动目标的问题

与监督学*不同,RL 中的目标并不是事先已知的。在移动目标的情况下,代理试图最大化期望回报,但最大值会随着代理学*的进行而不断变化。实际上,这就像试图捕捉一只蝴蝶,每次靠*它时,它都会飞到一个新的位置。移动目标的主要原因是,同一网络用来估计动作和目标值,这可能会导致学*中的震荡。

这个问题的解决方案是由 DeepMind 团队在 2015 年发表的论文《通过深度强化学*实现人类水平的控制》中提出的,该论文发表于《自然》杂志。该解决方案是,现在代理不再面对一个移动的目标,而是拥有短期固定的目标。代理现在保持两个网络,它们在架构上完全相同,一个叫做局部网络,用于在每一步估计当前的动作,另一个是目标网络,用来获取目标值。然而,两个网络各自有自己的一组权重。在每一个时间步,局部网络学*的方向是使其估计值和目标值尽可能接*。经过若干个时间步后,目标网络的权重会被更新。更新可以是硬更新,即在N个时间步之后,将局部网络的权重完全复制到目标网络,或者是软更新,在这种情况下,目标网络缓慢地(通过 Tau 因子!)将其权重向局部网络靠*。

*年来强化学*的成功

在过去的几年里,深度强化学*(DRL)已成功应用于各种任务,特别是在游戏和机器人领域。让我们在学*算法之前,先了解一些强化学*的成功案例:

  • AlphaGo Zero:由谷歌的 DeepMind 团队开发,AlphaGo Zero 的论文《无需任何人类知识即可掌握围棋》从一张完全空白的白纸(tabula rasa)开始。AlphaGo Zero 使用一个神经网络来*似计算棋步概率和价值。

  • 这个神经网络以原始棋盘表示作为输入。它使用一个由神经网络引导的蒙特卡洛树搜索来选择棋步。强化学*算法在训练循环中结合了前瞻性搜索。它使用 40 块残差 CNN 训练了 40 天,在训练过程中,它玩了大约 2900 万盘棋(这是一个非常大的数字!)。该神经网络在 Google Cloud 上使用 TensorFlow 进行优化,配备了 64 个 GPU 工作节点和 19 个 CPU 参数服务器。你可以在这里查看论文:www.nature.com/articles/nature24270

  • AI 控制的滑翔机:微软开发了一套控制系统,可以在多种不同的自动驾驶硬件平台上运行,如 Pixhawk 和 Raspberry Pi 3。它能够通过自主寻找并利用自然气流,将滑翔机保持在空中,无需使用发动机。控制系统帮助滑翔机在没有电机或人工干预的情况下,仅通过检测并利用气流来飞行。他们将其实现为一个部分可观察的马尔可夫决策过程(MDP)。他们采用了贝叶斯强化学*,并使用蒙特卡洛树搜索来寻找最佳行动。他们将整个系统分为多个层次的规划器——一个高层规划器根据经验做出决策,一个低层规划器则使用贝叶斯强化学*实时检测并锁定气流。你可以在微软新闻中看到这款滑翔机的实际操作:news.microsoft.com/features/science-mimics-nature-microsoft-researchers-test-ai-controlled-soaring-machine/

  • 运动行为:在论文《在丰富环境中运动行为的出现》(arxiv.org/pdf/1707.02286.pdf)中,DeepMind 的研究人员为智能体提供了丰富多样的环境。环境呈现出不同难度级别的挑战。智能体在不断增加的难度下进行训练,这促使其在没有进行任何奖励设计(即没有设计特定奖励函数)的情况下,学*到复杂的运动技能。

  • 使用强化学*进行数据中心冷却:数据中心是当今数字/互联网革命的主力军。凭借其庞大的服务器和网络设备,它们促进了数据存储、数据传输以及信息处理。数据中心约占全球能源消耗的 1.5%左右,如果不采取措施,消耗量只会增加。DeepMind 与 Google Research 在 2016 年采用强化学*模型,成功将其数据中心的能耗减少了 40%。通过使用数据中心传感器收集的历史数据,他们训练了一个深度神经网络来预测未来的能效并提出最佳行动方案。你可以在这篇论文中阅读该模型和方法的详细信息:使用模型预测控制进行数据中心冷却proceedings.neurips.cc/paper/2018/file/059fdcd96baeb75112f09fa1dcc740cc-Paper.pdf)。

  • 控制核聚变等离子体:RL 的一个最*(2022 年)且有趣的应用是借助强化学*来控制核聚变等离子体。相关成果已发布在《自然》期刊的论文中:通过强化学*实现托卡马克等离子体的磁控

看到 DRL 代理如何在没有任何隐性知识的情况下学*执行任务,甚至在许多专业任务中超越人类,真是令人惊叹。在接下来的章节中,我们将探索这些神奇的 DRL 算法,看看它们如何在几千个训练周期内,以几乎人类的效率玩游戏。

强化学*的仿真环境

正如前面提到的,试错是任何 RL 算法的重要组成部分。因此,在模拟环境中首先训练我们的 RL 代理是有意义的。

今天,已经有大量平台可以用来创建环境。一些流行的包括:

  • OpenAI Gym:它包含了一系列环境,我们可以用来训练 RL 代理。在本章中,我们将使用 OpenAI Gym 接口。

  • Unity ML-Agents SDK:它允许开发者通过一个简单易用的 Python API,将使用 Unity 编辑器创建的游戏和仿真转化为智能代理可以训练的环境,使用 DRL、进化策略或其他机器学*方法。它与 TensorFlow 兼容,能够训练适用于 2D/3D 以及 VR/AR 游戏的智能代理。你可以在这里了解更多:github.com/Unity-Technologies/ml-agents

  • Gazebo:在 Gazebo 中,我们可以构建具有基于物理的仿真功能的三维世界。gym-gazebo 工具包结合 Gazebo、机器人操作系统ROS)和 OpenAI Gym 接口,可以用于训练 RL 代理。有关更多信息,您可以参考白皮书:arxiv.org/abs/1608.05742

  • Blender 学*环境:这是一个用于 Blender 游戏引擎的 Python 接口,它也可以与 OpenAI Gym 配合使用。其基础是 Blender:一款免费的 3D 建模软件,内置游戏引擎。这为创建游戏提供了一个易用且强大的工具集。它提供了与 Blender 游戏引擎的接口,游戏本身是在 Blender 中设计的。然后,我们可以创建一个自定义虚拟环境,在特定问题上训练 RL 代理(github.com/LouisFoucard/gym-blender)。

  • Malmo:由微软团队构建的 Malmo 是一个基于 Minecraft 的 AI 实验和研究平台。它提供了一个简单的 API,用于创建任务和任务。您可以在这里了解更多关于 Project Malmo 的信息:www.microsoft.com/en-us/research/project/project-malmo/

OpenAI Gym 介绍

我们将使用 OpenAI Gym 来为我们的代理提供环境。OpenAI Gym 是一个开源工具包,用于开发和比较 RL 算法。它包含了多种可用于训练代理并开发新 RL 算法的仿真环境。

首先需要做的是安装 OpenAI Gym。以下命令将安装最小的 gym 包:

pip install gym 

如果您想安装所有(免费)gym 模块,可以在后面加上 [all]

pip install gym[all] 

MuJoCo 环境需要购买许可证。对于基于 Atari 的游戏,您需要安装 Atari 依赖项(Box2D 和 ROM):

pip install box2d-py 

OpenAI Gym 提供了多种环境,从简单的基于文本的到三维游戏。支持的环境可以按如下方式分组:

  • 算法:包含涉及执行计算任务(如加法)的环境。虽然我们可以轻松地在计算机上执行这些计算,但使这些问题成为 RL 问题的有趣之处在于,代理仅通过示例学*这些任务。

  • Atari:此环境提供了种类繁多的经典 Atari/街机游戏。

  • Box2D:包含二维机器人工具任务,如赛车代理或双足机器人走路。

  • 经典控制:包含经典控制理论问题,例如平衡小车杆。

  • MuJoCo:这是专有的(您可以获得一个月的免费试用)。它支持各种机器人仿真任务。该环境包括物理引擎,因此用于训练机器人任务。

  • 机器人学:此环境也使用 MuJoCo 的物理引擎。它模拟了以目标为导向的任务,适用于取物和影像手机器人。

  • Toy text:一个简单的基于文本的环境——非常适合初学者。

你可以从 Gym 网站获取完整的环境列表:gym.openai.com。要查看安装中所有可用环境的列表,你可以使用以下代码:

from gym import envs

envall = envs.registry.all()
len(envall) 

在编写本书时,结果是 859,即 gym 模块中存在 859 个不同的环境。让我们看看这些环境的更多细节。每个环境都是通过使用 make 函数创建的。每个环境都有一个唯一的 ID、其观察空间、动作空间和默认奖励范围。Gym 允许你通过点符号访问它们,如下代码所示。我们遍历 envall 列表中的所有环境,并记录下其唯一 ID,ID 用于通过 make 方法创建环境,观察空间、奖励范围和动作空间:

from tqdm import tqdm
List = []
for e in tqdm(envall):
    try:
        env = e.make()
        List.append([e.id, env.observation_space, env.action_space, env.reward_range])
        env.close() 
    except:
        continue 

图 11.4 显示了列表中的一个随机样本:

表格 描述自动生成

图 11.4:OpenAI Gym 中可用环境的随机列表

你可以使用这些命令来查看 Gym 中任何环境的详细信息。例如,以下代码打印出 MountainCar 环境的详细信息:

env = gym.make('MountainCar-v0')
print(f"The Observation space is        {env.observation_space}" )
print(f"Upper Bound for Env Observation {env.observation_space.high}")
print(f"Lower Bound for Env Observation {env.observation_space.low}")
print(f"Action Space                    {env.action_space}")
env.seed(0)
obs = env.reset()
print(f"The initial observation is      {obs}")
# Take a random actionget the new observation space
new_obs, reward, done, info = env.step(env.action_space.sample())
print(f"The new observation is          {new_obs}")
env.close() 

OpenAI Gym 提供的核心接口是统一的环境接口。代理可以使用三种基本方法与环境进行交互,即 resetsteprenderreset 方法重置环境并返回观察值。step 方法使环境按一个时间步长前进,并返回 new_obsrewarddoneinforender 方法渲染环境的一帧,类似于弹出一个窗口。让我们尝试查看一些不同的环境并查看它们的初始帧:

物理引擎 经典控制 Atari

|

e = 'LunarLander-v2'
env = gym.make(e)
obs = env.reset() 
img = env.render(mode='rgb_array')
env.close()
plt.imshow(img) 

|

e = 'CartPole-v0'
env = gym.make(e)
env.reset()
img = env.render(mode='rgb_array')
env.close()
plt.imshow(img) 

|

e = 'SpaceInvaders-v0'
env = gym.make(e)
env.reset()
img = env.render(mode='rgb_array')
env.close()
plt.imshow(img) 

|

图标 描述自动生成 图表,箱线图 描述自动生成 包含文本的图片 描述自动生成

表 11.1:OpenAI Gym 的不同环境及其初始状态

上述代码使用 Matplotlib 显示环境;你也可以直接使用 render 方法:

import gym
env_name = 'Breakout-v0'
env = gym.make(env_name)
obs = env.reset()
env.render() 

你可以在图 11.5中看到 Breakout 环境;render 函数会弹出环境窗口:

图形用户界面,应用程序 描述自动生成

图 11.5:Breakout 环境的初始状态

我们可以使用 env.observation_spaceenv.action_space 来了解 Breakout 游戏的状态空间和动作空间。结果显示,状态由一个 210 × 160 大小的三通道图像组成,动作空间是离散的,有四个可能的动作。完成后,别忘了使用以下命令关闭 OpenAI:

env.close() 

随机代理玩 Breakout

让我们玩得开心,玩一下 Breakout 游戏。当我第一次玩这个游戏时,我根本不知道规则是什么,也不知道该怎么玩,所以我随机选择了控制按钮。我们的新手智能体也将做同样的事情;它会从动作空间中随机选择动作。Gym 提供了一个名为sample()的函数,它从动作空间中选择一个随机动作——我们将使用这个函数。同时,我们还可以保存游戏的回放,以便稍后查看。保存回放有两种方式,一种是使用 Matplotlib,另一种是使用 OpenAI Gym 的 Monitor 包装器。让我们先看看 Matplotlib 方法。

我们将首先导入必要的模块;目前我们只需要gymmatplotlib,因为智能体将进行随机操作:

import gym
import matplotlib.pyplot as plt
import matplotlib.animation as animation 

我们创建 Gym 环境:

env_name = 'Breakout-v0'
env = gym.make(env_name) 

接下来,我们将一步一步运行游戏,选择一个随机动作,无论是 300 步还是直到游戏结束(以较早的为准)。环境状态(观察)空间将在每一步保存到列表frames中:

frames = [] # array to store state space at each step
env.reset()
done = False
for _ in range(300): 
    #print(done)
    frames.append(env.render(mode='rgb_array'))
    obs,reward,done, _ = env.step(env.action_space.sample())
    if done:
        break 

现在,我们进入将所有帧合成 GIF 图像的部分,使用 Matplotlib Animation。我们创建一个图像对象、补丁,然后定义一个函数,将图像数据设置为特定的帧索引。该函数由 Matplotlib 的Animation类使用,用来创建动画,最后我们将其保存在文件random_agent.gif中:

patch = plt.imshow(frames[0])
plt.axis('off')
def animate(i):
    patch.set_data(frames[i])
    anim = animation.FuncAnimation(plt.gcf(), animate, \
        frames=len(frames), interval=10)
    anim.save('random_agent.gif', writer='imagemagick') 

上面的代码将生成一个 GIF 图像。下面是从该图像中截取的一些屏幕截图:

图形用户界面,应用程序说明自动生成

图 11.6:从保存的 GIF 图像中截取的一些屏幕截图

现在我们已经熟悉了 OpenAI Gym,我们将继续介绍包装器——你可以用它来创建自定义环境。

Gym 中的包装器

Gym 为我们提供了多种包装器来修改现有环境。例如,如果你有图像输入,RGB 强度值在 0 到 255 之间,而你使用的 RL 智能体是神经网络,最佳输入范围是 0 到 1,那么你可以使用 Gym 包装器类来预处理状态空间。下面我们定义了一个包装器,用于连接观察:

from collections import deque
from gym import spaces
import numpy as np
#Class to concat observations
class ConcatObservations(gym.Wrapper):
    def __init__(self, env, n):
        gym.Wrapper.__init__(self, env)
        shape = env.observation_space.shape
        self.n = n
        self.frames = deque([], maxlen=n)
        self.observation_space = \
            spaces.Box(low=0, high=255, shape=((n,) + shape), dtype=env.observation_space.dtype)
    def reset(self):  #reset function
        obs = self.env.reset()
        for _ in range(self.n):
            self.frames.append(obs)
        return self._get_obs()
    def step(self, action): #step function
        obs, reward, done, info = self.env.step(action)
        self.frames.append(obs)
        return self._get_obs(), reward, done, info
    def _get_obs(self):
        return np.array(self.frames) 

你可以看到我们需要更改默认的reset函数、step函数和观察函数_get_obs。我们还需要修改默认的观察空间。

让我们看看它是如何工作的。如果你选择"BreakoutNoFrameskip-v4"环境,那么初始的观察空间是 210 x 160 x 3:

env = gym.make("BreakoutNoFrameskip-v4")
print(f"The original observation space is  {env.observation_space}") 
### OUTPUT: 
>>>The original observation space is  Box(0, 255, (210, 160, 3), uint8) 

现在,如果你使用我们刚刚创建的包装器:

env = ConcatObservations(env, 4)
print(f"The new observation space is  {env.observation_space}") 
### OUTPUT: 
The new observation space is  Box(0, 255, (4, 210, 160, 3), uint8) 

你可以看到现在添加了一个维度——它有四个帧,每个帧的大小是 210 x 160 x 3。你也可以使用包装器来修改奖励。在这种情况下,你使用父类RewardWrapper。下面是一个示例代码,可以将奖励裁剪到[-10, 10]的范围内:

class ClippedRewards(gym.RewardWrapper):
    def __init__(self, env):
        gym.RewardWrapper.__init__(self, env)
        self.reward_range = (-10,10)
    def reward(self, reward):
        """Clip to {+10, 0, -10} by its sign."""
        return reward if reward >= -10 and reward <= 10 else 10 * np.sign(reward) 

让我们尝试在 CartPole 环境中使用它,该环境的奖励范围是

env = ClippedRewards(gym.make("CartPole-v0"))
print(f'Clipped reward range: {env.reward_range}')
env.close() 
### OUTPUT: 
Clipped reward range: (-10, 10) 

包装器的另一个有用应用是,当你想在智能体学*时保存状态空间。通常,RL 智能体需要大量的步骤来进行适当的训练,因此在每一步保存状态空间并不可行。相反,我们可以选择在每 500 步后(或者你希望的任何其他步数)存储状态空间,正如前述算法所示。OpenAI Gym 提供了Wrapper Monitor类来将游戏保存为视频。为此,我们需要首先导入包装器,然后创建环境,最后使用Monitor

默认情况下,它将存储 1、8、27、64(完美立方数的剧集编号)等视频,然后每 1,000 个剧集;每次训练,默认保存在一个文件夹中。执行此操作的代码如下:

import gym
env = gym.make("Breakout-v0")
env = gym.wrappers.Monitor(env, 'recording', force=True)
observation = env.reset()
for _ in range(1000):
    #env.render()
    action = env.action_space.sample()
    # your agent here (this takes random actions)
    observation, reward, done, info = env.step(action)
    if done:
        observation = env.reset()
env.close() 

要使Monitor正常工作,我们需要 FFmpeg 的支持。根据操作系统的不同,可能需要安装它,如果缺少的话。

这将把视频以.mp4格式保存在recording文件夹中。这里需要注意的是,如果你想使用同一个文件夹进行下次训练,会需要设置force=True选项。

如果你想在 Google Colab 上训练你的智能体,你需要添加以下驱动程序,以便能够可视化 Gym 的输出:

!pip install pyglet
!apt-get install -y xvfb python-opengl > /dev/null 2>&1
!pip install gym pyvirtualdisplay > /dev/null 2>&1 

安装 Python 虚拟显示后,你需要启动它——Gym 使用虚拟显示来设置观察。以下代码可以帮助你启动一个大小为 600 x 400 的显示:

from pyvirtualdisplay import Display
display = Display(visible=0, size=(600, 400))
display.start() 

并且要能够玩 Atari 游戏,使用:

!wget http://www.atarimania.com/roms/Roms.rar
!mkdir /content/ROM/
!unrar e /content/Roms.rar /content/ROM/
!python -m atari_py.import_roms /content/ROM/ 

深度 Q 网络

深度 Q 网络,简称DQNs,是深度学*神经网络,旨在逼* Q 函数(价值-状态函数)。它们是最流行的基于价值的强化学*算法之一。该模型由 Google 的 DeepMind 在 2013 年 NeurIPS 会议上提出,论文标题为使用深度强化学*玩 Atari 游戏。这篇论文的最重要贡献是,他们直接将原始状态空间作为输入传递给网络;输入特征不像早期的 RL 实现那样是人工设计的。此外,他们能够使用完全相同的架构训练智能体进行不同的 Atari 游戏,并取得最先进的结果。

该模型是简单 Q 学*算法的扩展。在 Q 学*算法中,会维持一个 Q 表作为备忘单。每次执行动作后,Q 表会使用贝尔曼方程[5]进行更新:

是学*率,它的值位于[0,1]范围内。第一项表示旧的Q值的组成部分,第二项表示目标Q值。Q 学*适用于状态数量和可能的动作数量较少的情况,但对于较大的状态空间和动作空间,Q 学*的扩展性较差。一个更好的替代方法是使用深度神经网络作为函数*似器,逼*每个可能动作的目标 Q 函数。在这种情况下,深度神经网络的权重存储了 Q 表信息。每个可能的动作都有一个单独的输出单元。网络将状态作为输入,并返回所有可能动作的预测目标Q值。问题来了:我们如何训练这个网络,损失函数应该是什么?好吧,由于我们的网络必须预测目标Q值:

损失函数应该尽量减少预测的Q值(Q[predicted])与目标Q值(Q[target])之间的差异。我们可以通过将损失函数定义为:

其中W是我们深度Q网络的训练参数,通过梯度下降学*,以使损失函数最小化。

以下是 DQN 的一般架构。网络将n维状态作为输入,并输出Q值,对于m维动作空间中的每个可能动作。每一层(包括输入层)可以是卷积层(如果我们采用原始像素作为输入,卷积层更有意义)或密集层:

A picture containing icon  Description automatically generated

图 11.7:图示显示了一个简单的 DQN 网络,输入层接收状态向量 S,输出层预测该状态下所有可能动作的 Q 值

在接下来的部分,我们将尝试训练一个 DQN。我们的智能体任务是使小车上的杆子保持稳定。智能体可以左右移动小车以保持平衡。

CartPole 的 DQN

CartPole 是一个经典的 OpenAI 问题,具有连续的状态空间和离散的动作空间。在这个问题中,一根杆子通过一个不受控制的关节连接到一个小车上;小车沿着一个无摩擦的轨道移动。目标是通过左右移动小车来保持杆子在小车上站立。每当杆子站立时,奖励为+1。若杆子与垂直方向的角度超过 15 度,或小车从中心位置移动超过 2.4 个单位,游戏结束:

Box and whisker chart  Description automatically generated

图 11.8:来自 CartPole Gym 环境的截图

你可以查看 OpenAI Gym 的排行榜,看看 CartPole 环境中的一些酷炫条目:github.com/openai/gym/wiki/Leaderboard#cartpole-v0

我们首先导入必要的模块。我们显然需要gym来为我们提供 CartPole 环境,tensorflow来构建我们的 DQN 网络。除此之外,我们还需要randomnumpy模块:

import random
import gym
import math
import numpy as np
from collections import deque
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense
from tensorflow.keras.optimizers import Adam 

我们设置了训练代理的最大训练回合数(EPOCHS)、我们认为环境已解决的阈值(THRESHOLD),以及一个布尔值来表示是否希望记录训练过程(MONITOR)。请注意,根据官方 OpenAI 文档,CartPole 环境在代理能够在 195 个时间步(ticks)内保持杆处于竖直位置时被认为已解决。在以下代码中,为了节省时间,我们将THRESHOLD降低到 45:

EPOCHS = 1000
THRESHOLD = 45
MONITOR = True 

现在让我们构建我们的 DQN。我们声明一个DQN类,并在其__init__()函数中声明所有超参数和模型。我们还在DQN类内部创建了环境。如你所见,这个类非常通用,你可以用它来训练任何 Gym 环境,只要其状态空间信息可以被包含在一个一维数组中:

class DQN():
    def __init__(self, env_string, batch_size=64):
        self.memory = deque(maxlen=100000)
        self.env = gym.make(env_string)
        input_size = self.env.observation_space.shape[0]
        action_size = self.env.action_space.n
        self.batch_size = batch_size
        self.gamma = 1.0
        self.epsilon = 1.0
        self.epsilon_min = 0.01
        self.epsilon_decay = 0.995

        alpha=0.01
        alpha_decay=0.01
        if MONITOR: self.env = gym.wrappers.Monitor(self.env,
        'data/'+env_string, force=True)

        # Init model
        self.model = Sequential()
        self.model.add(Dense(24, input_dim=input_size,
        activation='tanh'))
        self.model.add(Dense(48, activation='tanh'))
        self.model.add(Dense(action_size, activation='linear'))
        self.model.compile(loss='mse', optimizer=Adam(lr=alpha,
        decay=alpha_decay)) 

我们构建的 DQN 是一个三层感知机;在以下输出中,你可以看到模型摘要。我们使用带有学*率衰减的 Adam 优化器:

Model: "sequential"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 dense (Dense)               (None, 24)                120       

 dense_1 (Dense)             (None, 48)                1200      

 dense_2 (Dense)             (None, 2)                 98        

=================================================================
Total params: 1,418
Trainable params: 1,418
Non-trainable params: 0
_________________________________________________________________ 

变量列表self.memory将包含我们的经验回放缓冲区。我们需要添加一个方法,将S,A,R,S’元组保存到内存中,并添加一个方法,从中批量获取随机样本以训练代理。我们通过定义类方法rememberreplay来实现这两个功能:

def remember(self, state, action, reward, next_state, done):
        self.memory.append((state, action, reward, next_state, done))
def replay(self, batch_size):
        x_batch, y_batch = [], []
        minibatch = random.sample(self.memory, min(len(self.memory),
        batch_size))
        for state, action, reward, next_state, done in minibatch:
             y_target = self.model.predict(state)
             y_target[0][action] = reward if done else reward + self.gamma * np.max(self.model.predict(next_state)[0])
             x_batch.append(state[0])
             y_batch.append(y_target[0])

        self.model.fit(np.array(x_batch), np.array(y_batch),
        batch_size=len(x_batch), verbose=0) 

我们的代理将在选择动作时使用epsilon-贪婪策略。该策略在以下方法中实现:

def choose_action(self, state, epsilon):
        if np.random.random() <= epsilon:
            return self.env.action_space.sample()
        else:
            return np.argmax(self.model.predict(state)) 

接下来,我们编写一个方法来训练代理。我们定义了两个列表来跟踪分数。首先,我们填充经验回放缓冲区,然后从中选择一些样本来训练代理,并希望代理能够逐渐学会做得更好:

def train(self):
    scores = deque(maxlen=100)
    avg_scores = []
    for e in range(EPOCHS):
        state = self.env.reset()
        state = self.preprocess_state(state)
        done = False
        i = 0
        while not done:
            action = self.choose_action(state,self.epsilon)
            next_state, reward, done, _ = self.env.step(action)
            next_state = self.preprocess_state(next_state)
            self.remember(state, action, reward, next_state, done)
            state = next_state
            self.epsilon = max(self.epsilon_min,
            self.epsilon_decay*self.epsilon) # decrease epsilon
            i += 1
        scores.append(i)
        mean_score = np.mean(scores)
        avg_scores.append(mean_score)
        if mean_score >= THRESHOLD and e >= 100:
            print('Ran {} episodes. Solved after {} trials ✔'.format(e, e - 100))
            return avg_scores
        if e % 100 == 0:
            print('[Episode {}] - Mean survival time over last 100 episodes was {} ticks.'.format(e, mean_score))
    self.replay(self.batch_size)
    print('Did not solve after {} episodes :('.format(e))
    return avg_scores 

现在所有必要的函数已经完成,我们只需要一个辅助函数来重新塑造 CartPole 环境的状态,以便模型的输入是正确的形状。环境的状态由四个连续变量描述:小车位置([-2.4-2.4]),小车速度,杆角度([-41.8o-41.8o]),和杆速度:

def preprocess_state(self, state):
    return np.reshape(state, [1, self.input_size]) 

现在让我们实例化我们的代理,应用于 CartPole 环境并进行训练:

env_string = 'CartPole-v0'
agent = DQN(env_string)
scores = agent.train() 
[Episode 0] - Mean survival time over last 100 episodes was 28.0 ticks.
[Episode 100] - Mean survival time over last 100 episodes was 15.71 ticks.
[Episode 200] - Mean survival time over last 100 episodes was 27.81 ticks.
Ran 259 episodes. Solved after 159 trials ✔ 

让我们绘制代理学*过程中获得的平均奖励:

import matplotlib.pyplot as plt
plt.plot(scores)
plt.show() 

图 11.9显示了代理在我的系统上进行训练的过程。代理在 254 步内达到了我们设定的 45 的阈值:

![图表 描述自动生成](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/dl-tf-keras-3e/img/B18331_11_12.png)

图 11.9:代理平均奖励图

训练完成后,你可以关闭环境:

agent.env.close() 

你可以看到,从一开始没有任何平衡杆的平衡信息,到代理使用 DQN 逐渐能够平衡杆越来越长时间(平均值),随着学*的进行。起初一片空白,代理能够构建信息/知识来完成目标,真是了不起!

DQN 用于玩 Atari 游戏

在前一节中,我们训练了一个 DQN 来平衡 CartPole。这个问题比较简单,因此我们可以使用感知机模型来解决。但是,假设环境状态仅仅是我们人类看到的 CartPole 视觉画面。如果输入状态空间是原始的像素值,我们之前的 DQN 将无法工作。我们需要的是卷积神经网络(CNN)。接下来,我们将基于 DQN 经典论文《通过深度强化学*玩 Atari》来构建一个模型。

大部分代码将与用于 CartPole 的 DQN 相似,但 DQN 网络本身以及我们如何处理从环境中获得的状态将发生显著变化。

首先,让我们看看状态空间处理方式的变化。图 11.10 显示了其中一款 Atari 游戏——Breakout:

图形用户界面,应用程序 描述自动生成

图 11.10:Atari 游戏 Breakout 的截图

现在,如果你看看这张图像,并非所有部分都包含相关信息:顶部有关于分数的冗余信息,底部有不必要的空白区域,图像还带有颜色。为了减轻模型的负担,最好移除不必要的信息,因此我们将裁剪图像,转换为灰度图,并将其调整为 84 × 84 的正方形(如论文中所示)。以下是预处理输入原始像素的代码:

def preprocess_state(self, img):
    img_temp = img[31:195]  # Choose the important area of the image
    img_temp = tf.image.rgb_to_grayscale(img_temp)
    img_temp = tf.image.resize(img_temp, [self.IM_SIZE, self.IM_SIZE],
    method=tf.image.ResizeMethod.NEAREST_NEIGHBOR)
    img_temp = tf.cast(img_temp, tf.float32)
    return img_temp[:,:,0] 

另一个重要问题是,仅通过一次观察图像,代理如何知道小球是上升还是下降?一种方法是使用 LSTM 和 CNN 结合,记录过去的状态,从而追踪小球的运动。然而,论文中采用了一种简单的技术:它将过去四个时间步的状态空间连接在一起作为 CNN 的输入,而不是单一的状态帧;也就是说,网络将过去四帧环境画面作为输入。以下是将当前状态和过去状态组合的代码:

def combine_images(self, img1, img2):
    if len(img1.shape) == 3 and img1.shape[0] == self.m:
        im = np.append(img1[1:,:, :],np.expand_dims(img2,0), axis=2)
        return tf.expand_dims(im, 0)
    else:
        im = np.stack([img1]*self.m, axis = 2)
        return tf.expand_dims(im, 0) 

模型是在 __init__ 函数中定义的。我们修改该函数,使其现在具有一个输入为(84 × 84 × 4)的 CNN,表示四个大小为 84 × 84 的状态帧:

def __init__(self, env_string,batch_size=64, IM_SIZE = 84, m = 4):
    self.memory = deque(maxlen=5000)
    self.env = gym.make(env_string)
    input_size = self.env.observation_space.shape[0]
    action_size = self.env.action_space.n
    self.batch_size = batch_size
    self.gamma = 1.0
    self.epsilon = 1.0
    self.epsilon_min = 0.01
    self.epsilon_decay = 0.995
    self.IM_SIZE = IM_SIZE
    self.m = m

    alpha=0.01
    alpha_decay=0.01
    if MONITOR: self.env = gym.wrappers.Monitor(self.env, '../data/'+env_string, force=True)

    # Init model
    self.model = Sequential()
    self.model.add( Conv2D(32, 8, (4,4), activation='relu',padding='valid', input_shape=(IM_SIZE, IM_SIZE, m)))
    self.model.add( Conv2D(64, 4, (2,2), activation='relu',padding='valid'))
    self.model.add( Conv2D(64, 3, (1,1), activation='relu',padding='valid'))
    self.model.add(Flatten())
    self.model.add(Dense(512, activation='elu'))
    self.model.add(Dense(action_size, activation='linear'))
    self.model.compile(loss='mse', optimizer=Adam(lr=alpha, decay=alpha_decay)) 

最后,我们需要在 train 函数中做一个小改动。我们需要调用新的 preprocess 函数,并使用 combine_images 函数来确保四帧图像被连接在一起:

def train(self):
    scores = deque(maxlen=100)
    avg_scores = []

    for e in range(EPOCHS):
        state = self.env.reset()
        state = self.preprocess_state(state)
        state = self.combine_images(state, state)
        done = False
        i = 0
        while not done:
            action = self.choose_action(state,self.epsilon)
            next_state, reward, done, _ = self.env.step(action)
            next_state = self.preprocess_state(next_state)
            next_state = self.combine_images(next_state, state)
            #print(next_state.shape)
            self.remember(state, action, reward, next_state, done)
            state = next_state
            self.epsilon = max(self.epsilon_min, self.epsilon_decay*self.epsilon) # decrease epsilon
            i += reward
        scores.append(i)
        mean_score = np.mean(scores)
        avg_scores.append(mean_score)
        if mean_score >= THRESHOLD and e >= 100:
            print('Ran {} episodes. Solved after {} trials ✔'.format(e, e - 100))
            return avg_scores
        if e % 100 == 0:
            print('[Episode {}] - Score over last 100 episodes was {}.'.format(e, mean_score))
        self.replay(self.batch_size)

    print('Did not solve after {} episodes :('.format(e))
    return avg_scores 

就是这样。我们现在可以训练智能体来玩 Breakout 游戏。本章节的完整代码可以在 GitHub 仓库中找到,链接为 github.com/PacktPublishing/Deep-Learning-with-TensorFlow-and-Keras-3rd-edition/tree/main/Chapter_11,文件名为 DQN_Atari_v2.ipynb

DQN 变种

在 DQN 取得前所未有的成功之后,强化学*(RL)领域的关注度增加,许多新的 RL 算法应运而生。接下来,我们将看到一些基于 DQN 的算法。它们都以 DQN 为基础,并在此基础上进行扩展。

Double DQN

在 DQN 中,智能体使用相同的 Q 值来选择和评估一个动作。这可能会导致学*中的最大化偏差。例如,假设对于某个状态 S,所有可能的动作的真实 Q 值都为零。那么,我们的 DQN 估计值将有些高于零,有些低于零,并且由于我们选择具有最大 Q 值的动作,然后使用相同的(最大化的)估计值函数来评估每个动作的 Q 值,我们就在高估 Q 值——换句话说,我们的智能体过于乐观。这可能导致训练不稳定和低质量的策略。为了解决这个问题,DeepMind 的 Hasselt 等人在论文 Deep Reinforcement Learning with Double Q-Learning 中提出了 Double DQN 算法。在 Double DQN 中,我们有两个具有相同架构但不同权重的 Q 网络。一个 Q 网络用于通过 epsilon-greedy 策略确定动作,另一个用于计算其值(Q 目标)。

如果你还记得,在 DQN 中,Q 目标是通过以下公式给出的:

在这里,使用相同的 DQN Q(S,A; W) 选择了动作 A,其中 W 是网络的训练参数;也就是说,我们在编写 Q 值函数时,连同其训练参数一起,以强调 vanilla DQN 和 Double DQN 之间的区别:

在 Double DQN 中,目标的计算公式现在将发生变化。现在,DQN Q(S,A;W) 用于确定动作,而 DQN Q(S,A;W’) 用于计算目标(注意权重不同)。因此,前面的公式将变为:

这个简单的变化减少了高估的情况,并帮助我们更快速、更可靠地训练智能体。

Dueling DQN

这个架构是 Wang 等人在 2015 年的论文 Dueling Network Architectures for Deep Reinforcement Learning 中提出的。与 DQN 和 Double DQN 一样,它也是一种无模型算法。

Dueling DQN 将 Q 函数解耦为价值函数和优势函数。我们之前讨论过的价值函数表示状态的价值,而不考虑任何动作。另一方面,优势函数提供了动作 A 在状态 S 中的相对效用(优势/好处)度量。Dueling DQN 在初始层使用卷积网络从原始像素中提取特征。然而,在后期阶段,它被分为两个不同的网络,一个用于*似价值,另一个用于*似优势。这样可以确保网络为价值函数和优势函数提供独立的估计。

在这里, 是共享卷积网络的训练参数数组(它被 VA 共享),而 AdvantageValue 估计器网络的训练参数。稍后,两个网络通过聚合层重新组合,以估算 Q 值。

图 11.11 中,你可以看到 Dueling DQN 的架构:

图形用户界面 自动生成的描述

图 11.11:可视化 Dueling DQN 的架构

你可能会想,这样做有什么好处?如果我们最后还要将它们组合起来,为什么要解构 Q 呢?其实,解耦价值和优势函数可以让我们知道哪些状态是有价值的,而不需要考虑每个状态下每个动作的影响。有很多状态,无论采取什么行动,都是好或坏的状态:例如,在一个好的度假村与亲人一起吃早餐始终是一个好状态,而被送进医院急诊室始终是一个坏状态。因此,分开价值和优势可以获得更稳健的价值函数*似。接下来,你可以看到来自论文的一幅图,展示了在 Atari 游戏《Enduro》中,价值网络学会关注道路,而优势网络只在前方有车时才关注,以避免碰撞:

图形用户界面,应用程序 自动生成的描述

图 11.12:在 Atari 游戏 Enduro 中,价值网络学会关注道路(红点),而优势网络只在有其他车辆立即在前方时才关注。图像来源:https://arxiv.org/pdf/1511.06581.pdf

聚合层的实现方式使得可以从给定的 Q 中恢复 VA。这是通过强制使优势函数估计器在选择的动作下具有零优势来实现的:

在论文中,Wang 等人报告称,如果将最大值操作替换为平均值操作,网络会更加稳定。原因是,优势的变化速度现在与平均值的变化速度相同,而不是与最优(最大)值的变化速度相同。

Rainbow

Rainbow 是当前最先进的 DQN 变体。从技术上讲,称其为 DQN 变体是不准确的。实际上,它是许多 DQN 变体的集成,组合成一个单一的算法。它将分布式强化学*[6]损失修改为多步损失,并将其与 Double DQN 和贪心策略相结合。论文中引用如下:

网络架构是一个适用于回报分布的对抗性网络架构。该网络具有一个共享的表示 ,然后被输入到具有 N[原子] 输出的值流 ,以及具有 N[原子]×N[动作] 输出的优势流 ,其中 a1ξ(fξ(s), a) 表示与原子 i 和动作 a 对应的输出。对于每个原子 z[i],值流和优势流按 Dueling DQN 的方式聚合,然后通过 softmax 层以获得用于估计回报分布的标准化参数分布。

Rainbow 集成了六种不同的强化学*算法:

  • N 步回报

  • 分布式状态-动作值学*

  • 对抗网络

  • 噪声网络

  • 双重 DQN

  • 优先经验回放

到目前为止,我们已经讨论了基于价值的强化学*算法。在下一节中,我们将学*基于策略的强化学*算法。

深度确定性策略梯度

DQN 及其变体在解决状态空间连续且动作空间离散的问题上非常成功。例如,在 Atari 游戏中,输入空间由原始像素构成,但动作是离散的—[无操作]。如何解决具有连续动作空间的问题呢?例如,假设一个强化学*智能体驾驶一辆车需要转动车轮:这个动作具有连续的动作空间。

解决这种情况的一种方法是通过离散化动作空间并继续使用 DQN 或其变体。然而,更好的解决方案是使用策略梯度算法。在策略梯度方法中,策略 直接进行*似。

神经网络用于*似策略;在最简单的形式下,神经网络学*一种选择动作的策略,以通过调整权重来最大化回报,这一过程使用最陡峭的梯度上升,因此得名:策略梯度。

在本节中,我们将重点介绍 深度确定性策略梯度DDPG)算法,这是 Google DeepMind 于 2015 年提出的另一种成功的强化学*算法。DDPG 使用两个网络进行实现;一个叫做演员网络,另一个叫做评论员网络。

演员网络确定性地逼*最优策略,也就是说,它为任何给定的输入状态输出最优的动作。从本质上讲,演员在学*。评论员则使用演员最优选择的动作来评估最优动作值函数。在继续之前,让我们将其与前一节讨论的 DQN 算法进行对比。在图 11.13中,你可以看到 DDPG 的整体架构:

一张计算机截图  自动生成的描述可信度较低

图 11.13:DDPG 模型架构

图 11.13的左侧是评论员网络,它以状态向量S和所采取的动作A为输入。网络的输出是该状态和动作对应的Q值。右侧的图示则展示了演员网络。它以状态向量 S 为输入,并预测要采取的最优动作 A。在图中,我们展示了演员和评论员网络各有四层,这仅是为了演示目的。

演员网络输出最优的动作;评论员网络则以输入状态和所采取的动作为输入,并评估其Q值。为了训练评论员网络,我们遵循与 DQN 相同的过程;也就是说,我们尝试最小化估计的Q值与目标Q值之间的差异。然后,动作的Q值的梯度会被传播回去,训练演员网络。因此,如果评论员足够优秀,它将迫使演员选择具有最优价值函数的动作。

总结

*年来,强化学*取得了显著进展。将所有这些进展总结在一章中是不可能的。然而,在这一章中,我们重点讨论了*年来成功的 RL 算法。本章从介绍 RL 领域的重要概念、挑战以及前进的解决方案开始。接下来,我们深入探讨了两个重要的 RL 算法:DQN 和 DDPG 算法。章末,我们还涵盖了深度学*领域的一些重要话题。

在下一章中,我们将继续将所学应用于实际生产。

参考文献

  1. 《MIT 技术评论》报道了 OpenAI 在强化学*方面的实验:www.technologyreview.com/s/614325/open-ai-algorithms-learned-tool-use-and-cooperation-after-hide-and-seek-games/

  2. Coggan, Melanie. (2014). 强化学*中的探索与利用。由 Doina Precup 教授监督的研究,McGill 大学 CRA-W DMP 项目。

  3. Lin, Long-Ji. (1993). 使用神经网络的机器人强化学*。编号 CMU-CS-93-103。卡内基梅隆大学计算机科学学院,宾夕法尼亚州,匹兹堡。

  4. Schaul, Tom, John Quan, Ioannis Antonoglou, 和 David Silver. (2015). 优先经验回放。arXiv 预印本 arXiv:1511.05952

  5. Sutton R.、Barto A. 第四章,强化学*。MIT 出版社:web.stanford.edu/class/psych209/Readings/SuttonBartoIPRLBook2ndEd.pdf

  6. Dabney W.、Rowland M.、Bellemare M G. 和 Munos R.(2018)。基于分位数回归的分布式强化学*。发表于第三十二届 AAAI 人工智能会议。

  7. Hessel, M.、Modayil, J.、Van Hasselt, H.、Schaul, T.、Ostrovski, G.、Dabney, W.、Horgan, D.、Piot, B.、Azar, M. 和 Silver, D.(2018)。Rainbow:结合深度强化学*中的改进。发表于第三十二届 AAAI 人工智能会议。

  8. 关于不同环境的详细信息可以在www.gymlibrary.ml/找到

  9. 一些环境的维基页面可以在github.com/openai/gym/wiki找到

  10. 关于安装说明和依赖项的详细信息可以在github.com/openai/gym找到

  11. DeepMind 的论文《深度强化学*的异步方法》链接:arxiv.org/pdf/1602.01783.pdf

  12. 这是 Andrej Karpathy 关于强化学*的博客文章:karpathy.github.io/2016/05/31/rl/

  13. Glorot X. 和 Bengio Y.(2010)。理解训练深度前馈神经网络的困难。第十三届国际人工智能与统计会议论文集:proceedings.mlr.press/v9/glorot10a/glorot10a.pdf

  14. 关于为什么强化学*仍然很难突破的精彩读物:www.alexirpan.com/2018/02/14/rl-hard.xhtml

  15. Lillicrap, T. P.、Hunt, J. J.、Pritzel, A.、Heess, N.、Erez, T.、Tassa, Y.、... & Wierstra, D.(2015)。使用深度强化学*进行连续控制。arXiv 预印本 arXiv:1509.02971

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,结识志同道合的人,并与超过 2000 名成员一起学*:packt.link/keras

第十二章:概率 TensorFlow

不确定性是生活中的一部分;无论你是在进行分类任务还是回归任务,了解你的模型在预测中的信心度非常重要。到目前为止,我们已经介绍了传统的深度学*模型,虽然它们在许多任务中表现出色,但它们无法处理不确定性。相反,它们本质上是确定性的。在本章中,你将学*如何利用 TensorFlow Probability 构建能够处理不确定性的模型,特别是概率深度学*模型和贝叶斯网络。本章内容包括:

  • TensorFlow Probability

  • TensorFlow Probability 中的分布、事件和形状

  • 使用 TensorFlow Probability 构建贝叶斯网络

  • 理解机器学*模型中的不确定性

  • 使用 TensorFlow Probability 模拟随即性和认知不确定性

本章的所有代码文件可以在 packt.link/dltfchp12 找到

让我们从理解 TensorFlow Probability 开始。

TensorFlow Probability

TensorFlow Probability (TFP),是 TensorFlow 生态系统的一部分,是一个为开发概率模型提供工具的库。它可以用于进行概率推理和统计分析。它建立在 TensorFlow 之上,提供相同的计算优势。

图 12.1 显示了构成 TensorFlow Probability 的主要组件:

图形用户界面,应用程序 描述自动生成

图 12.1:TensorFlow Probability 的不同组件

在根本上,我们有 TensorFlow 支持的所有数值运算,特别是 LinearOperator 类(属于 tf.linalg)——它包含了对矩阵执行的所有方法,而无需实际构造矩阵。这提供了计算上高效的矩阵自由计算。TFP 包含大量概率分布及其相关的统计计算。它还包括 tfp.bijectors,提供了广泛的变换分布。

Bijectors 封装了概率密度的变量变换。也就是说,当一个变量从空间 A 变换到空间 B 时,我们需要一种方法来映射变量的概率分布。Bijectors 为我们提供了完成这一任务所需的所有工具。

TensorFlow 概率还提供了JointDistribution,它允许用户抽取联合样本并计算联合对数密度(对数概率密度函数)。标准的 TFP 分布作用于张量,但JointDistribution作用于张量的结构。tfp.layers提供了神经网络层,可用于扩展标准 TensorFlow 层并为其添加不确定性。最后,它还提供了广泛的概率推理工具。在本章中,我们将通过一些这些函数和类;我们首先从安装开始。要在你的工作环境中安装 TFP,只需运行:

pip install tensorflow-probability 

让我们玩一下 TFP。要使用 TFP,我们需要导入它。此外,我们将进行一些绘图。因此,我们导入一些额外的模块:

import matplotlib.pyplot as plt
import tensorflow_probability as tfp
import functools, inspect, sys 

接下来,我们探索tfp.distributions中可用的不同分布类别:

tfd = tfp.distributions
distribution_class =  tfp.distributions.Distribution
distributions = [name for name, obj in inspect.getmembers(tfd)
                if inspect.isclass(obj) and issubclass(obj, distribution_class)]
print(distributions) 

这是输出结果:

['Autoregressive', 'BatchBroadcast', 'BatchConcat', 'BatchReshape', 'Bates', 'Bernoulli', 'Beta', 'BetaBinomial', 'BetaQuotient', 'Binomial', 'Blockwise', 'Categorical', 'Cauchy', 'Chi', 'Chi2', 'CholeskyLKJ', 'ContinuousBernoulli', 'DeterminantalPointProcess', 'Deterministic', 'Dirichlet', 'DirichletMultinomial', 'Distribution', 'DoublesidedMaxwell', 'Empirical', 'ExpGamma', 'ExpInverseGamma', 'ExpRelaxedOneHotCategorical', 'Exponential', 'ExponentiallyModifiedGaussian', 'FiniteDiscrete', 'Gamma', 'GammaGamma', 'GaussianProcess', 'GaussianProcessRegressionModel', 'GeneralizedExtremeValue', 'GeneralizedNormal', 'GeneralizedPareto', 'Geometric', 'Gumbel', 'HalfCauchy', 'HalfNormal', 'HalfStudentT', 'HiddenMarkovModel', 'Horseshoe', 'Independent', 'InverseGamma', 'InverseGaussian', 'JohnsonSU', 'JointDistribution', 'JointDistributionCoroutine', 'JointDistributionCoroutineAutoBatched', 'JointDistributionNamed', 'JointDistributionNamedAutoBatched', 'JointDistributionSequential', 'JointDistributionSequentialAutoBatched', 'Kumaraswamy', 'LKJ', 'LambertWDistribution', 'LambertWNormal', 'Laplace', 'LinearGaussianStateSpaceModel', 'LogLogistic', 'LogNormal', 'Logistic', 'LogitNormal', 'MarkovChain', 'Masked', 'MatrixNormalLinearOperator', 'MatrixTLinearOperator', 'Mixture', 'MixtureSameFamily', 'Moyal', 'Multinomial', 'MultivariateNormalDiag', 'MultivariateNormalDiagPlusLowRank', 'MultivariateNormalDiagPlusLowRankCovariance', 'MultivariateNormalFullCovariance', 'MultivariateNormalLinearOperator', 'MultivariateNormalTriL', 'MultivariateStudentTLinearOperator', 'NegativeBinomial', 'Normal', 'NormalInverseGaussian', 'OneHotCategorical', 'OrderedLogistic', 'PERT', 'Pareto', 'PixelCNN', 'PlackettLuce', 'Poisson', 'PoissonLogNormalQuadratureCompound', 'PowerSpherical', 'ProbitBernoulli', 'QuantizedDistribution', 'RelaxedBernoulli', 'RelaxedOneHotCategorical', 'Sample', 'SigmoidBeta', 'SinhArcsinh', 'Skellam', 'SphericalUniform', 'StoppingRatioLogistic', 'StudentT', 'StudentTProcess', 'StudentTProcessRegressionModel', 'TransformedDistribution', 'Triangular', 'TruncatedCauchy', 'TruncatedNormal', 'Uniform', 'VariationalGaussianProcess', 'VectorDeterministic', 'VonMises', 'VonMisesFisher', 'Weibull', 'WishartLinearOperator', 'WishartTriL', 'Zipf'] 

你可以看到,TFP 中有丰富的分布可供选择。现在让我们尝试其中一种分布:

normal = tfd.Normal(loc=0., scale=1.) 
N samples and plots them:
def plot_normal(N):
  samples = normal.sample(N)
  sns.distplot(samples)
  plt.title(f"Normal Distribution with zero mean, and 1 std. dev {N} samples")
  plt.show() 

你可以看到,随着N的增加,图形遵循一个很好的正态分布:

N=100 Chart, histogram  Description automatically generated
N=1000 Chart, histogram  Description automatically generated
N=10000 A picture containing histogram  Description automatically generated

图 12.2:从随机生成的样本中生成的正态分布,样本大小为 100、1,000 和 10,000。该分布的均值为零,标准差为一。

现在让我们探索 TFP 中可用的不同分布。

TensorFlow 概率分布

TFP 中的每个分布都有一个与之相关的形状、批次和事件大小。形状是样本大小;它代表独立同分布的抽样或观测。考虑我们在前一节中定义的正态分布:

normal = tfd.Normal(loc=0., scale=1.) 

这定义了一个单一的正态分布,均值为零,标准差为一。当我们使用sample函数时,我们从这个分布中进行随机抽样。

如果打印对象normal,请注意batch_shapeevent_shape的细节:

print(normal) 
>>> tfp.distributions.Normal("Normal", batch_shape=[], event_shape=[], dtype=float32) 

让我们尝试定义第二个normal对象,不过这次,locscale是列表:

normal_2 = tfd.Normal(loc=[0., 0.], scale=[1., 3.])
print(normal_2) 
>>> tfp.distributions.Normal("Normal", batch_shape=[2], event_shape=[], dtype=float32) 

你注意到batch_shape的变化了吗?现在,如果我们从中抽取一个样本,我们将从两个正态分布中抽取,一个均值为零,标准差为一,另一个均值为零,标准差为三。因此,批次形状决定了来自同一分布族的观测数。这两个正态分布是独立的;因此,它是一个同一分布族的分布批次。

你可以有同一类型的分布族的批次,就像前面例子中有两个正态分布一样。你不能创建一个批次,例如,一个正态分布和一个高斯分布。

如果我们需要一个依赖于两个变量且每个变量具有不同均值的单一正态分布,该如何操作?这可以通过 MultivariateNormalDiag 来实现,并且这会影响事件形状——它是从该分布中抽取单个样本或观测值的原子形状:

normal_3 = tfd.MultivariateNormalDiag(loc = [[1.0, 0.3]])
print(normal_3) 
>>> tfp.distributions.MultivariateNormalDiag("MultivariateNormalDiag", batch_shape=[1], event_shape=[2], dtype=float32) 

我们可以看到在上面的输出中,event_shape 已发生变化。

使用 TFP 分布

一旦定义了分布,你可以做很多其他操作。TFP 提供了丰富的函数来执行各种操作。我们已经使用了 Normal 分布和 sample 方法。上面的部分也展示了如何使用 TFP 创建单变量、多变量或独立分布。TFP 提供了许多重要方法,用于与创建的分布进行交互。以下是一些重要的方法:

  • sample(n):它从分布中抽取 n 个观测值。

  • prob(value):它为该值提供概率(离散)或概率密度(连续)。

  • log_prob(values):为值提供对数概率或对数似然。

  • mean():它提供分布的均值。

  • stddev():它提供分布的标准差。

硬币投掷示例

现在,让我们使用 TFP 的一些功能来描述数据,以下是一个例子:我们在学校时就熟悉的标准硬币投掷例子。我们知道如果我们投掷一枚硬币,只有两种可能性——要么是正面,要么是反面。这样的分布,只有两个离散值,称为 伯努利 分布。让我们考虑不同的场景:

场景 1

一个公平的硬币,正面概率为 0.5,反面概率为 0.5

让我们创建分布:

coin_flip = tfd.Bernoulli(probs=0.5, dtype=tf.int32) 

现在获取一些样本:

coin_flip_data = coin_flip.sample(2000) 

让我们可视化这些样本:

plt.hist(coin_flip_data) 

自动生成的形状描述

图 12.3:来自 2,000 次观测的正面和反面的分布

你可以看到我们正反面出现的次数是相等的;毕竟,它是一个公平的硬币。正面和反面的概率都是 0.5

coin_flip.prob(0) ## Probability of tail 
>>> <tf.Tensor: shape=(), dtype=float32, numpy=0.5> 

场景 2

一个偏向正面的硬币,正面概率为 0.8,反面概率为 0.2

现在,由于硬币是有偏的,正面概率为 0.8,我们将使用以下方法创建分布:

bias_coin_flip = tfd.Bernoulli(probs=0.8, dtype=tf.int32) 

现在获取一些样本:

bias_coin_flip_data = bias_coin_flip.sample(2000) 

让我们可视化这些样本:

plt.hist(bias_coin_flip_data) 

自动生成的形状描述,具有中等置信度

图 12.4:来自 2,000 次偏向正面的硬币投掷的正面和反面分布

我们可以看到,现在正面的次数远大于反面。因此,反面的概率不再是 0.5

bias_coin_flip.prob(0) ## Probability of tail 
>>> <tf.Tensor: shape=(), dtype=float32, numpy=0.19999999> 

你可能会得到一个接* 0.2 的数字。

场景 3

两个硬币,一个偏向正面,正面概率为 0.8,另一个偏向正面,正面概率为 0.6

现在,我们有两个独立的硬币。由于硬币有偏差,正面概率分别为 0.80.6,我们使用以下方法创建分布:

two_bias_coins_flip = tfd.Bernoulli(probs=[0.8, 0.6], dtype=tf.int32) 

现在获取一些样本:

two_bias_coins_flip_data = two_bias_coins_flip.sample(2000) 

让我们可视化这些样本:

plt.hist(two_bias_coins_flip_data[:,0], alpha=0.8, label='Coin 1')
plt.hist(two_bias_coins_flip_data[:,1], alpha=0.5, label='Coin 2')
plt.legend(loc='center') 

图形用户界面  描述自动生成

图 12.5:2000 次独立投掷中,两个硬币的正反面分布

蓝色的柱子对应于硬币 1,橙色的柱子对应于硬币 2。图表中的棕色部分是两个硬币结果重叠的区域。可以看到,硬币 1 的正面数量明显大于硬币 2,正如预期的那样。

正态分布

我们可以使用伯努利分布,其中数据只有两个可能的离散值:正面和反面,好与坏,垃圾邮件和火腿,等等。然而,日常生活中的大量数据是连续范围的,正态分布是非常常见的。所以,让我们也来探讨不同的正态分布。

从数学上讲,正态分布的概率密度函数可以表示为:

其中 是分布的均值, 是标准差。

在 TFP 中,参数 loc 表示均值,参数 scale 表示标准差。现在,为了说明我们如何使用分布,我们假设想要表示某个地点的天气数据,例如印度德里的夏季天气。

单变量正态分布

我们可以认为天气只依赖于温度。所以,通过收集多年来夏季的温度样本,我们可以获得数据的良好表示。也就是说,我们可以得到一个单变量正态分布。

现在,基于天气数据,德里 6 月的平均最高温度为 35 摄氏度,标准差为 4 摄氏度。所以,我们可以通过以下方式创建正态分布:

temperature = tfd.Normal(loc=35, scale = 4) 

从中获取一些观测样本:

temperature_data = temperature.sample(1000) 

现在,让我们来可视化它:

sns.displot(temperature_data, kde= True) 

图表,直方图  描述自动生成

图 12.6:德里 6 月温度的概率密度函数

检查我们的样本数据的均值和标准差是否接*我们描述的值是很有帮助的。

使用该分布,我们可以通过以下方式找到均值和标准差:

temperature.mean() 
# output
>>> <tf.Tensor: shape=(), dtype=float32, numpy=35.0> 
temperature.stddev() 
# output
>>> <tf.Tensor: shape=(), dtype=float32, numpy=4.0> 

从采样数据中,我们可以通过以下方式进行验证:

tf.math.reduce_mean(temperature_data) 
# output
>>> <tf.Tensor: shape=(), dtype=float32, numpy=35.00873> 
tf.math.reduce_std(temperature_data) 
# output
>>> <tf.Tensor: shape=(), dtype=float32, numpy=3.9290223> 

因此,采样数据遵循相同的均值和标准差。

多变量分布

到目前为止一切正常。我把我的分布展示给一位从事气象学的朋友看,他说仅使用温度是不够的,湿度也很重要。因此,现在每个天气点依赖于两个参数——当天的温度和湿度。这种数据分布可以通过 TFP 中定义的 MultivariateNormalDiag 分布类来获得:

weather = tfd.MultivariateNormalDiag(loc = [35, 56], scale_diag=[4, 15])
weather_data = weather.sample(1000)
plt.scatter(weather_data[:, 0], weather_data[:, 1], color='blue', alpha=0.4)
plt.xlabel("Temperature Degree Celsius")
plt.ylabel("Humidity %") 

图 12.7 显示了使用 TFP 生成的两个变量(温度和湿度)的多变量正态分布:

图表,散点图  描述自动生成

图 12.7:多元正态分布,其中 x 轴代表温度,y 轴代表湿度

使用 TFP 中提供的不同分布和双射函数,我们可以生成遵循与真实数据相同联合分布的合成数据来训练模型。

贝叶斯网络

贝叶斯网络BNs)利用图论、概率论和统计学的概念来封装复杂的因果关系。在这里,我们构建一个有向无环图DAG),其中节点(称为因素或随机变量)通过箭头连接,表示因果关系。每个节点代表一个具有相关概率的变量(也称为条件概率表CPT))。这些连接表示一个节点对另一个节点的依赖关系。尽管它们最早由 Pearl 于 1988 年提出,但*年来它们重新引起了关注。贝叶斯网络之所以受到广泛关注,主要原因是标准的深度学*模型无法表示因果关系。

它们的优势在于可以结合专家知识和数据来建模不确定性。由于其在做概率和因果推理方面的强大能力,贝叶斯网络已经在许多领域得到了应用。贝叶斯网络的核心是贝叶斯定理:

贝叶斯定理用于根据某些条件来确定事件的联合概率。理解贝叶斯网络最简单的方法是,它可以确定假设与证据之间的因果关系。假设有一个未知的假设 H,我们想要评估它的不确定性并做出一些决策。我们从关于假设 H 的一些先验信念开始,然后根据证据 E 更新我们对 H 的信念。

让我们通过一个例子来理解它。我们考虑一个非常标准的例子:一个有草和喷头的花园。现在,凭常识我们知道,如果喷头开着,草地就会湿。现在让我们反过来说:如果你回家后发现草地湿了,那么喷头开着的概率是多少?而实际上下雨的概率又是多少?有意思吧?让我们进一步增加证据——你发现天空多云。那么,你认为草地湿的原因是什么?

这种基于证据的推理通过贝叶斯网络(BNs)以有向无环图(DAG)的形式呈现,也叫因果图——因为它们提供了因果关系的洞察。

为了建模这个问题,我们使用了JointDistributionCoroutine分布类。这个分布类允许从单一的模型规范中同时进行数据采样和联合概率计算。让我们做出一些假设来建立模型:

  • 天空多云的概率是0.2

  • 天空多云并且下雨的概率是0.8,而天空不多云但下雨的概率是0.1

  • 草地湿且喷洒器开启的情况下,云层存在的概率为0.1,而云层不存在且喷洒器开启的概率为0.5

  • 现在,对于草地,我们有四种可能性:

喷洒器 降雨 草地湿
F F 0
F T 0.8
T F 0.9
T T 0.99

表 12.1:喷洒器-降雨-草地情境的条件概率表

图 12.8 显示了对应的 BN 有向无环图(DAG):

图示描述自动生成

图 12.8:我们玩具问题的贝叶斯网络

这个信息可以通过以下模型表示:

Root = tfd.JointDistributionCoroutine.Root
def model():
  # generate the distribution for cloudy weather
  cloudy = yield Root(tfd.Bernoulli(probs=0.2, dtype=tf.int32))
  # define sprinkler probability table
  sprinkler_prob = [0.5, 0.1]
  sprinkler_prob = tf.gather(sprinkler_prob, cloudy)
  sprinkler = yield tfd.Bernoulli(probs=sprinkler_prob, dtype=tf.int32)
  # define rain probability table
  raining_prob = [0.1, 0.8]
  raining_prob = tf.gather(raining_prob, cloudy)
  raining = yield tfd.Bernoulli(probs=raining_prob, dtype=tf.int32)
  #Conditional Probability table for wet grass
  grass_wet_prob = [[0.0, 0.8],
                    [0.9, 0.99]]
  grass_wet_prob = tf.gather_nd(grass_wet_prob, _stack(sprinkler, raining))
  grass_wet = yield tfd.Bernoulli(probs=grass_wet_prob, dtype=tf.int32) 

上述模型将像一个数据生成器一样工作。Root函数用来告诉图中的节点没有父节点。我们定义了几个实用函数,broadcaststack

def _conform(ts):
  """Broadcast all arguments to a common shape."""
  shape = functools.reduce(
      tf.broadcast_static_shape, [a.shape for a in ts])
  return [tf.broadcast_to(a, shape) for a in ts]
def _stack(*ts):
  return tf.stack(_conform(ts), axis=-1) 

为了进行推理,我们使用了MarginalizableJointDistributionCoroutine类,因为它可以帮助我们计算边际化的概率:

d = marginalize.MarginalizableJointDistributionCoroutine(model) 

现在,基于我们的观察,我们可以获取其他因素的概率。

案例 1:

我们观察到草地是湿的(对应的观察值为 1——如果草地是干的,我们会将其设为 0),我们对于云层或喷洒器的状态一无所知(对应未知状态的观察值设置为“边际化”),并且我们想知道降雨的概率(对应我们想找到的概率的观察值设置为“列举”)。将其转化为观察值:

observations = ['marginalize', # We don't know the cloudy state
                'tabulate', # We want to know the probability of rain
                'marginalize', # We don't know the sprinkler state.
                1]             # We observed a wet lawn. 

现在我们通过以下方式得到降雨的概率:

p = tf.exp(d.marginalized_log_prob(observations))
p = p / tf.reduce_sum(p) 

结果是array([0.27761015, 0.72238994], dtype=float32),即有 0.722 的概率表示下雨了。

案例 2:

我们观察到草地是湿的,对于云层或降雨的状态我们一无所知,我们想要知道喷洒器是否开启的概率。将其转化为观察值:

observations = ['marginalize',  
                'marginalize', 
                'tabulate',  
                1] 

这得到了概率array([0.61783344, 0.38216656], dtype=float32),即有0.382的概率表示喷洒器开启。

案例 3:

如果我们观察到没有下雨,且喷洒器关闭,你认为草地的状态会是什么?逻辑上,草地不应该是湿的。让我们通过将观察值传递给模型来确认这一点:

observations = ['marginalize',  
                 0,
                 0, 
                'tabulate'] 

这得到了概率array([1., 0], dtype=float32),即有 100%的概率表示草地是干的,正如我们预期的那样。

如你所见,一旦我们知道了父节点的状态,就不需要知道父节点的父节点的状态——也就是说,BN 遵循局部马尔可夫性质。在我们这里讨论的例子中,我们从结构开始,且有条件概率可以使用。我们演示了如何基于模型进行推理,并且尽管使用的是相同的模型和条件概率分布(CPD),证据仍然会改变后验概率

在贝叶斯网络中,结构(节点及其相互连接方式)和参数(每个节点的条件概率)是从数据中学*得出的。它们分别被称为结构学*和参数学*。涉及结构学*和参数学*的算法超出了本章的范围。

使用 TensorFlow Probability 处理预测中的不确定性

在本章开始时,我们讨论了深度学*模型中的预测不确定性,以及现有的深度学*架构无法解释这些不确定性。在本章中,我们将使用 TFP 提供的层来建模不确定性。

在添加 TFP 层之前,让我们先理解一下不确定性。我们可以将不确定性分为两类。

随机不确定性

这种不确定性存在于自然过程的随机性中。它是固有的不确定性,由于概率的变化性而存在。例如,在投掷硬币时,总会有一定程度的不确定性,无法准确预测下一次投掷是正面还是反面。无法消除这种不确定性。本质上,每次重复实验时,结果都会有一定的变化。

认知不确定性

这种不确定性源于知识的缺乏。知识缺乏的原因可能有很多,比如对底层过程的理解不足、对现象的知识不完整等。这种类型的不确定性可以通过理解原因来减少,例如,通过获取更多数据,进行更多实验。

这些不确定性的存在增加了风险。我们需要一种方法来量化这些不确定性,从而量化风险。

创建合成数据集

在本节中,我们将学*如何修改标准的深度神经网络以量化不确定性。我们从创建一个合成数据集开始。为了创建数据集,我们假设输出预测 y 与输入 x 之间是线性关系,如下所示:

这里, 服从均值为零,标准差为 1 的正态分布,围绕 x 变化。下面的函数将为我们生成这些合成数据。请注意,为了生成这些数据,我们使用了作为 TFP 分布一部分的 Uniform 分布和 Normal 分布:

def create_dataset(n, x_range):
    x_uniform_dist = tfd.Uniform(low=x_range[0], high=x_range[1])
    x = x_uniform_dist.sample(n).numpy() [:, np.newaxis] 
    y_true = 2.7*x+3
    eps_uniform_dist = tfd.Normal(loc=0, scale=1)
    eps = eps_uniform_dist.sample(n).numpy() [:, np.newaxis] *0.74*x
    y = y_true + eps
    return x, y, y_true 

y_true 是不包含正态分布噪声的真实值

现在我们用它来创建训练数据集和验证数据集:

x_train, y_train, y_true = create_dataset(2000, [-10, 10])
x_val, y_val, _ = create_dataset(500, [-10, 10]) 

这将为我们提供 2,000 个用于训练的数据点和 500 个用于验证的数据点。图 12.9 显示了这两个数据集的图形,背景为真实值(在没有任何噪声的情况下的 y 值):

图表,散点图 描述自动生成

图 12.9:合成数据集的图示

使用 TensorFlow 构建回归模型

我们可以构建一个简单的 Keras 模型,执行对前一部分创建的合成数据集的回归任务:

# Model Architecture
model = Sequential([Dense(1, input_shape=(1,))])
# Compile 
model.compile(loss='mse', optimizer='adam')
# Fit
model.fit(x_train, y_train, epochs=100, verbose=1) 

让我们看看拟合模型在测试数据集上的表现如何:

图表,散点图 描述自动生成

图 12.10:真实值与拟合回归线

这是一个简单的问题,我们可以看到拟合的回归线几乎与真实值重合。然而,无法判断预测的不确定性。

用于随机不确定性的概率神经网络

如果我们不使用线性回归,而是构建一个能够拟合分布的模型,会怎样呢?在我们的合成数据集中,随机不确定性的来源是噪声,我们知道我们的噪声遵循正态分布,这种分布由两个参数来描述:均值和标准差。因此,我们可以修改我们的模型,预测均值和标准差的分布,而不是实际的y值。我们可以通过使用IndependentNormal TFP 层或DistributionLambda TFP 层来实现这一点。以下代码定义了修改后的模型架构:

model = Sequential([Dense(2, input_shape = (1,)),
    tfp.layers.DistributionLambda(lambda t: tfd.Normal(loc=t[..., :1], scale=0.3+tf.math.abs(t[...,1:])))
]) 

我们需要再做一次修改。之前,我们预测的是y值;因此,均方误差损失是一个不错的选择。现在,我们预测的是分布;因此,更好的选择是负对数似然作为损失函数:

# Define negative loglikelihood loss function
def neg_loglik(y_true, y_pred):
    return -y_pred.log_prob(y_true) 

现在让我们训练这个新模型:

model.compile(loss=neg_loglik, optimizer='adam')
# Fit
model.fit(x_train, y_train, epochs=500, verbose=1) 

由于现在我们的模型返回的是一个分布,我们需要测试数据集的统计信息,包括均值和标准差:

# Summary Statistics
y_mean = model(x_test).mean()
y_std = model(x_test).stddev() 

请注意,现在预测的均值对应于第一种情况中的拟合线。现在让我们来看一下图表:

fig = plt.figure(figsize = (20, 10))
plt.scatter(x_train, y_train, marker='+', label='Training Data', alpha=0.5)
plt.plot(x_train, y_true, color='k', label='Ground Truth')
plt.plot(x_test, y_mean, color='r', label='Predicted Mean')
plt.fill_between(np.squeeze(x_test), np.squeeze(y_mean+1*y_std), np.squeeze(y_mean-1*y_std),  alpha=0.6, label='Aleatory Uncertainty (1SD)')
plt.fill_between(np.squeeze(x_test), np.squeeze(y_mean+2*y_std), np.squeeze(y_mean-2*y_std),  alpha=0.4, label='Aleatory Uncertainty (2SD)')
plt.title('Aleatory Uncertainty')
plt.xlabel('$x$')
plt.ylabel('$y$')
plt.legend()
plt.show() 

以下曲线显示了拟合线以及随机不确定性:

折线图 描述自动生成

图 12.11:使用 TFP 层建模随机不确定性

你可以看到,我们的模型在原点附*的不确定性较小,但随着距离增大,不确定性增加。

考虑到认识论不确定性

在传统的神经网络中,每个权重都由一个数字表示,并且该数字会被更新,以使得模型相对于其权重的损失最小化。我们假设这样学*到的权重就是最优权重。但真的是这样吗?为了回答这个问题,我们将每个权重替换为一个分布,而不是学*一个单一的值,我们现在让模型为每个权重分布学*一组参数。这是通过将 Keras 的 Dense 层替换为 DenseVariational 层来实现的。DenseVariational 层通过对权重使用变分后验分布来表示其值的不确定性。它试图将后验分布正则化,使其接*先验分布。因此,为了使用 DenseVariational 层,我们需要定义两个函数,一个是先验生成函数,另一个是后验生成函数。我们使用在 www.tensorflow.org/probability/examples/Probabilistic_Layers_Regression 上定义的后验和先验函数。

现在我们的模型有两层,一个是 DenseVariational 层,后面跟着一个 DistributionLambda 层:

model = Sequential([
  tfp.layers.DenseVariational(1, posterior_mean_field, prior_trainable, kl_weight=1/x_train.shape[0]),
  tfp.layers.DistributionLambda(lambda t: tfd.Normal(loc=t, scale=1)),
]) 

同样,由于我们要处理的是分布,我们使用的损失函数是负对数似然函数:

model.compile(optimizer=tf.optimizers.Adam(learning_rate=0.01), loss=negloglik) 

我们继续使用之前创建的相同的合成数据并训练模型:

model.fit(x_train, y_train, epochs=100, verbose=1) 

现在模型已经训练完毕,我们进行预测,为了理解不确定性的概念,我们对相同的输入范围进行了多次预测。我们可以在以下图表中看到结果的方差差异:

图表,散点图 自动生成的描述 图表,散点图 自动生成的描述

图 12.12:认识不确定性

图 12.12 显示了两个图表,一个是使用仅 200 个训练数据点构建模型时的图表,另一个是使用 2000 个数据点训练模型时的图表。我们可以看到,当数据量增加时,方差以及因此的认识不确定性减少。这里的 总体均值 指的是所有预测(共 100 个)的均值,而 集成均值 指的是我们只考虑了前 15 个预测值。所有机器学*模型在预测结果时都会受到一定程度的不确定性影响。获得一个估计值或可量化的不确定性范围,能帮助 AI 用户在 AI 预测中建立更多信心,并推动 AI 的广泛应用。

总结

本章介绍了 TensorFlow Probability,这是一个建立在 TensorFlow 之上的库,用于进行概率推理和统计分析。本章从对概率推理的需求开始——数据固有的不确定性和由于缺乏知识而产生的不确定性。我们演示了如何使用 TensorFlow Probability 分布生成不同的数据分布。我们学*了如何构建贝叶斯网络并执行推理。接着,我们使用 TFP 层构建了贝叶斯神经网络,以考虑到偶然性不确定性。最后,我们学会了如何借助 DenseVariational TFP 层处理认知不确定性。

在下一章中,我们将学* TensorFlow AutoML 框架。

参考文献

  1. Dillon, J. V., Langmore, I., Tran, D., Brevdo, E., Vasudevan, S., Moore, D., Patton, B., Alemi, A., Hoffman, M., 和 Saurous, R. A. (2017). TensorFlow 分布. arXiv 预印本 arXiv:1711.10604.

  2. Piponi, D., Moore, D., 和 Dillon, J. V. (2020). TensorFlow 概率的联合分布. arXiv 预印本 arXiv:2001.11819.

  3. Fox, C. R. 和 Ülkümen, G. (2011). 区分不确定性的两个维度,载于《判断与决策的论文集》,Brun, W., Kirkebøen, G. 和 Montgomery, H. 编。奥斯陆:Universitetsforlaget。

  4. Hüllermeier, E. 和 Waegeman, W. (2021). 机器学*中的偶然性和认知不确定性:概念与方法简介. 《机器学*》110 卷,第 3 期:457–506。

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,与志同道合的人交流,并与超过 2000 名成员一起学*: packt.link/keras

第十三章:AutoML 简介

AutoML 的目标是使那些不熟悉机器学*技术的领域专家能够轻松使用机器学*技术。

在本章中,我们将通过一个实际练*使用 Google Cloud 平台,并在简要讨论基础知识后进行大量动手操作。

我们将涵盖:

  • 自动数据准备

  • 自动特征工程

  • 自动模型生成

  • AutoKeras

  • Google Cloud AutoML 提供了多种解决方案,包括表格、视觉、文本、翻译和视频处理。

让我们从 AutoML 的介绍开始。

什么是 AutoML?

在前几章中,我们介绍了几种现代机器学*和深度学*中使用的模型。例如,我们见过密集网络、卷积神经网络(CNN)、递归神经网络(RNN)、自编码器和生成对抗网络(GAN)等架构。

有两点需要说明。首先,这些架构是由深度学*专家手工设计的,并不一定容易向非专家解释。其次,这些架构的组合本身是一个手工过程,涉及大量的人工直觉和试错。

今天,人工智能研究的一个主要目标是实现人工通用智能AGI)——一种能够理解并自动学*任何人类能做的工作或活动的机器智能。需要注意的是,许多研究人员认为 AGI 是不可实现的,因为智能并不只有一种形式,而是有多种形式。

就我个人而言,我倾向于认同这一观点。请查看twitter.com/ylecun/status/1526672565233758213了解 Yann LeCun 在这一主题上的立场。然而,AutoML 研究和工业应用开始之前,现实情况是非常不同的。事实上,在 AutoML 出现之前,设计深度学*架构与手工制作装饰品非常相似——这是一种通过手工制作装饰物品的活动或爱好。

以通过 X 光识别乳腺癌的任务为例。在阅读了前几章之后,你可能会认为,通过组合多个卷积神经网络(CNN)创建的深度学*管道可能是一个合适的工具。这个直觉可能是一个很好的起点。问题在于,向你的模型使用者解释为什么某个特定的 CNN 组合在乳腺癌检测领域有效并不容易。理想情况下,你希望为领域专家(在这个例子中是医学专业人士)提供易于访问的深度学*工具,而这些工具不需要强大的机器学*背景。

另一个问题是,不容易理解是否存在原始手工设计的模型的变种(例如不同的组合),这些变种可能会取得更好的结果。理想情况下,你希望为探索变种空间(例如不同组合)提供深度学*工具,并以一种更有原则且自动化的方式进行。

所以,AutoML 的核心理念是通过使整个端到端的机器学*流程更加自动化,从而减少陡峭的学*曲线和手工构建机器学*解决方案的巨大成本。为此,我们假设 AutoML 管道包括三个宏观步骤:数据准备、特征工程和自动化模型生成,如图 13.1所示:

Text, letter  Description automatically generated with medium confidence

图 13.1:AutoML 管道的三个步骤

在本章的初步部分,我们将详细讨论这三个步骤。接着,我们将重点关注 Google Cloud AutoML。

实现 AutoML

AutoML 如何实现端到端自动化的目标?嗯,你可能已经猜到了,自然的选择是使用机器学*——这非常酷。AutoML 使用机器学*来自动化机器学*管道。

其好处是什么?自动化创建和调整机器学*端到端的过程提供了更简单的解决方案,减少了生产时间,并最终可能产生比手工构建的模型更优秀的架构。

这是一个封闭的研究领域吗?恰恰相反。到 2022 年初,AutoML 是一个非常开放的研究领域,这并不令人惊讶,因为最初引起人们对 AutoML 关注的论文是在 2016 年底发表的。

自动化数据准备

典型机器学*管道的第一阶段是数据准备(回想一下图 13.1中的管道)。在这一步中,有两个主要方面需要考虑:数据清洗和数据合成:

数据清洗是通过检查错误的数据类型、缺失值和错误,以及应用数据归一化、分桶、缩放和编码等方法来提高数据质量。一个健壮的 AutoML 管道应该尽可能自动化所有这些枯燥但至关重要的步骤。

数据合成是指通过数据增强生成合成数据,用于训练、评估和验证。通常,这一步是领域特定的。例如,我们已经看到如何通过裁剪、旋转、调整大小和翻转操作生成类似 CIFAR10 的合成图像(第四章)。也可以考虑通过 GAN 生成额外的图像或视频(参见第九章),并使用增强的合成数据集进行训练。对于文本,应该采取不同的方法,可以训练 RNN(第五章)生成合成文本,或者采用更多的 NLP 技术,如 BERT、Seq2Seq 或 Transformers(参见第六章)对文本进行标注或跨语言翻译,然后再翻译回原始语言——这是另一种领域特定的增强形式。

另一种方法是生成可以进行机器学*的合成环境。这种方法在强化学*和游戏中非常流行,特别是在像 OpenAI Gym 这样的工具包中,旨在提供易于设置的模拟环境,并包含各种不同的(游戏)场景。

简单来说,我们可以说合成数据生成是 AutoML 引擎应提供的另一种选择。通常,使用的工具非常具有领域特定性,适用于图像或视频的工具不一定适用于其他领域,如文本。因此,我们需要一套(相当)庞大的工具集,用于跨领域执行合成数据生成。

自动特征工程

特征工程是典型机器学*管道中的第二步(参见图 13.1)。它包括三个主要步骤:特征选择、特征构建和特征映射。让我们依次来看每个步骤:

特征选择旨在通过丢弃对学*任务贡献较小的特征来选择一个有意义的特征子集。在这个过程中,“有意义”的定义确实取决于应用和你特定问题的领域。

特征构建的目标是从基本特征出发,构建新的派生特征。通常,这一技术用于实现更好的泛化能力,并对数据进行更丰富的表示。

特征映射旨在通过映射函数改变原始特征空间。这可以通过多种方式实现;例如,它可以使用自编码器(参见第八章)、PCA(参见第七章)或聚类(参见第七章)。

总之,特征工程是一门基于直觉、试验和错误,以及大量人类经验的艺术。现代的 AutoML 引擎旨在使整个过程更加自动化,从而减少人工干预。

自动模型生成

模型生成和超参数调优是机器学*管道中的典型第三个宏观步骤(参见图 13.1)。

模型生成包括为解决特定任务创建合适的模型。例如,你可能会使用 CNN 进行视觉识别,使用 RNN 进行时间序列分析或处理序列问题。当然,也有很多变种,每一种都通过试验和错误的过程手动构建,且适用于非常特定的领域。

超参数调优发生在模型手动构建之后。这个过程通常计算开销非常大,且可以显著改善结果质量。因为调优超参数有助于进一步优化我们的模型。

自动模型生成是任何 AutoML 流水线的最终目标。如何实现这一目标?一种方法是通过组合一组基本操作来生成模型,包括卷积、池化、拼接、跳跃连接、递归神经网络、自编码器以及我们在本书中遇到的几乎所有深度学*模型。这些操作构成了一个(通常非常大的)搜索空间,需要进行探索,目标是使这一探索尽可能高效。在 AutoML 行话中,这种探索被称为 NAS,即 神经架构搜索。关于 AutoML 的开创性论文[1]发布于 2016 年 11 月。其关键思想(见图 13.2)是使用强化学*(RL,见第十一章)。RNN 充当控制器,生成候选神经网络的模型描述。强化学*被用来最大化生成架构在验证集上的预期准确性。

在 CIFAR-10 数据集上,这种方法从头开始设计了一个新颖的网络架构,其测试集准确性可与最优秀的人工发明架构相媲美。CIFAR-10 模型的测试误差率为 3.65,比之前的最先进模型提高了 0.09 个百分点,且速度比其快了 1.05 倍。对于 Penn Treebank 数据集,该模型能够构建一个新型递归单元,其表现优于广泛使用的 LSTM 单元(见第九章)及其他最先进的基线。该单元在 Penn Treebank 上的测试集困惑度为 62.4,比之前的最先进模型低了 3.6。

论文的关键结果如图 13.2所示。基于 RNN 的控制网络以概率 p 生成样本架构 A。该候选架构 A 通过子网络训练以得到候选准确性 R。然后计算 p 的梯度,并由 R 进行缩放以更新控制器。这个强化学*操作在多个循环中计算。生成架构的过程如果层数超过一定值就会停止。

控制器 RNN 如何使用基于强化学*的策略梯度方法来生成更好的架构的细节,请参考[1]。在这里,我们强调 NAS 使用基于 Q-learning 的元建模算法,并采用 ϵ-greedy 探索策略以及经验回放(见第十一章)来探索模型搜索空间:

图示 描述自动生成

图 13.2:使用递归神经网络的神经架构搜索(NAS)

自 2016 年底的原始论文以来,模型生成技术经历了“寒武纪大爆发”。最初,目标是在一次性步骤中生成整个模型。后来,提出了一种基于细胞的方法,将生成过程分为两个宏观步骤:首先自动构建一个细胞结构,然后将预定义数量的已发现细胞堆叠在一起生成整个端到端架构[2]。这种高效神经架构搜索ENAS)在使用显著更少的 GPU 小时的情况下,表现出强大的经验性能,相较于所有现有的自动模型设计方法,特别是在 2018 年时,其计算开销比标准神经架构搜索低了 1,000 倍。这里,ENAS 的主要目标是通过层次化组合来减少搜索空间。已经提出了基于细胞的方法的变种,包括纯层次化方法,其中更高层次的细胞通过逐步结合较低层次的细胞生成。

NAS 的另一种完全不同的方法是使用迁移学*(见第五章),将现有神经网络的学*迁移到新的神经网络,以加速设计过程[3]。换句话说,我们希望在 AutoML 中使用迁移学*。

另一种方法基于遗传编程GP)和进化算法EAs),其中构成模型搜索空间的基本操作被编码为合适的表示,然后这种编码通过逐步变异的方式,逐步演化成更好的模型,这种方式类似于生物体的基因进化[4]。

超参数调优包括找到与学*优化(批量大小、学*率等)和模型特定(卷积神经网络的卷积核大小、特征图数量等;或密集网络或自编码器网络的神经元数量等)相关的超参数的最优组合。同样,搜索空间可能非常大。通常使用三种方法:贝叶斯优化、网格搜索和随机搜索。

贝叶斯优化构建了一个目标函数的概率模型,并利用该模型选择最有前景的超参数来在真实的目标函数中进行评估。

网格搜索将搜索空间划分为离散的值网格,并测试网格中的所有可能组合。例如,如果有三个超参数,每个超参数有两个候选值的网格,那么需要检查总共 2 x 3 = 6 种组合。网格搜索还有分层变种,逐步细化搜索空间中的网格区域,并提供更好的结果。其核心思想是首先使用粗网格,在找到更好的网格区域后,在该区域进行更细致的网格搜索。

随机搜索对参数搜索空间进行随机抽样,这种简单的方法在许多情况下已经证明非常有效[5]。

现在我们已经简要讨论了基础知识,接下来我们将在 Google Cloud 上进行大量的动手操作。让我们开始吧。

AutoKeras

AutoKeras [6] 提供了自动搜索深度学*模型架构和超参数的功能。该框架使用贝叶斯优化进行高效的神经网络架构搜索。您可以通过 pip 安装 Alpha 版本:

pip3 install autokeras # for 1.19 version 

架构在图 13.3 [6]中有详细说明:

图表 描述自动生成,信心中等

图 13.3:AutoKeras 系统概述

架构遵循以下步骤:

  1. 用户调用 API。

  2. 搜索器在 CPU 上生成神经网络架构。

  3. 基于神经架构在 RAM 上构建的带参数的真实神经网络。

  4. 神经网络被复制到 GPU 上进行训练。

  5. 训练后的神经网络被保存在存储设备中。

  6. 搜索器基于训练结果进行更新。

步骤 2 到 6 将重复,直到达到时间限制。

Google Cloud AutoML 和 Vertex AI

Google Cloud AutoML (cloud.google.com/automl/) 是一整套用于图像、视频和文本处理的产品。AutoML 可以用最少的努力和机器学*专业知识训练高质量的自定义机器学*模型。

Vertex AI 将构建机器学*的 Google Cloud 服务整合到一个统一的用户界面和 API 中。在 Vertex AI 中,您现在可以轻松地训练、比较、测试和部署模型。然后,您可以使用先进的方式监控和运行实验(请参见 cloud.google.com/vertex-ai)。

截至 2022 年,套件由以下组件组成,这些组件无需您了解深度学*网络的内部结构:

Vertex AI

  • 统一平台,帮助您构建、部署和扩展更多的 AI 模型

结构化数据

  • AutoML Tables:自动构建和部署最先进的机器学*模型以处理结构化数据

Sight

  • AutoML Image:在云端或边缘设备上从物体检测和图像分类中提取见解

  • AutoML Video:实现强大的内容发现和互动视频体验

语言

  • AutoML Text:通过机器学*揭示文本的结构和意义

  • AutoML Translation:动态检测并在不同语言之间进行翻译

在本章的剩余部分,我们将回顾三种 AutoML 解决方案:AutoML Tables、AutoML Text 和 AutoML Video。

使用 Google Cloud AutoML Tables 解决方案

让我们看一个使用 Google Cloud AutoML Tables 的示例。我们的目标是导入一些表格数据并在这些数据上训练一个分类器;我们将使用某银行的营销数据。请注意,这个和随后的示例可能会根据不同的使用标准由 Google 收费(请查看在线的最新费用估算 – 见cloud.google.com/products/calculator/)。

所需的第一步是启用 Vertex AI API:

图形用户界面,文本,应用程序,网站 描述自动生成

图 13.4:启用 Vertex AI API

然后我们可以从控制台选择TABULAR数据集(见图 13.5)。数据集的名称是bank-marketing.csv

图形用户界面,应用程序 描述自动生成

图 13.5:选择 TABULAR 数据集

在下一个屏幕上,我们指明要从 CSV 加载数据:

图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 13.6:AutoML 表格——从 CSV 文件加载数据

接下来,我们可以训练一个新模型,如图 13.7所示:

图形用户界面,文本,应用程序,聊天或文本消息 描述自动生成

图 13.7:训练新模型

提供了多个训练选项,适用于分类回归

图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 13.8:分类和回归的选项

让我们选择Deposit列作为目标。数据集描述在archive.ics.uci.edu/ml/datasets/bank+marketing中。数据与葡萄牙一家银行的直接营销活动(电话营销)有关。分类目标是预测客户是否会订阅定期存款。

由于选定的列是分类数据,AutoML 表格将构建一个分类模型。它将根据所选列中的类别预测目标。分类是二元的:1表示负面结果,意味着没有在银行进行存款;2表示正面结果,意味着在银行进行了存款,如图 13.9所示:

文本,应用程序 描述自动生成

图 13.9:将目标列设置为 Deposit 时训练新模型

然后我们可以检查数据集(见图 13.10),这使我们有机会检查数据集的多个特征,如名称类型缺失值不同值无效值与目标的相关性均值标准差

表格 描述自动生成

图 13.10:AutoML 表格——检查数据集

现在是通过训练标签来训练模型的时间了。首先让我们为训练设定一个预算,如图 13.11所示:

图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 13.11:设置训练预算

在这个例子中,我们接受3小时作为我们的训练预算。在此期间,您可以去喝杯咖啡,而 AutoML 会为您工作(见图 13.12)。训练预算是一个介于 1 到 72 之间的数字,表示最大节点小时数,用于训练您的模型。如果您的模型在此之前停止改进,AutoML 表格将停止训练,您只会被收取与实际使用的节点预算相对应的费用:

Text  Description automatically generated

图 13.12:AutoML 表格训练过程

在训练过程中,我们可以检查进度,如图 13.13所示:

Table  Description automatically generated

图 13.13:检查训练进度

在不到一小时的时间内,Google AutoML 应该会向我们的收件箱发送一封电子邮件:

Graphical user interface, text, application, email  Description automatically generated

图 13.14:AutoML 表格:训练结束,并向我的账户发送了电子邮件

点击建议的 URL,您可以查看我们训练的结果。AutoML 生成的模型达到了 94% 的准确率(见图 13.15)。记住,准确率是模型在自动保留的测试集上正确分类预测的比例。还提供了对数损失(例如,模型预测与标签值之间的交叉熵)。对于对数损失,较低的值表示更高质量的模型:

Chart, line chart  Description automatically generated

图 13.15:AutoML 表格 – 分析我们的训练结果

此外,接收者操作特征曲线下面积AUC ROC)也被表示出来。该值范围从零到一,值越高表示模型质量越高。该统计数据总结了 AUC ROC 曲线,这是一张显示分类模型在所有分类阈值下性能的图表。真正率TPR)(也称为“召回率”)为:

其中,TP 是真正例的数量,FN 是假负例的数量。假正率FPR)为:

其中,FP 是假正例的数量,TN 是真负例的数量。

ROC 曲线绘制了不同分类阈值下的真正率(TPR)与假正率(FPR)。在图 13.16中,您将看到 ROC 曲线一个阈值下的曲线下面积AUC):

Chart, line chart  Description automatically generated

图 13.16:AutoML 表格 – 深入分析我们的训练结果

可以深入评估并访问混淆矩阵(见图 13.17):

A picture containing chart  Description automatically generated

图 13.17:AutoML 表格 – 进一步深入分析我们的训练结果

请注意,手工制作的模型在 www.kaggle.com/uciml/adult-census-income/kernels 上的准确率为 ˜86-90%。因此,我们通过 AutoML 生成的模型无疑是一个非常好的结果!

我们还可以查看每个特征在孤立情况下的重要性,如 图 13.18 所示:

图表,条形图 描述自动生成

图 13.18:孤立考虑每个特征的具体重要性

如果我们对结果满意,可以通过 DEPLOY & TEST 将模型部署到生产环境中(参见 图 13.19)。我们可以选择创建一个可在边缘部署的 Docker 容器,或者直接使用端点。我们选择后者,并对每个可用选项使用默认设置:

图形用户界面,应用程序,Teams 描述自动生成

图 13.19:AutoML 表格 – 在生产中部署

然后,使用 REST API 可以进行在线收入预测(参见 en.wikipedia.org/wiki/Representational_state_transfer),使用本章所示示例的命令,如 图 13.20 所示:

图形用户界面,文本,应用程序 描述自动生成

图 13.20:AutoML 表格 – 查询已部署的生产模型

简单来说,我们可以说 Google Cloud ML 非常注重 AutoML 的易用性和效率。让我们总结一下所需的主要步骤(参见 图 13.21):

  1. 数据集已导入。

  2. 数据集的架构和标签已定义。

  3. 输入特征会被自动识别。

  4. AutoML 通过自动执行特征工程、创建模型并调整超参数来实现“魔法”。

  5. 自动生成的模型可以进行评估。

  6. 模型接着会在生产环境中部署。

当然,也可以通过更改架构和标签定义来重复步骤 2-6。

图形用户界面,应用程序 描述自动生成

图 13.21:AutoML 表格 – 所需的主要步骤

在本节中,我们看到了一个关注易用性和效率的 AutoML 示例。Faes 等人[7]展示了取得的进展,引用了该论文:

“据我们所知,这是首次由非人工智能专家(即医生)自动设计和实现深度学*模型用于医疗应用。尽管在二分类和多分类任务的内部验证中取得了与专家调优的医学图像分类算法相当的表现,但在更复杂的挑战(如多标签分类)和这些模型的外部验证方面尚显不足。我们相信,人工智能有可能通过提高分诊效率和个性化医疗(通过量身定制的预测模型)来推动医疗保健的发展。自动化的预测模型设计方法提高了技术的可访问性,从而促进了医疗社区的参与,并为临床医生提供了一个平台,帮助他们更好地理解人工智能集成的优势和潜在风险。”

在这种情况下,使用了 Cloud AutoML Tables。接下来,我们来看另一个例子。

使用 Google Cloud AutoML 文本解决方案

在本节中,我们将使用 AutoML 构建一个分类器。让我们从 Vertex AI 控制台创建一个文本数据集。我们要集中在单标签分类任务上:

图形用户界面,应用程序 自动生成的描述

图 13.22:AutoML 文本分类—创建数据集

我们将使用一个已经在线可用的数据集(快乐时光数据集存储在cloud-ml-data/NL-classification/happiness.csv),将其加载到名为happiness的数据集中,并进行单标签分类(如图 13.23所示)。这可能需要几分钟或更长时间。

处理完成后,我们将通过电子邮件收到通知:

图形用户界面,文本,应用程序,电子邮件 自动生成的描述

图 13.23:AutoML 文本分类—创建数据集

一旦数据集加载完成,您应该能够看到每个文本片段都被标注为七个类别中的一个,如图 13.24所示:

图形用户界面,应用程序 自动生成的描述

图 13.24:AutoML 文本分类—类别示例

现在是时候开始训练模型了:

图形用户界面,文本,应用程序,聊天或文本消息 自动生成的描述

图 13.25:AutoML 文本分类—开始训练

最终,模型建立完成,达到了 90.2%的精度和 86.7%的召回率:

表格 自动生成的描述

图 13.26:AutoML 文本分类—精度与召回率

我们还可以查看精度-召回率曲线以及基于阈值的精度-召回率(见图 13.27)。这些曲线可以用于对分类器进行校准,根据阈值(基于预测概率大于阈值的值)进行校准:

图表,折线图 描述自动生成

图 13.27:精度-召回率与阈值下的精度-召回率

混淆矩阵如图 13.28所示:

应用程序,表格 描述自动生成

图 13.28:文本分类问题的混淆矩阵

使用 Google Cloud AutoML 视频解决方案

在这个解决方案中,我们将自动构建一个用于视频分类的新模型。目的是能够根据视频的内容将不同的视频片段分类到各种类别(或类)中。第一步是创建数据集,如图 13.29所示:

图形用户界面,文本,应用程序 描述自动生成

图 13.29:AutoML 视频智能 – 一个分类问题

我们将使用大约 5,000 个视频的集合,这些视频已存储在 GCP 存储桶中的一个示例数据集中,路径为automl-video-demo-data/hmdb_split1_5classes_all.csv,如图 13.30所示:

图形用户界面,文本,应用程序 描述自动生成

图 13.30. 导入示例数据集

和往常一样,导入过程需要一些时间,当完成时我们会收到一封电子邮件通知。一旦视频导入完成,我们可以预览它们及其关联的类别:

表格 描述自动生成

图 13.31:AutoML 视频智能 – 导入的视频预览

我们现在可以开始构建模型了。有多种选项,包括使用 AutoML 进行训练,使用边缘设备的 AutoML 模型进行导出,或基于 TensorFlow 构建自定义模型。让我们使用默认选项,如图 13.32所示:

图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 13.32:AutoML 视频智能 – 提示获取更多视频

在这种情况下,我们决定进行一个实验训练,使用一些标签,并将数据集分为 20% 的训练集和 80% 的测试集:

图形用户界面,应用程序 描述自动生成

图 13.33:测试集和训练集划分

一旦模型训练完成,您可以从控制台访问结果(图 13.34)。在本例中,尽管我们只使用了 20% 的标签进行实验训练,但我们实现了 99.5% 的精度和 99.5% 的召回率。我们希望将训练时间保持较短,同时仍能取得优异的结果。您可以尝试调整模型,例如增加可用的标记视频数量,看看性能如何变化:

图形用户界面,表格 描述自动生成

图 13.34:AutoML 视频智能 – 评估结果

让我们详细查看一下结果。例如,我们可以分析不同阈值水平下的精度/召回率图:

图表,折线图 描述自动生成

图 13.35:AutoML 视频智能 – 精度和召回率

混淆矩阵展示了错误分类的镜头示例:

应用程序 描述自动生成,置信度较低

图 13.36:AutoML 视频智能 – 混淆矩阵

成本

在 GCP 上的训练成本因采用的 AutoML 类型而异;例如,2022 年在本章中介绍的所有解决方案的训练和模型服务测试的成本低于 10 美元。不过,这还未包括帐户初始时提供的六小时免费折扣(在撰写本文时,约有 150 美元可用)。根据您的组织需求,这可能显著低于购买昂贵本地硬件的成本。

总结

AutoML 的目标是使不熟悉机器学*技术的领域专家能够轻松使用机器学*技术。其主要目标是通过使整个端到端的机器学*流程(数据准备、特征工程和自动模型生成)更加自动化,从而减少陡峭的学*曲线和手工制作机器学*解决方案的巨大成本。

在回顾了 2022 年底的最先进解决方案后,我们讨论了如何使用 Google Cloud AutoML 来处理文本、视频和图像,并取得了与手工制作模型相当的效果。AutoML 可能是目前发展最快的研究课题,感兴趣的读者可以在www.automl.org/找到最新的成果。

下一章将讨论深度学*背后的数学,这是一个相对先进的话题,如果你对理解在玩神经网络时“幕后发生的事情”感兴趣,推荐阅读。

参考文献

  1. Zoph, B., Le, Q. V. (2016). 使用强化学*的神经架构搜索arxiv.org/abs/1611.01578

  2. Pham, H., Guan, M. Y., Zoph, B., Le, Q. V., Dean, J. (2018). 通过参数共享的高效神经架构搜索arxiv.org/abs/1802.03268

  3. Borsos, Z., Khorlin, A., Gesmundo, A. (2019). Transfer NAS: 使用 Transformer 代理在搜索空间之间进行知识转移arxiv.org/abs/1906.08102

  4. Lu, Z., Whalen, I., Boddeti V., Dhebar, Y., Deb, K., Goodman, E., and Banzhaf, W. (2018). NSGA-Net: 使用多目标遗传算法的神经架构搜索arxiv.org/abs/1810.03522

  5. Bergstra, J., Bengio, Y. (2012). 超参数优化的随机搜索www.jmlr.org/papers/v13/bergstra12a.xhtml

  6. Jin, H., Song, Q., 和 Hu, X.(2019)。 Auto-Keras:一个高效的神经架构搜索系统arxiv.org/abs/1806.10282

  7. Faes, L., 等人(2019)。 无编程经验的医疗专业人员自动化深度学*设计,用于医学图像分类:可行性研究。《柳叶刀数字健康》 第 1 卷,第 5 期,2019 年 9 月。第 e232-e242 页。 www.sciencedirect.com/science/article/pii/S2589750019301086

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,与志同道合的人一起交流,并与超过 2000 名成员共同学*: packt.link/keras

第十四章:深度学*背后的数学

在本章中,我们将讨论深度学*背后的数学。这个话题相当深入,可能并非所有从业者都需要掌握。然而,如果你有兴趣了解当你操作神经网络时,背后的工作原理,那么这篇内容是值得一读的。

你将学到的内容:

  • 历史介绍

  • 导数和梯度的概念

  • 梯度下降和反向传播算法通常用于优化深度学*网络

让我们开始吧!

历史

连续反向传播的基本原理由亨利·J·凯利(Henry J. Kelley)于 1960 年提出,他使用了动态规划方法。斯图尔特·德雷福斯(Stuart Dreyfus)在 1962 年提出使用链式法则。保罗·韦尔博斯(Paul Werbos)是第一个在 1974 年博士论文中将反向传播(简称 backprop)应用于神经网络的人。然而,直到 1986 年,反向传播才在大卫·E·鲁梅尔哈特(David E. Rumelhart)、杰弗里·E·辛顿(Geoffrey E. Hinton)和罗纳德·J·威廉姆斯(Ronald J. Williams)在《自然》杂志上发表的论文中取得成功。1987 年,扬·勒昆(Yann LeCun)描述了现代版本的反向传播算法,现用于神经网络的训练。

随机梯度下降SGD)的基本直觉由罗宾斯和蒙罗(Robbins and Monro)在 1951 年提出,这一概念的背景与神经网络不同。2012 年——即反向传播首次提出 52 年后——AlexNet [7]在 2012 年 ImageNet 挑战赛中使用 GPU 实现了 15.3%的前五名错误率。根据《经济学人》[8]的报道,突然间,人们开始关注这一领域,不仅仅是 AI 社区,整个技术行业都开始关注。 这一领域的创新并非一蹴而就,而是经历了超过 50 年的漫长探索!

一些数学工具

在介绍反向传播之前,我们需要回顾一些微积分中的数学工具。别担心,我们将简要回顾几个常见的数学领域,这些内容通常都在高中数学中涉及。

向量

我们将回顾两个在机器学*中非常有用的基本几何和代数概念:向量和角度的余弦。我们首先解释向量的概念。根本上,向量是一个数字列表。给定一个向量,我们可以将其解释为空间中的一个方向。数学家们通常将向量写成列向量 x 或行向量 x^T。给定两个列向量 uv,我们可以通过计算它们的点积来得到它们的结果!。可以很容易证明,!,其中!是两个向量之间的夹角。

这里有两个简单的问题:当两个向量非常接*时,结果是什么?当两个向量相同的时候,结果是什么?

处处都有导数和梯度

导数是一个强大的数学工具。我们将使用导数和梯度来优化我们的网络。让我们来看一下定义。函数y = f(x) 的导数是衡量函数值y相对于变量x变化的变化速率。

如果xy 是实数,并且绘制了 fx 的图像,则导数是该图像在每个点的“斜率”。

如果函数是线性的 ,则斜率为 。这是微积分的一个简单结果,可以通过考虑得到:

图 14.1中,我们展示了 和角度 之间的几何意义,角度是线性函数与x-笛卡尔坐标轴之间的角度:

Chart  Description automatically generated

图 14.1:线性函数及其变化率示例

如果函数不是线性的,那么通过将差异的比值 作为 的数学极限值计算变化速率,即差异变得无限小。从几何角度来看,这就是图 14.2中所示的切线:

A picture containing chart  Description automatically generated

图 14.2: 的变化速率和当 时的切线

例如,考虑 和在给定点(比如x = 2)处的导数 ,我们可以看到导数为正 ,如图 14.3所示:

Chart, line chart  Description automatically generated

图 14.3:

梯度是多个变量的导数的推广。请注意,单一变量的函数的导数是标量值函数,而多个变量的函数的梯度是向量值函数。梯度用倒三角符号 表示,称为“德尔”或来自希腊字母的nabla。这有道理,因为 delta 表示单一变量的变化,而梯度表示所有变量的变化。假设 (例如具有m维度的实数空间)且f 映射到 ;梯度定义如下:

在数学中,多个变量的函数的偏导数 是该函数相对于其中一个变量的导数,其他变量保持不变。

请注意,可以证明梯度是一个向量(移动的方向),它:

  • 指向函数最大增量方向的点。

  • 在局部最大值或局部最小值处为 0。这是因为如果它为 0,它就无法继续增大或减小。

证明留给有兴趣的读者作为练*。(提示:考虑 图 14.2图 14.3。)

梯度下降

如果梯度指向一个函数的最大增量方向,那么只需沿着梯度的反方向移动,就有可能朝着函数的局部最小值前进。这是梯度下降算法的关键观察,接下来将会使用该算法。

图 14.4 中提供了一个例子:

Chart, radar chart  Description automatically generated

图 14.4:三变量函数的梯度下降

链式法则

链式法则指出,如果我们有一个函数 y = g(x) 且 ,那么导数定义如下:

这种链式操作可以超越标量情况进行推广。假设 g 相关,g 映射到 ,而 f 映射到 。若 y = g(x) 且 z = f(y),我们可以推导出:

使用偏导数的广义链式法则将在处理多变量函数时作为反向传播算法的基本工具。稍作停顿,确保你完全理解它。

一些求导法则

可能需要提醒自己一些将在后面使用的额外求导法则:

  • 常数求导:c' = 0,其中 c 是常数。

  • 变量求导:,用于求变量的导数。

  • 线性求导:

  • 倒数求导:

  • 指数求导:

矩阵运算

关于矩阵微积分的书籍有很多。这里我们仅关注神经网络中使用的几种基本运算。回顾一下,矩阵 可用来表示权重 w[ij],其中 与相邻两层之间的连接相关。请注意,通过调整权重,我们可以控制网络的“行为”,而且对特定的 w[ij] 的微小变化将沿着网络拓扑结构传播(见 图 14.5,其中粗体边是受特定 w[ij] 微小变化影响的边):

Diagram  Description automatically generated

图 14.5:通过粗体边传播 w[ij] 的变化

现在我们已经回顾了一些微积分的基本概念,让我们开始将它们应用于深度学*。第一个问题是如何优化激活函数。嗯,我敢肯定你正在考虑计算导数,所以我们来做吧!

激活函数

第一章神经网络基础与 TF 中,我们看到了一些激活函数,包括 sigmoid、tanh 和 ReLU。在下面的部分中,我们将计算这些激活函数的导数。

Sigmoid 的导数

记住,Sigmoid 函数定义为 (见 图 14.6):

图 14.6:Sigmoid 激活函数

导数可以按如下方式计算:

因此, 的导数可以计算为一个非常简单的形式:

tanh 的导数

记住,arctan 函数定义为 ,如 图 14.7 所示:

图表,折线图 自动生成的描述

图 14.7:Tanh 激活函数

如果你记得 ,那么导数可以计算为:

因此, 的导数可以计算为一个非常简单的形式:

ReLU 的导数

ReLU 函数定义为 (见 图 14.8)。ReLU 的导数是:

注意,ReLU 在零点处不可微分。然而,在其他地方它是可微分的,并且零点处的导数值可以任意选择为 0 或 1,如 图 14.8 中所示:

图表,折线图 自动生成的描述

图 14.8:ReLU 激活函数

反向传播

现在我们已经计算了激活函数的导数,可以描述反向传播算法——深度学*的数学核心。有时,反向传播简称为 backprop

记住,一个神经网络可以有多个隐藏层,以及一个输入层和一个输出层。

除此之外,请回想一下 第一章神经网络基础与 TF,中提到的,反向传播可以被描述为一种在错误被发现后,逐步纠正错误的方式。为了减少神经网络的错误,我们必须训练网络。训练需要一个包含输入值及其相应真实输出值的数据集。我们希望使用这个网络来预测尽可能接*真实输出值的结果。反向传播算法的关键直觉是根据输出神经元的误差来更新连接的权重。在本节的剩余部分,我们将解释如何形式化这个直觉。

当反向传播开始时,所有权重都有一些随机赋值。然后,网络对训练集中的每个输入进行激活;值从输入阶段通过隐藏阶段传播到输出阶段,最终做出预测(请注意,为了简化示意图,我们只表示了一些带绿色虚线的值,但实际上所有值都会通过网络向前传播):

图示,示意图 描述自动生成

图 14.9:反向传播中的正向步骤

由于我们知道训练集中的真实观察值,可以计算预测中所犯的错误。回溯的最简单方式是将错误反向传播(见图 14.10),使用合适的优化算法(如梯度下降)来调整神经网络的权重,目的是减少误差(为了简化起见,这里仅表示少量误差值):

图示 描述自动生成

图 14.10:反向传播中的反向步骤

从输入到输出的正向传播和误差的反向传播过程会重复多次,直到误差降到预定的阈值以下。整个过程如图 14.11所示。选择一组特征作为机器学*模型的输入,模型根据这些输入生成预测结果。

将预测结果与(真实的)标签进行比较,生成的损失函数由优化器最小化,优化器更新模型的权重:

图示 描述自动生成

图 14.11:正向传播与反向传播

让我们详细看看正向和反向步骤是如何实现的。回顾一下图 14.5可能会有帮助,并回忆一下特定w[ij]的小变化将如何通过网络传播,遵循其拓扑结构(参见图 14.5,其中粗体的边是受特定权重小变化影响的部分)。

正向步骤

在正向步骤中,输入与权重相乘,然后所有结果加起来。接着应用激活函数(见图 14.12)。此步骤对每一层依次重复。第一层将输入特征作为输入并产生输出。然后,每一层的输入是前一层的输出:

箭头 描述自动生成,置信度中等

图 14.12:正向传播

如果我们只看一个单独的层,数学上我们有两个方程:

  • 转移方程 ,其中x[i]为输入值,w[i]为权重,b为偏置。以向量表示为 。注意,b可以通过设置 吸收进求和式中。

  • 激活函数: ,其中 是选择的激活函数。

一个人工神经网络由输入层I、输出层O以及位于输入层和输出层之间的任意数量的隐藏层H[i]组成。为了简化起见,假设只有一个隐藏层,因为结果可以很容易地推广。

图 14.12所示,来自输入层的特征x[i]与连接输入层和隐藏层的全连接权重w[ij]相乘(见图 14.12的左侧)。加权信号与偏置一起求和以计算结果 (见图 14.12的中间)。结果通过激活函数 传递,最终从隐藏层流向输出层(见图 14.12的右侧)。

总结一下,在前向传播过程中,我们需要执行以下操作:

  1. 对于一层中的每个神经元,将每个输入乘以其对应的权重。

  2. 然后对于层中的每个神经元,将所有输入权重加在一起。

  3. 最后,对于每个神经元,对结果应用激活函数来计算新的输出。

在前向传播结束时,我们从输出层o获得一个预测向量 ,该向量是给定输入向量x的结果,输入向量在输入层给出。现在的问题是:预测向量 与真实值向量t有多接*?

这就是反向传播的作用。

反向传播

要了解预测向量 与真实值向量t有多接*,我们需要一个函数来衡量输出层o的误差。这就是书中早期定义的损失函数。损失函数有很多选择。例如,我们可以定义均方误差,如下所示:

注意,E是一个二次函数,因此,当t相距较远时,差值的平方会更大,且符号不重要。注意,这个二次误差(损失)函数并不是唯一可以使用的函数。稍后在本章中,我们将看到如何处理交叉熵。

现在,记住关键点是,在训练过程中,我们希望调整网络的权重,以最小化最终误差。如前所述,我们可以通过沿着梯度的反方向移动来接*局部最小值!。沿梯度的反方向移动是为什么这个算法叫做梯度下降的原因。因此,定义更新权重w[ij]的方程是合理的,如下所示:

对于多变量的函数,梯度通过偏导数来计算。我们引入超参数 —— 或者在机器学*术语中称为学*率 —— 来衡量在梯度的反方向上应该走多大的步长。

考虑误差 E,我们得到以下方程:

上述方程只是捕捉到一个微小变化将影响最终误差的事实,如图 14.13所示:

图示 说明自动生成

图 14.13:w[ij] 的小变化将影响最终的误差 E

让我们定义在剩余部分中方程中使用的符号:

  • 是层 l 中节点 j 的输入。

  • 是层 l 中神经元 j 的激活函数(应用于 )。

  • 是层 l 中神经元 j 的激活输出。

  • 是连接层 中神经元 i 到层 l 中神经元 j 的权重矩阵。

  • 是层 l 中单元 j 的偏置。

  • 是输出层节点 o 的目标值。

现在我们需要计算当权重变化为 时,输出层 处误差的偏导数。这里有两种不同的情况:

  • 情况 1: 从隐藏(或输入)层到输出层的神经元权重更新方程。

  • 情况 2: 从隐藏(或输入)层到隐藏层的神经元权重更新方程。

我们将从情况 1 开始。

情况 1:从隐藏层到输出层

在这种情况下,我们需要考虑从隐藏层 j 到输出层 o 的神经元的方程。应用 E 的定义并对其求导,我们得到:

这里求和项消失了,因为当我们对 j 维度求偏导时,误差中唯一不为零的项是 j 维度。考虑到微分是线性操作,并且 —— 因为真实的 值不依赖于 —— 我们得到:

再次应用链式法则,并记住!,我们得到:

记住 ,我们再次得到 ,因为当我们对 j 维度求偏导时,误差中唯一不为零的项是 j 维度。根据定义,,因此将所有内容合并后我们得到:

因此,误差 E 相对于从隐藏层 j 到输出层 o 的权重 w[j] 的梯度,实际上是三项的乘积:预测值 与真实值 之间的差值、输出层激活函数的导数 ,以及隐藏层节点 j 的激活输出 。为了简化,我们还可以定义 ,得到:

简而言之,对于案例 1,每个隐藏层-输出层连接的权重更新方程为:

注意:如果我们要显式计算相对于输出层偏置的梯度,遵循的步骤与上述类似,唯一的区别是:

所以在这种情况下,

接下来,我们将查看案例 2。

案例 2:从隐藏层到隐藏层

在这种情况下,我们需要考虑从隐藏层(或输入层)到隐藏层的神经元方程。图 14.13 显示了隐藏层权重变化与输出误差之间的间接关系。这使得梯度的计算变得有些复杂。在这种情况下,我们需要考虑从隐藏层 i 到隐藏层 j 的神经元方程。

应用 E 的定义并进行微分,我们得到:

在这种情况下,和不会消失,因为隐藏层中权重的变化直接影响输出。替换 并应用链式法则,我们得到:

和内部权重 w[ij] (图 14.13) 之间的间接关系可以通过以下展开式数学表达:

因为

这表明需要再次应用链式法则:

应用链式法则:

替换

推导:

替换

应用链式法则:

替换

推导:

现在我们可以结合上述两个结果:

并得到:

记住定义:,我们得到:

这最后一个替代公式与 特别有趣,因为它反向传播了后续层中计算得到的信号 。相对于权重 w[ij] 的变化率 因此是三个因子的乘积:来自下面一层的输出激活值 y[i],隐藏层激活函数的导数 ,以及通过 权重加权的之前在后续层计算的反向传播信号 。我们可以通过定义 来利用这种反向传播误差信号的思想,从而得出 。这表明,为了计算深度神经网络中任何一层 的梯度,我们只需将反向传播的误差信号 与前馈信号 相乘,就能到达 l 层。注意,数学公式稍显复杂,但结果实际上是非常非常简单的!直观理解见 图 14.14。给定一个函数 ,在神经元处局部计算得到输入 ,梯度 被反向传播。然后,通过链式法则与局部梯度 结合,进一步进行反向传播。

这里,L 表示来自上一层的误差:

Diagram  Description automatically generated

图 14.14:反向传播背后的数学示例

注意:如果我们想显式地计算输出层偏置的梯度,可以证明 。我们将这作为练*留给你。

简而言之,对于案例 2(隐藏层到隐藏层的连接),权重的变化量是 ,每个隐藏层连接的权重更新方程仅为:

我们已经到达本节的结尾,所有数学工具都已定义好,以便做出最终的陈述。反向传播的本质无非是从最后的输出层开始,一层一层地应用权重更新规则,一直到第一层输入层。虽然推导过程很困难,但一旦定义清楚,应用起来极其简单。深度学*的前向-反向算法的核心可以总结为以下内容:

  1. 计算从输入到输出的前馈信号。

  2. 根据预测值 和真实值 ,计算输出误差 E

  3. 反向传播误差信号;将它们与前一层的权重和相关激活函数的梯度相乘。

  4. 计算所有参数 的梯度 ,基于反向传播的误差信号和来自输入的前向传播信号。

  5. 使用计算出的梯度 更新参数。

请注意,上述算法适用于任何可微的误差函数 E 和任何可微的激活 函数。唯一的要求是它们都必须是可微的。

使用反向传播的梯度下降不能保证找到损失函数的全局最小值,而只是局部最小值。然而,这在实际应用中不一定是一个问题。

交叉熵及其导数

当交叉熵作为损失函数时,可以使用梯度下降。正如在 第一章 中讨论的,《使用 TF 的神经网络基础》,逻辑回归损失函数定义为:

其中 c 指的是独热编码类(或标签),而 p 指的是 softmax 应用后的概率。由于交叉熵应用于 softmax 应用后的概率和独热编码类,我们需要考虑计算相对于最终权重 score[i] 的梯度的链式法则。从数学上来说,我们有:

分别计算每一部分,让我们从 开始:

(注意,对于固定的 ,求和中的所有项都是常数,除了选择的项)。

因此,我们得到:

(将偏导数应用到求和式中,考虑到

因此,我们得到:

现在让我们计算另一个部分 ,其中 p[i] 是定义为以下的 softmax 函数:

导数是:

使用克罗内克 delta 我们得到:

因此,考虑到我们正在计算偏导数,除了一个分量外,所有其他分量都会归零,我们得到:

将结果结合起来,我们得到:

其中 c[i] 表示独热编码类,而 p[i] 指的是 softmax 概率。简而言之,导数既优雅又容易计算:

批量梯度下降、随机梯度下降和小批量

如果我们将之前的讨论进行概括,那么我们可以说优化神经网络的问题就是调整网络的权重 w,使得损失函数最小化。方便的是,我们可以将损失函数看作一个求和的形式,因为这种形式实际上代表了所有常用的损失函数:

在这种情况下,我们可以使用与之前讨论的步骤非常相似的推导方法,遵循更新规则,其中是学*率,是梯度:

在许多情况下,评估上述梯度可能需要对所有加和函数的梯度进行昂贵的评估。当训练集非常大时,这可能非常昂贵。如果我们有三百万个样本,我们就得循环三百万次或使用点积。这可真不简单!我们该如何简化这个过程呢?有三种梯度下降方法,它们在处理训练数据集的方式上各不相同。

批量梯度下降

批量梯度下降BGD)计算误差的变化,但只有在整个数据集评估完之后,才会更新整个模型。从计算上讲,它非常高效,但需要将整个数据集的结果保存在内存中。

随机梯度下降

与在整个数据集评估完之后更新模型不同,随机梯度下降SGD)在每个训练样本之后进行更新。其关键思想非常简单:SGD 在每一步都抽样一小部分加和函数。

小批量梯度下降

小批量梯度下降MBGD)在深度学*中非常常用。MBGD(或小批量)将 BGD 和 SGD 结合在一个单一的启发式方法中。数据集被分成小批量,大小约为bs,通常为 64 到 256。然后,每个批量会单独进行评估。

请注意,bs是另一个需要在训练过程中微调的超参数。MBGD 位于 BGD 和 SGD 的两个极端之间——通过调整批量大小和学*率参数,我们有时可以找到比这两种极端方法更接*全局最小值的解。

与梯度下降平滑地最小化代价函数不同,小批量梯度下降具有较为噪声和崎岖的下降趋势,但代价函数仍然是下降的。噪声的原因在于小批量是所有示例的一个子集,这种抽样可能会导致损失函数出现波动。

思考反向传播和卷积神经网络(ConvNets)

在这一部分,我们将讨论反向传播和卷积神经网络。为了简化起见,我们将重点关注一个卷积例子,输入X大小为 3x3,一个大小为 2x2 的单一滤波器W,没有填充,步长为 1,且没有扩张(见第三章卷积神经网络)。推广部分留作练*。

标准的卷积操作如图 14.15所示。简单来说,卷积操作是前向传播过程:

输入 X11 X12 X13 X21 X22 X23 X31 X32 X33 权重 W11 W12 W21 W22 卷积 W11X11+W12X12+W21X21+W22X22 W11X12+W12X13+W21X21+W22X23 W11X21+W12X22+W21X31+W22X32 W11X22+W12X23+W21X32+W22X33

图 14.15:ConvNet 范例的前向传播

在检查完 图 14.15 后,我们现在可以将注意力集中在当前层的反向传播上。关键假设是,我们接收到一个反向传播信号 作为输入,需要计算 。这个计算留作练*,请注意,滤波器中的每个权重都影响输出地图中的每个像素,或者说,滤波器权重的任何变化都会影响所有输出像素。

思考反向传播和 RNN

第五章递归神经网络,我们记得一个 RNN 的基本方程是 ,第 t 步的最终预测是 ,正确值为 y[t],误差 E 是交叉熵。这里 UVW 是用于 RNN 方程的学*参数。这些方程可以像 图 14.16 中展示的那样进行可视化,其中我们展开了递归。核心思想是总误差只是每个时间步的误差的总和。

如果我们使用 SGD,需要对给定训练样本的每个时间步骤的误差和梯度进行求和:

图 14.16:展开的 RNN 方程式

我们不打算详细写出所有梯度背后繁琐的数学计算,而是只专注于几个特殊情况。例如,通过与前几节相似的数学计算,可以使用链式法则证明 V 的梯度仅取决于当前时间步 s[3]、y[3] 和

然而, 的依赖关系跨越时间步骤,因为例如, 依赖于 s[2],而 s[2] 又依赖于 W[2] 和 s[1]。因此,梯度会稍微复杂一些,因为我们需要对每个时间步的贡献进行求和:

要理解上述方程,可以想象我们正在使用传统前馈神经网络用于 RNN 的标准反向传播算法,但需要额外添加跨时间步的 W 梯度。这是因为通过展开 RNN,我们可以有效地使时间上的依赖关系显式化。这也是为什么 RNN 的反向传播经常被称为递归神经网络的时序反向传播BPTT)。

直觉在 图 14.17 中展示,其中显示了反向传播信号:

自动生成的图示说明

图 14.17:循环神经网络方程和反向传播信号

希望您迄今为止都有跟上,因为现在的讨论将会稍微困难一些。如果我们考虑:

然后我们注意到, 应该再次使用链式法则来计算,产生一系列的乘法。在这种情况下,我们需要计算一个向量函数关于另一个向量的导数,因此我们需要一个矩阵,其元素是所有逐点导数(在数学中,这个矩阵称为雅可比矩阵)。从数学上可以证明:

因此,我们得出:

上述方程中的乘法尤其具有问题,因为 sigmoid 和 tanh 在两端都会饱和,其导数会趋*于 0。当这种情况发生时,它们会使得前面层的其他梯度趋*于 0。这会导致梯度在几次迭代后完全消失,网络也就无法从“远距离”学*。

第五章循环神经网络,讨论了如何使用长短期记忆网络LSTM)和门控循环单元GRU)来处理梯度消失问题,并高效地学*长期依赖关系。类似地,当雅可比矩阵中的某个单一项变得很大时,梯度也可能爆炸。第五章讨论了如何使用梯度裁剪来处理这个问题。

我们现在已经完成了这次旅程,你应该已经理解了反向传播的工作原理,以及它如何在密集网络、卷积神经网络(CNN)和循环神经网络(RNN)中应用。在接下来的章节中,我们将讨论 TensorFlow 如何计算梯度,以及为什么这对反向传播很有用。

关于 TensorFlow 和自动微分的说明

TensorFlow 可以自动计算导数,这一特性被称为自动微分。通过使用链式法则来实现这一功能。计算图中的每个节点都有一个附加的梯度操作,用于计算输入与输出之间的导数。之后,参数的梯度会在反向传播过程中自动计算。

自动微分是一个非常重要的特性,因为你不需要为每个新的神经网络模型手动编写新的反向传播变种。这使得快速迭代和进行许多实验变得更加高效。

总结

在本章中,我们讨论了深度学*背后的数学原理。简单来说,深度学*模型根据输入向量计算一个函数,进而生成输出。令人感兴趣的是,它实际上可能拥有数十亿个需要调整的参数(权重)。反向传播是深度学*中用于高效训练人工神经网络的核心数学算法,它采用梯度下降法,并利用链式法则。该算法基于两个交替重复的步骤:前向步骤和后向步骤。

在前向步骤中,输入通过网络传播以预测输出。这些预测可能与评估网络质量所需的真实值不同。换句话说,存在误差,我们的目标是最小化它。此时,反向传播发挥了作用,通过调整网络的权重来最小化误差。误差通过损失函数进行计算,如均方误差MSE)或用于非连续值(如布尔值)的交叉熵(第一章使用 TF 的神经网络基础)。一种梯度下降优化算法用于通过计算损失函数的梯度来调整神经元的权重。反向传播计算梯度,梯度下降使用梯度来训练模型。通过减少预测的误差率,可以提高准确性,使机器学*模型不断改进。SGD 是最简单的做法,通过在梯度方向上迈出一步来实现。此章节不涉及其他优化算法(如 Adam 和 RMSProp,第一章)背后的数学原理。然而,它们涉及使用梯度的第一阶和第二阶矩。第一阶矩涉及先前梯度的指数衰减平均,第二阶矩则涉及先前平方梯度的指数衰减平均。

有三个数据的主要特性证明了使用深度学*的必要性;否则,我们完全可以使用常规机器学*:

  • 高维输入(如文本、图像、音频信号、视频和时间序列,通常是一个很好的例子)。

  • 处理无法用低阶多项式函数*似的复杂决策面。

  • 拥有大量可用的训练数据。

深度学*模型可以看作是由多个基本组件堆叠在一起的计算图,例如密集网络(第一章)、CNN(第三章)、嵌入层(第四章)、RNN(第五章)、GAN(第九章)、自编码器(第八章),有时还采用像“窥视孔”、“跳跃”和“残差”这样的捷径连接,因为它们有助于数据更平滑地流动。图中的每个节点接收张量作为输入,并生成张量作为输出。如前所述,训练通过调整每个节点中的权重来进行,使用反向传播,其中关键思想是通过梯度下降减少最终输出节点的误差。GPU 和 TPU(第十五章)可以显著加速优化过程,因为它本质上依赖于(数百万)矩阵计算。

还有一些其他数学工具可能有助于提高你的学*过程。正则化(L1, L2 和 Lasso (第一章))可以通过保持权重归一化显著改善学*。批量归一化 (第一章) 基本上帮助追踪数据集在多个深度层次之间的均值和标准差。关键思想是使数据在通过计算图时呈现*似正态分布。丢弃法(第一章第三章第五章第六章第九章,和第二十章)通过在计算中引入一些冗余元素来帮助防止过拟合,从而提高泛化能力。

本章介绍了直觉背后的数学基础。如前所述,这个话题相当高级,并不一定是从业者必需的。然而,如果你有兴趣了解在玩转神经网络时,“引擎盖下”究竟发生了什么,建议阅读。

下一章将介绍张量处理单元TPU),这是谷歌开发的一种特殊芯片,旨在超快执行本章描述的许多数学运算。

参考文献

  1. Kelley, Henry J. (1960). 最优飞行路径的梯度理论. ARS Journal. 30 (10): 947–954. Bibcode:1960ARSJ...30.1127B. doi:10.2514/8.5282.

  2. Dreyfus, Stuart. (1962). 变分问题的数值解法. 《数学分析与应用杂志》. 5 (1): 30–45. doi:10.1016/0022-247x(62)90004-5.

  3. Werbos, P. (1974). 超越回归:行为科学中的新预测与分析工具. 哈佛大学博士论文.

  4. Rumelhart, David E.; Hinton, Geoffrey E.; Williams, Ronald J. (1986-10-09). 通过反向传播误差学*表示. 《自然》. 323 (6088): 533–536. Bibcode:1986Natur.323..533R. doi:10.1038/323533a0.

  5. LeCun, Y. (1987). 连接主义学*模型(Modèles Connexionnistes de l’apprentissage),博士论文,巴黎 P. et M. Curie 大学。

  6. Herbert Robbins 和 Sutton Monro. (1951). 一种随机逼*方法. 数学统计学年鉴, 第 22 卷, 第 3 期. 第 400–407 页.

  7. Krizhevsky, Alex; Sutskever, Ilya; Hinton, Geoffrey E. (2017 年 6 月). 使用深度卷积神经网络进行 ImageNet 分类(PDF)。《ACM 通讯》。60 (6): 84–90. doi:10.1145/3065386. ISSN 0001-0782.

  8. 从无所作为到神经网络. 《经济学人》. (2016 年 6 月 25 日)

加入我们书籍的 Discord 频道

加入我们的 Discord 社区,与志同道合的人一起学*,并与超过 2000 名成员一起成长,网址:packt.link/keras

第十五章:张量处理单元

本章介绍了张量处理单元TPU),这是谷歌开发的一种专用芯片,旨在超快速执行神经网络的数学运算。与图形处理单元GPU)一样,这里的理念是拥有一个专门的处理器,只关注极其快速的矩阵运算,而不支持中央处理单元CPU)通常支持的其他运算。然而,TPU 的额外改进在于,从芯片中去除了通常存在于 GPU 中的图形操作硬件支持(如光栅化、纹理映射、帧缓冲操作等)。可以把 TPU 看作是一个专门针对深度学*的协处理器,专注于矩阵或张量运算。本章将对比 CPU 和 GPU 与四代 TPU 及边缘 TPU 的性能。这些加速器在 2022 年 4 月已经推出。本章将包括使用 TPU 的代码示例。

本章将让你学*以下内容:

  • C/G/T 处理单元

  • 四代 TPU 和边缘 TPU

  • TPU 性能

  • 如何在 Colab 中使用 TPU

那么,让我们开始吧。

C/G/T 处理单元

本节讨论 CPU、GPU 和 TPU。在讨论 TPU 之前,回顾一下 CPU 和 GPU 是很有帮助的。

CPU 和 GPU

你可能对 CPU 的概念略有了解,它是每台计算机、平板电脑和智能手机中的通用芯片。CPU 负责所有计算:从逻辑控制到算术、从寄存器操作到内存操作等。CPU 遵循著名的摩尔定律[1],即密集型集成电路中的晶体管数量大约每两年就会翻一番。

许多人认为我们现在正处于一个无法长期维持这一趋势的时代,事实上,它在过去十年中已经出现了衰退。因此,如果我们要支持日益增长的数据处理需求,我们需要一些额外的技术来支持越来越快的计算。

一个改进来源于 GPU,GPU 是专用芯片,完美适合于快速的图形操作,如矩阵乘法、光栅化、帧缓冲操作、纹理映射等。除了计算机图形学中矩阵乘法应用于图像像素外,GPU 也非常适合深度学*。这是一个偶然的幸运故事(幸运是指事件通过偶然的方式以愉快或有益的方式发生和发展)——这是一个技术为了一个目标创造出来,却在与最初设想目标无关的领域取得巨大成功的典型例子。

TPU

使用 GPU 进行深度学*时遇到的一个问题是,这些芯片是为图形和游戏设计的,而不仅仅是为了快速的矩阵计算。考虑到 GPU 中的 G 代表图形,这种情况显然是可以理解的!GPU 极大地推动了深度学*的发展,但在神经网络的张量运算中,芯片的大部分部分根本没有被使用。对于深度学*来说,实际上不需要光栅化、不需要帧缓冲区操作,也不需要纹理映射。唯一需要的,是一种非常高效的矩阵和张量运算方式。因此,GPU 不一定是深度学*的理想解决方案也就不足为奇了,因为 CPU 和 GPU 的设计早于深度学*的成功。

在深入技术细节之前,我们先来讨论一下张量处理单元(Tensor Processing Unit,简称 TPU)版本 1 的迷人起源。2013 年,Google 大脑部门的负责人 Jeff Dean 估算(见图 15.1),如果所有拥有手机的人每天多通话 3 分钟,那么 Google 就需要两到三倍数量的服务器来处理这些数据。这将是一个无法承受的成功灾难案例,也就是说,巨大的成功导致了无法妥善管理的问题。显然,CPU 和 GPU 都不是合适的解决方案。因此,Google 决定需要一种全新的技术——一种能够在没有显著成本增加的情况下实现 10 倍性能增长的技术。这就是 TPU v1 诞生的背景!令人印象深刻的是,从最初设计到生产只花费了 15 个月的时间。你可以在 Jouppi 等人 2014 年的报告中找到更多关于这个故事的细节[3],该报告还详细描述了 2013 年 Google 遇到的不同推理工作负载:

表格 描述自动生成

图 15.1:2013 年 Google 看到的不同推理工作负载(来源 [3])

让我们来谈谈一些技术细节。TPU v1 是一种特殊的设备(或特定应用集成电路,简称ASIC),专为超高效的张量运算设计。TPU 遵循“少即是多”的理念。这个理念有一个重要的后果:TPU 没有 GPU 所需的所有图形组件。因此,从能效角度来看,TPU 非常高效,并且在很多情况下比 GPU 快得多。迄今为止,TPU 已经有四代产品。我们来回顾一下它们。

四代 TPU,以及 Edge TPU

如前所述,TPU 是专门针对矩阵运算进行优化的领域专用处理器。现在,你可能还记得矩阵乘法的基本操作是一个矩阵的行和另一个矩阵的列之间的点积。例如,给定一个矩阵乘法 ,计算 Y[i, 0] 的方法是:

该操作的顺序实现对于大矩阵来说是耗时的。蛮力计算的时间复杂度为 O(n³),对于 n x n 矩阵来说,因此它不适用于进行大规模计算。

第一代 TPU

第一代 TPU(TPU v1)于 2016 年 5 月在 Google I/O 大会上发布。TPU v1 [1] 支持使用 8 位算术进行矩阵乘法。TPU v1 专门用于深度学*推理,但不适用于训练。训练需要执行浮点运算,正如下面段落中所讨论的那样。

TPU 的一个关键功能是“脉动”矩阵乘法。让我们来看看这是什么意思。请记住,深度学*的核心是一个核心产品 ,例如,计算 Y[i, 0] 的基本操作是:

“脉动”矩阵乘法允许多个 Y[i, j] 值并行计算。数据以协调的方式流动,实际上,在医学中,“脉动”一词指的是心脏收缩以及血液如何在我们的静脉中有节奏地流动。在这里,脉动指的是数据在 TPU 内部的脉冲流动。可以证明,脉动乘法算法比蛮力算法更节省成本 [2]。TPU v1 拥有 矩阵乘法单元MMU),在 256 x 256 核心上运行脉动乘法,这样就可以在一次操作中并行计算 65,536 次乘法。此外,TPU v1 放置在机架中,不能直接访问。相反,CPU 作为主机,控制数据传输并向 TPU 发送指令,以执行张量乘法、计算卷积和应用激活函数。CPU TPU v1 之间的通信通过标准 PCIe 3.0 总线进行。从这个角度来看,TPU v1 在本质上更接* 浮点运算单元FPU)协处理器,而不是 GPU。然而,TPU v1 具备运行完整推理模型的能力,从而减少对主机 CPU 的依赖。图 15.2 展示了 TPU v1,如 [3] 中所示。如图所示,处理单元通过 PCI 端口连接,并通过标准 DDR4 DRAM 芯片获取权重。乘法操作在 MMU 内进行脉动处理。然后将激活函数应用于结果。MMU 和用于激活的统一缓冲区占据了大量空间。还有一个区域专门计算激活函数。

图 15.2:TPU v1 设计架构(来源 [3])

TPU v1 采用 28 纳米工艺节点制造,芯片面积为≤331 mm²,时钟频率为 700 MHz,配备 28 MiB 的片上内存、4 MiB 的 32 位累加器,并且拥有 256 x 256 的 8 位乘法器心脏阵列。因此,我们可以得到 700 MHz65,536(乘法器) 92 万亿次操作/秒。对于矩阵乘法来说,这是一个惊人的性能;图 15.3展示了 TPU 电路板及 MMU 执行心脏阵列矩阵乘法时的数据流。此外,TPU v1 还配备 8 GiB 的双通道 2133 MHz DDR3 SDRAM,带宽为 34 GB/s。外部内存是标准配置,主要用于存储和提取推理过程中使用的权重。另请注意,TPU v1 的热设计功耗为 28–40 瓦,这与 GPU 和 CPU 相比,消耗非常低。此外,TPU v1 通常安装在用于 SATA 硬盘的 PCI 插槽中,因此不需要对主机服务器进行任何修改[3]。每台服务器最多可以安装四张卡。图 15.3*展示了 TPU v1 卡以及心脏阵列计算过程:

图示,示意图  描述自动生成

图 15.3:左侧为 TPU v1 电路板,右侧为心脏阵列计算过程中数据处理的示例

如果你想查看 TPU 与 GPU 和 CPU 的性能比较,可以参考[3],并通过对数-对数坐标图查看,TPU 的性能比 Tesla K80 GPU 高出两个数量级。该图展示了一个“屋顶”性能,性能不断增长,直到达到峰值后保持不变。

屋顶越高,性能越好:

图表,折线图  描述自动生成

图 15.4:TPU v1 的峰值性能可达到 Tesla K80 的 3 倍

第二代 TPU

第二代 TPU(TPU2)于 2017 年发布。在这种情况下,内存带宽提高到 600 GB/s,性能达到 45 TFLOPS。四个 TPU2 被排列在一个模块中,性能为 180 TFLOPS。然后,将 64 个模块组合成一个 POD,性能达到 11.5 PFLOPS。TPU2 采用浮点运算,因此既适用于训练也适用于推理。

TPU2 拥有一个 128*128 核心的 MNU 用于矩阵乘法,并且配备向量处理单元VPU)处理其他任务,如应用激活函数等。VPU 处理 float32 和 int32 运算。另一方面,MXU 在 16-32 位混合精度浮点格式下运行。

每个 TPU v2 芯片有两个核心,每块板上最多可以安装四个芯片。在 TPU v2 中,Google 采用了一种新的浮点模型,叫做 bfloat16,目的是牺牲一些精度,但仍然非常适合深度学*。这种精度的降低使得我们能够提升 TPU2 的性能,而 TPU2 比 TPU v1 更加节能。事实上,可以证明,较小的尾数有助于减少物理硅面积和乘法器功耗。因此,bfloat16 采用与 IEEE 754 单精度浮点格式相同的标准,但将尾数字段从 23 位截断为 7 位。

保留指数位使得该格式能够保持与 32 位单精度浮点数相同的范围。这使得两种数据类型之间的转换相对简单:

图 15.5:Cloud TPU v2 和 Cloud TPU v3

Google 通过Google Compute Engine (GCE) 和 Google Kubernetes Engine (GKE) 提供这些 TPU v2 和 TPU v3 的访问权限。此外,还可以通过 Colab 免费使用它们。

第三代 TPU

第三代 TPU(TPU3)于 2018 年发布[4]。TPU3 的速度是 TPU2 的两倍,并且它们被组合成 4 倍更大的集群。总体来说,这意味着性能提升了 8 倍。Cloud TPU v3 Pods 可以提供超过 100 petaflops 的计算能力。另一方面,2018 年以 alpha 版本发布的 Cloud TPU v2 Pods 可以达到 11.5 petaflops——这是另一个令人印象深刻的进步。到 2019 年,TPU2 和 TPU3 已投入生产,并有不同的定价:

图 15.6:Google 在 2019 年 Google I/O 上宣布了 TPU v2 和 v3 Pods 的 beta 版

一块 TPU v3 板有四个 TPU 芯片、八个核心,并且使用液冷系统。Google 采用了源自超级计算机技术的超高速互联硬件,将成千上万个 TPU 连接起来,具有非常低的延迟。

每次在单个 TPU 上更新参数时,所有其他 TPU 都会通过一种通常用于并行计算的 reduce-all 算法得到通知。所以,你可以把 TPU v3 看作是当今最快的超计算机之一,专门用于矩阵和张量运算,内部包含成千上万个 TPU。

第四代 TPU

Google 的第四代 TPU ASIC 的矩阵乘法 TFLOPs 是 TPU v3 的两倍多,内存带宽有了显著提升,并且在互联技术方面有更多进展。每个 TPU v4 芯片提供的计算能力是 TPU v3 芯片的 2 倍以上——最高可达 275 峰值 TFLOPS。每个 TPU v4 Pod 的峰值性能可达 1.1 exaflops/s。Google 声称,TPU v4 Pods 广泛用于开发如 MUM 和 LaMDA 等研究突破,并改进搜索、助手和翻译等核心产品(见blog.google/technology/developers/io21-helpful-google/)。截至 2022 年 4 月,TPU v4 仅在预览版中提供(图 15.7):

图 15.7:TPU v4 芯片及部分 TPU v4 Pod – 来源:twitter.com/google/status/1394785686683783170

在本节中,我们介绍了四代 TPU。在结束之前,我想提到,使用可抢占的云 TPU 进行容错机器学*工作负载可以节省成本。这些工作负载包括但不限于带检查点的长时间训练任务或大数据集上的批量预测。

Edge TPU

除了已经讨论的三代 TPU 外,Google 在 2018 年宣布了一种专门用于边缘计算的 TPU。该 TPU 特别适用于物联网IoT)以及支持移动设备和物联网中的 TensorFlow Lite。单个 Edge TPU 每秒可以执行 4 万亿(定点)操作(4 TOPS),仅消耗 2 瓦功率。Edge TPU 专为小型、低功耗设备设计,非常适合在设备端进行机器学*,且既快速又节能。Edge TPU 支持 TensorFlow Lite 开发框架(参见图 15.8)。2019 年底,Google 发布了 Pixel 4 智能手机,内置了一款名为 Pixel Neural Core 的 Edge TPU:

A picture containing logo  Description automatically generated

图 15.8:一分硬币上的两个 Edge TPU – 来源:coral.ai/docs/edgetpu/faq/#what-is-the-edge-tpu

通过这些内容,我们结束了 TPU v1、v2、v3、v4 及 Edge TPU 的介绍。在接下来的章节中,我们将简要讨论性能。

TPU 性能

讨论性能总是很困难,因为首先需要定义我们要衡量的指标,以及我们将用作基准的工作负载集。例如,Google 在使用 ResNet-50 [4] 时报告了 TPU v2 的令人印象深刻的线性扩展性(参见图 15.9图 15.10):

Chart, line chart  Description automatically generated

图 15.9:增加图像数量时,TPU v2 的线性扩展性

此外,您可以在网上找到 ResNet-50 [4] 的比较,其中完整的 Cloud TPU v2 Pod 比 V100 NVIDIA Tesla GPU 在 ResNet-50 训练中快 >200 倍:

A picture containing timeline  Description automatically generated

图 15.10:完整的 Cloud TPU v2 Pod 比 V100 NVIDIA Tesla GPU 在训练 ResNet-50 模型时快 >200 倍

根据 Google 的说法,TPU v4 在与 Nvidia A100 GPU 相比时,给出了 MLPerf1.0 [5] 的顶级结果(参见图 15.11)。事实上,这些加速器的设计考虑了最新的大型模型,涉及数十亿甚至数万亿的参数(比如 GPT-3、T5 和 Switch Transformer):

Chart, bar chart  Description automatically generated

图 15.11:MLPerf 1.0 TPU v4 Pod 性能 – 来源:cloud.google.com/blog/products/ai-machine-learning/google-wins-mlperf-benchmarks-with-tpu-v4

如何在 Colab 中使用 TPU

在本节中,我们展示了如何在 Colab 中使用 TPU。只需将浏览器指向 colab.research.google.com/ 并从Runtime菜单中更改运行时,如图 15.12所示。首先,你需要为笔记本启用 TPU,然后导航到编辑笔记本设置并从硬件加速器下拉框中选择TPU

图形用户界面,文本,应用程序,聊天或文本消息 描述自动生成

图 15.12:将 TPU 设置为硬件加速器

检查 TPU 是否可用

首先,让我们通过使用这个简单的代码片段来检查是否有可用的 TPU,它会返回分配给 TPU 的 IP 地址。CPU 与 TPU 之间的通信是通过gRPCgRPC 远程过程调用)进行的,gRPC 是一个现代的、开源的高性能远程过程调用RPC)框架,可以在任何环境中运行:

%tensorflow_version 2.x
import tensorflow as tf
print("Tensorflow version " + tf.__version__)
try:
  tpu = tf.distribute.cluster_resolver.TPUClusterResolver()  # TPU detection
  print('Running on TPU ', tpu.cluster_spec().as_dict()['worker'])
except ValueError:
  raise BaseException('ERROR: Not connected to a TPU runtime; please see the previous cell in this notebook for instructions!')
tf.config.experimental_connect_to_cluster(tpu)
tf.tpu.experimental.initialize_tpu_system(tpu)
tpu_strategy = tf.distribute.experimental.TPUStrategy(tpu) 

你应该会看到如下内容:

Tensorflow version 2.8.0
Running on TPU  ['10.36.66.50:8470']
INFO:tensorflow:Deallocate tpu buffers before initializing tpu system.
INFO:tensorflow:Deallocate tpu buffers before initializing tpu system.
INFO:tensorflow:Initializing the TPU system: grpc://10.36.66.50:8470
INFO:tensorflow:Initializing the TPU system: grpc://10.36.66.50:8470
INFO:tensorflow:Finished initializing TPU system.
INFO:tensorflow:Finished initializing TPU system.
WARNING:absl:'tf.distribute.experimental.TPUStrategy' is deprecated, please use  the non experimental symbol 'tf.distribute.TPUStrategy' instead.
INFO:tensorflow:Found TPU system:
INFO:tensorflow:Found TPU system:
INFO:tensorflow:*** Num TPU Cores: 8
INFO:tensorflow:*** Num TPU Cores: 8
INFO:tensorflow:*** Num TPU Workers: 1
INFO:tensorflow:*** Num TPU Workers: 1
INFO:tensorflow:*** Num TPU Cores Per Worker: 8
INFO:tensorflow:*** Num TPU Cores Per Worker: 8 

我们确认 TPU 是可用的!

Keras MNIST TPU 端到端训练

参考 Google Research Colab 上提供的笔记本(参见 colab.research.google.com/github/GoogleCloudPlatform/training-data-analyst/blob/master/courses/fast-and-lean-data-science/01_MNIST_TPU_Keras.ipynb#scrollTo=Hd5zB1G7Y9-7),我们可以查看如何使用这个代码片段检测 TPU 或 GPU,它会使用 TPU 或 GPU 作为回退:

try: # detect TPUs
    tpu = tf.distribute.cluster_resolver.TPUClusterResolver.connect() # TPU detection
    strategy = tf.distribute.TPUStrategy(tpu)
except ValueError: # detect GPUs
    strategy = tf.distribute.MirroredStrategy() # for GPU or multi-GPU machines
    #strategy = tf.distribute.get_strategy() # default strategy that works on CPU and single GPU
    #strategy = tf.distribute.experimental.MultiWorkerMirroredStrategy() # for clusters of multi-GPU machines
print("Number of accelerators: ", strategy.num_replicas_in_sync) 

请注意,tf.distribute.TPUStrategy(tpu)是你在 TPU 和 TPU 集群上进行同步训练时,代码中唯一需要更改的部分。然后,要在 TPU 上运行 TF2 程序,你可以使用tf.keras中的.compile.fitAPI 与TPUStrategy一起使用。

如果你愿意,你可以通过直接调用strategy.run编写你自己的自定义训练循环(参见 www.tensorflow.org/api_docs/python/tf/distribute/TPUStrategy)。

使用预训练的 TPU 模型

Google 在 GitHub 的tensorflow/tpu仓库中提供了一些预训练的 TPU 模型(github.com/tensorflow/tpu)。这些模型包括图像识别、物体检测、低资源模型、机器翻译和语言模型、语音识别以及图像生成。我的建议是,在可能的情况下,先从一个预训练模型[6]开始,然后进行微调或应用某种形式的迁移学*。截至 2022 年 4 月,以下模型可用:

图像识别、分割等 机器翻译和语言模型 语音识别 图像生成
图像识别AmoebaNet-DResNet-50/101/152/2000Inception v2/v3/v4物体检测RetinaNetMask R-CNN图像分割Mask R-CNNDeepLabRetinaNet低资源模型MnasNetMobileNetSqueezeNet 机器翻译(基于 transformer)情感分析(基于 transformer)问答BERT 语音识别Transformer 图像 TransformerDCGANGAN

表 15.1:在 GitHub 上可用的使用 TPU 预训练的最先进的模型集合

玩转这个代码库的最佳方式是通过 Google Cloud 控制台克隆它,并使用在 github.com/tensorflow/tpu/blob/master/README.md 中提供的环境。你应该能够浏览 图 15.13 中展示的内容:

图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 15.13:云 TPU

如果你点击 OPEN IN GOOGLE CLOUD SHELL 按钮,系统会将 Git 仓库克隆到你的云 Shell 中,然后打开 Shell(见 图 15.14):

图形用户界面,文本,应用程序 描述自动生成

图 15.14:Google Cloud Shell 与为你克隆的 TPU Git 仓库

在那里,你可以通过一个不错的 Google Cloud TPU 演示,使用 TPU 群体——一个计算引擎虚拟机和云 TPU 配对,在 MNIST 上训练 ResNet-50(见 图 15.15):

文本 描述自动生成

图 15.15:Google Cloud TPU 演示,使用 TPU 群体在 MNIST 上训练 ResNet-50

如果你有兴趣查看,我将为你保留这个训练演示。

总结

TPU 是 Google 为执行神经网络数学运算而开发的非常特殊的 ASIC 芯片,以超快的方式进行运算。计算的核心是一个 systolic multiplier,它并行计算多个点积(行 * 列),从而加速基础深度学*操作的计算。可以将 TPU 看作是一个专门用于深度学*的协处理器,专注于矩阵或张量操作。到目前为止,Google 已经发布了四代 TPU,并且还推出了一个针对物联网的 Edge TPU。Cloud TPU v1 是一个基于 PCI 的专用协处理器,提供 92 teraops,只支持推理。Cloud TPU v2 实现了 180 teraflops,支持训练和推理。2018 年发布的 Cloud TPU v2 Pods(测试版)可达到 11.5 petaflops。Cloud TPU v3 实现了 420 teraflops,支持训练和推理。Cloud TPU v3 Pods 可以提供超过 100 petaflops 的计算能力。每个 TPU v4 芯片提供超过 TPU v3 芯片 2 倍的计算能力——最高可达 275 peak TFLOPS。每个 TPU v4 Pod 可提供 1.1 exaflops/s 的峰值性能。

这就是一台世界级的张量操作超级计算机!

在下一章中,我们将介绍一些其他有用的深度学*库。

参考文献

  1. 摩尔定律:en.wikipedia.org/wiki/Moore%27s_law

  2. Milovanović, I. Ž. 等人(2010 年 5 月)。43 种同步矩阵乘法方法。刊登于《国际计算数学杂志》87(6):1264–1276。

  3. Jouppi, N. P. 等人(2014 年 6 月)。数据中心内的张量处理单元性能分析。第 44 届国际计算机架构研讨会(ISCA)。

  4. Google TPU v2 性能:storage.googleapis.com/nexttpu/index.xhtml

  5. MLPerf 网站:mlperf.org/

  6. 一些使用 TPU 预训练的模型:cloud.google.com/tpu

加入我们书籍的 Discord 社区

加入我们的 Discord 社区,结识志同道合的人,并与超过 2000 名成员一起学*,访问链接:packt.link/keras

第十六章:其他有用的深度学*库

来自 Google 的 TensorFlow 并不是唯一可用于深度学*任务的框架。市场上有许多不同的库和框架,每个都有其独特的功能、特性和应用场景。在本章中,我们将探索一些流行的深度学*库,并比较它们的功能。

本章将包括:

  • Hugging Face

  • H2O

  • PyTorch

  • ONNX

  • Open AI

本章的所有代码文件可以在 packt.link/dltfchp16 找到。

让我们开始吧!

Hugging Face

Hugging Face 对我们来说并不陌生;第六章Transformers,向我们介绍了这个库。Hugging Face 是一家以 NLP 为中心的初创公司,由 Delangue 和 Chaumond 于 2016 年创办。它在短时间内已成为处理所有 NLP 相关任务的最佳工具之一。AutoNLP 和加速推理 API 需要付费使用,但其核心 NLP 库 datasets、tokenizers、Accelerate 和 transformers(图 16.1)是免费的。它已经建立了一个由社区驱动的开源平台。

图示 说明自动生成,置信度中等

图 16.1:来自 Hugging Face 的 NLP 库

Hugging Face 生态系统的核心是其 transformers 库。Tokenizers 和 Datasets 库支持 transformers 库。要使用这些库,我们需要先安装它们。Transformers 可以通过简单的 pip install 命令进行安装:

pip install transformers 

Hugging Face 提供的一些开箱即用的模型包括文本摘要、问答、文本分类、音频分类、自动语音识别、特征提取、图像分类和翻译。在图 16.2中,我们可以看到 Hugging Face 提供的开箱即用的摘要模型的结果。

一个包含图形用户界面的图片 说明自动生成

图 16.2:使用 Hugging Face 进行开箱即用的文本摘要

除了这些开箱即用的模型,我们还可以使用 Hugging Face Hub 上提供的大量模型和数据集,并可以与 PyTorch、TensorFlow 和 JAX 一起使用来构建定制化的模型。

OpenAI

OpenAI 是另一个在强化学*领域中为人所知的名字。他们的 Gym 模块是全球开发人员用于开发和比较强化学*算法的标准工具包。在第十一章强化学*,我们已经详细介绍了 Gym 模块。在本章中,我们将探讨 OpenAI 提供的另外两个工具。

OpenAI GPT-3 API

“OpenAI GPT-3 是一个机器学*平台,允许开发者为深度学*构建自定义算法。该平台于 2017 年 12 月发布,并已广泛应用于人工智能领域的企业和个人。GPT-3 成功的主要原因之一是它易于使用,并且具有广泛的功能。这个平台能够从数据中学*,并可用于多种任务,包括深度学*、自然语言处理和图像识别。GPT-3 还因为是开源的,任何人都可以使用,因此它成为了任何想要学*深度学*及其各种应用方式的人的理想平台。总的来说,GPT-3 是一个功能强大且易于使用的机器学*平台,在人工智能领域被企业和个人广泛应用。”

这是 OpenAI GPT-3 API 生成的文本,当被要求撰写关于 GPT-3 本身的内容时 (beta.openai.com/playground):

图形用户界面,文本,应用程序 说明自动生成

图 16.3:使用 OpenAI GPT-3 API 进行文本生成

OpenAI GPT-3 API 提供以下任务:

  • 文本补全:在这里,GPT-3 API 被用来生成或操作文本,甚至是代码。你可以用它来写广告语、介绍或文章,或者你可以留下半句未写完的句子,要求它补全。人们已经用它来生成故事和广告文案。

  • 语义搜索:这允许你对一组文档进行语义搜索。例如,你可以通过 API 上传文档;它最多可以处理 200 个文档,每个文件的最大大小为 150MB,总大小限制为 1GB。API 会根据语义相似度得分(通常在 0-300 之间)对文档进行排名。

  • 问答系统:该 API 使用上传的文档作为真实来源;API 首先搜索文档中与问题相关的内容。然后根据语义相关性对其进行排序,最后回答问题。

  • 文本分类:OpenAI GPT-3 的文本分类端点接受一组带标签的示例作为输入,然后利用其中的标签对查询文本进行标注。有许多例子表明,这一功能已被用于情感分析。

最初,OpenAI GPT-3 仅在申请后才可使用,但现在,任何人都可以使用该 API;不再需要排队等待。

OpenAI DALL-E 2

OpenAI 的 GPT-3 API 涉及与自然语言处理(NLP)相关的所有内容;而 DALL-E 2 更进一步。DALL-E 最初由 OpenAI 于 2021 年 1 月发布。它声称能够根据提供给模型的文本描述生成照片级真实感图像。它还可以对现有图像进行真实感编辑;你可以使用它添加或删除图像中的物体和元素,当它执行这些操作时,会考虑阴影、反射和纹理的影响。图 16.4 展示了 DALL-E 2 的一些令人瞩目的成就。在顶部的图像中,我给 DALL-E 2 提供了一个描述我想要的文本:“阿尔伯特·爱因斯坦骑着恐龙飞越亚马逊雨林。”它生成了一个卡通风格的图像。下排的图像是使用 DALL-E 2 的图像编辑功能生成的。我添加了左侧的图像,它生成了四个变体。如果忽略面部模糊的话,变体看起来非常逼真:

图 16.4:上方是 DALL-E 2 生成的图像,下方是 DALL-E 2 编辑的图像

在撰写本书时(2022 年 8 月),DALL-E 2 还不能公开使用。但想象一下,当这个模型作为 API 发布后,艺术家和从事数字媒体创作的专业人士将拥有怎样的无限可能。

OpenAI Codex

当学生开始学*编程的第一课时,作为老师,我通常建议他们将程序看作一组指令——唯一需要掌握的就是用你知道的语言尽可能清晰地编写这些指令。

好吧,Codex 使这一切成为可能,你只需要给它提供你想要实现的指令,它就会为你生成相应的代码。

OpenAI 将其推出作为一个通用编程模型,并且它已经在公开的 GitHub 代码上进行了训练。

以下是 Codex 生成的任务和相应代码的一些片段:

""" Ask the user for their name and say "Hello" in Python """ 
print("Hello, world!") 
name = input("Please type your name: ") 
print("Hello, " + name + "!")
""" Write a program in Python to find sum of n fibonacci sequences """ 
def Fibonacci(n): 
    if n < 0: 
        print("Incorrect input") 
    elif n == 1: 
       return 0 
    elif n == 2: 
        return 1 
    else: 
        return Fibonacci(n - 1) + Fibonacci(n - 2) 
n = int(input("Enter the number of terms: ")) 
# check if the number of terms is valid 
if n <= 0: 
    print("Please enter a positive integer") 
else: 
    print("Fibonacci sequence: ") 
    for I in range(1, n + 1): 
        print(Fibonacci(n), end=' ') 

第一个任务,如你所见,完成得非常完美。在第二个任务中,我们要求它计算斐波那契数列的和;然而,它生成了斐波那契数列本身,这是一个更常见的问题。这告诉我们,尽管它在处理常见任务方面非常出色,但对于真正的程序员的需求依然存在。

PyTorch

与 TensorFlow 类似,PyTorch 是一个完整的深度学*框架。在基于 AI 的社交圈中,你经常会看到 PyTorch 和 TensorFlow 的死忠粉丝争论谁才是最好的。PyTorch 由 Facebook(现在是 Meta)开发,是一个开源的深度学*框架。许多研究人员偏爱它,因为它具有灵活和模块化的特点。PyTorch 还对生产部署提供了稳定的支持。像 TensorFlow 一样,PyTorch 的核心是其张量处理库和自动微分引擎。在 C++ 运行时环境中,它利用 TorchScript 实现图模式和急切模式之间的轻松转换。使 PyTorch 流行的主要特性是其支持动态计算,即能够动态构建计算图——这使得程序员能够在任何时候修改和检查计算图,增加了灵活性。

PyTorch 库由多个模块组成,这些模块作为构建复杂模型的基础块。此外,PyTorch 还提供了方便的功能来在不同设备之间传输变量和模型,例如 CPU、GPU 或 TPU。特别需要提到的是以下三个强大的模块:

  • NN 模块:这是所有层和构建深度学*网络所需函数的基类。下面,你可以看到一个代码片段,展示了如何使用 NN 模块构建网络。然后可以使用语句 net = My_Net(1,10,5) 来实例化该网络;这会创建一个具有一个输入通道、10 个输出神经元和 5x5 大小卷积核的网络:

    import torch.nn as nn
    import torch.nn.functional as F
    class My_Net(nn.Module):
        def __init__(self, input_channel, output_neurons, kernel_size):
            super(My_Net, self).__init__()
            self.conv1 = nn.Conv2d(input_channel, 6, kernel_size)
            self.conv2 = nn.Conv2d(6, 16, 5)
            self.fc1 = nn.Linear(16 * 5 * 5, 120)
            self.fc2 = nn.Linear(120, 84)
            self.fc3 = nn.Linear(84,output_neurons)
        def forward(self, x):
            x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
            x = F.max_pool2d(F.relu(self.conv2(x)), 2)
            x = x.view(-1, self.num_flat_features(x))
            x = F.relu(self.fc1(x))
            x = F.relu(self.fc2(x))
            x = self.fc3(x)
            return x
        def num_flat_features(self, x):
            size = x.size()[1:]  
            num_features = 1
            for s in size:
                num_features *= s
            return num_features 
    

    这是该网络的概述:

    My_Net(
        (conv1): Conv2d(1, 6, kernel_size=(5, 5), stride=(1, 1))
        (conv2): Conv2d(6, 16, kernel_size=(5, 5), stride=(1,
        1))
        (fc1): Linear(in_features=400, out_features=120,
        bias=True)
        (fc2): Linear(in_features=120, out_features=84,
        bias=True)
        (fc3): Linear(in_features=84, out_features=10,
        bias=True)
    ) 
    
  • Autograd 模块:这是 PyTorch 的核心。该模块提供了用于实现自动微分的类和函数。该模块创建了一个称为动态计算图的无环图;图的叶子是输入张量,根是输出张量。它通过沿着路径从根追踪到叶子并利用链式法则相乘,来计算梯度。下面的代码片段展示了如何使用 Autograd 模块来计算梯度。backward() 函数计算损失相对于所有 requires_grad 设置为 True 的张量的梯度。假设你有一个变量 w,那么在调用 backward() 后,张量 w.grad 将给出损失相对于 w 的梯度。

    然后我们可以利用这个规则来根据学*规则更新变量 w

    loss = (y_true – y_pred).pow(2).sum()
    loss.backward()
    # Here the autograd is used to compute the backward pass. 
    With torch.no_grad():
        W = w – lr_rate * w.grad
        w.grad = None # Manually set to zero after updating 
    
  • Optim 模块:Optim 模块实现了各种优化算法。Optim 中提供的一些优化器算法包括 SGD、AdaDelta、Adam、SparseAdam、AdaGrad 和 LBFGS。用户还可以使用 Optim 模块创建复杂的优化器。要使用 Optim 模块,只需构建一个优化器对象,该对象将保存当前状态并根据梯度更新参数。

PyTorch 被许多公司用于他们的 AI 解决方案。特斯拉使用 PyTorch 进行自动驾驶。特斯拉的自动驾驶系统使用车辆周围八个摄像头的视频,将这些视频传递通过 48 个神经网络进行物体检测、语义分割和单眼深度估计。该系统提供了 2 级车辆自动化。他们从所有八个摄像头获取视频,以生成道路布局、任何静态基础设施(如建筑物和交通/电力杆)以及 3D 物体(其他车辆、路上的行人等)。这些网络通过实时迭代进行训练。尽管有些技术性,但这是特斯拉 AI 主管 Andrej Karpathy 在 2019 年的一场演讲,它从鸟瞰视角展示了自动驾驶的能力:www.youtube.com/watch?v=oBklltKXtDE&t=670s。Uber 的 Pyro,一个概率深度学*库,以及 OpenAI 是其他使用 PyTorch 进行研发的大型 AI 公司的例子。

ONNX

开放神经网络交换 (ONNX) 提供了一个用于 AI 模型的开源格式。它支持深度学*模型和传统的机器学*模型。ONNX 是一个设计用来表示任何类型模型的格式,它通过使用不同框架创建的计算图的中间表示来实现这一目标。它支持 PyTorch、TensorFlow、MATLAB 等多个深度学*框架。因此,使用 ONNX,我们可以轻松地将模型从一个框架转换到另一个框架。这有助于减少从研究到部署的时间。例如,您可以使用 ONNX 将 PyTorch 模型转换为 ONNX.js 格式,然后可以直接在网页上部署。

H2O.ai

H2O 是由 H2O.ai 开发的快速、可扩展的机器学*和深度学*框架,遵循开源 Apache 许可发布。根据公司官网的信息,截至本书编写时,已有超过 20,000 个组织使用 H2O 来满足他们的机器学*/深度学*需求。公司提供了许多产品,如 H2O AI 云、H2O Driverless AI、H2O wave 和 Sparkling Water。在本节中,我们将探索其开源产品 H2O。

它可以在 Hadoop、Spark 或 Kubernetes 集群的大数据基础设施上运行,也可以在独立模式下工作。它利用分布式系统和内存计算,使其能够在即使只有少量机器的集群中,也能处理大量数据。它有 R、Python、Java、Scala 和 JavaScript 的接口,甚至还有一个内置的 Web 界面。

H2O 包括大量基于统计的机器学*算法,如广义线性模型、朴素贝叶斯、随机森林、梯度提升以及所有主要的深度学*算法。H2O 的最佳部分是用户可以仅通过几行代码构建数千个模型、比较结果,甚至进行超参数调优。H2O 还拥有更好的数据预处理工具。

H2O 需要 Java 环境,请确保系统已安装 Java。您可以使用 PyPi 安装 H2O,以便在 Python 中使用,如下所示的代码:

pip install h2o 

H2O 的 AutoML

H2O 最令人兴奋的功能之一是AutoML,即自动机器学*。它是开发的一种旨在为初学者和非专家提供友好的 ML 接口的尝试。H2O AutoML 自动化了训练和调整大量候选模型的过程。其界面设计得使用户只需指定其数据集、输入和输出特征以及任何对总训练模型数或时间约束的限制。其余工作由 AutoML 本身完成,在指定的时间限制内,它确定表现最佳的模型并提供一个排行榜。通常观察到,堆叠集成模型,即所有先前训练模型的集成,通常占据排行榜的顶部位置。高级用户可以使用大量选项;有关这些选项及其各种特性的详细信息,请参阅docs.h2o.ai/h2o/latest-stable/h2o-docs/automl.xhtml

欲了解更多关于 H2O 的信息,请访问它们的网站:h2o.ai

使用 H2O 的 AutoML

让我们在一个合成创建的数据集上尝试 H2O AutoML。我们使用 scikit-learn 的make_circles方法创建数据,并将其保存为 CSV 文件:

from sklearn.datasets import make_circles
import pandas as pd
X, y = make_circles(n_samples=1000, noise=0.2, factor=0.5, random_state=9)
df = pd.DataFrame(X, columns=['x1','x2'])
df['y'] = y
df.head()
df.to_csv('circle.csv', index=False, header=True) 

在使用 H2O 之前,我们需要初始化其服务器,这通过init()函数完成。

import h2o
h2o.init() 

以下显示了在初始化 H2O 服务器后我们将收到的输出:

Checking whether there is an H2O instance running at http://localhost:54321 ..... not found.
Attempting to start a local H2O server...
  Java Version: openjdk version "11.0.15" 2022-04-19; OpenJDK Runtime Environment (build 11.0.15+10-Ubuntu-0ubuntu0.18.04.1); OpenJDK 64-Bit Server VM (build 11.0.15+10-Ubuntu-0ubuntu0.18.04.1, mixed mode, sharing)
  Starting server from /usr/local/lib/python3.7/dist-packages/h2o/backend/bin/h2o.jar
  Ice root: /tmp/tmpm2fsae68
  JVM stdout: /tmp/tmpm2fsae68/h2o_unknownUser_started_from_python.out
  JVM stderr: /tmp/tmpm2fsae68/h2o_unknownUser_started_from_python.err
  Server is running at http://127.0.0.1:54321
Connecting to H2O server at http://127.0.0.1:54321 ... successful.
H2O_cluster_uptime:    05 secs
H2O_cluster_timezone:    Etc/UTC
H2O_data_parsing_timezone:    UTC
H2O_cluster_version:    3.36.1.1
H2O_cluster_version_age:    27 days 
H2O_cluster_name:    H2O_from_python_unknownUser_45enk6
H2O_cluster_total_nodes:    1
H2O_cluster_free_memory:    3.172 Gb
H2O_cluster_total_cores:    2
H2O_cluster_allowed_cores:    2
H2O_cluster_status:    locked, healthy
H2O_connection_url:    http://127.0.0.1:54321
H2O_connection_proxy:    {"http": null, "https": null}
H2O_internal_security:    False
Python_version:    3.7.13 final 

我们读取之前创建的合成数据文件。由于我们希望将问题视为分类问题,即点是否在圆内,我们将我们的标签'y'重新定义为asfactor() – 这将告诉 H2O 的 AutoML 模块将变量y视为分类变量,从而将问题视为分类问题。数据集按 60:20:20 的比例分为训练、验证和测试数据集:

class_df = h2o.import_file("circle.csv",\
                           destination_frame="circle_df")
class_df['y'] = class_df['y'].asfactor()
train_df,valid_df,test_df = class_df.split_frame(ratios=[0.6, 0.2],\
                                                 seed=133) 

现在我们从 H2O 调用 AutoML 模块,并在我们的训练数据集上进行训练。AutoML 将搜索最多 10 个模型,但您可以更改参数max_models来增加或减少要测试的模型数量:

from h2o.automl import H2OAutoML as AutoML
aml = AutoML(max_models = 10, max_runtime_secs=100, seed=2)
aml.train(training_frame= train_df, \
          validation_frame=valid_df, \
          y = 'y', x=['x1','x2']) 

对于每个模型,它都提供了性能摘要,例如,在图 16.5中,您可以看到二项式 GLM 的评估摘要:

自动生成的文本描述

图 16.5:H2O AutoML 模型之一的性能摘要

您可以检查 H2O AutoML 评估的所有模型的性能在排行榜上:

lb = aml.leaderboard
lb.head() 

这是排行榜的片段:

model_id     auc    logloss    aucpr    mean_per_class_error    rmse    mse
StackedEnsemble_BestOfFamily_1_AutoML_2_20220511_61356    0.937598    0.315269    0.940757    0.117037    0.309796    0.0959735
StackedEnsemble_AllModels_1_AutoML_2_20220511_61356     0.934905    0.323695    0.932648    0.120348    0.312413    0.0976021
XGBoost_2_AutoML_2_20220511_61356     0.93281     0.322668    0.938299    0.122004    0.313339    0.0981811
XGBoost_3_AutoML_2_20220511_61356     0.932392    0.330866    0.929846    0.130168    0.319367    0.101995 
GBM_2_AutoML_2_20220511_61356     0.926839    0.353181    0.923751    0.141713    0.331589    0.109951 
XRT_1_AutoML_2_20220511_61356     0.925743    0.546718    0.932139    0.154774    0.331096    0.109625 
GBM_3_AutoML_2_20220511_61356     0.923935    0.358691    0.917018    0.143374    0.334959    0.112197 
DRF_1_AutoML_2_20220511_61356     0.922535    0.705418    0.921029    0.146669    0.333494    0.111218 
GBM_4_AutoML_2_20220511_61356     0.921954    0.36403     0.911036    0.151582    0.336908    0.113507 
XGBoost_1_AutoML_2_20220511_61356     0.919142    0.365454    0.928126    0.130227    0.336754    0.113403 

H2O 模型可解释性

H2O 提供了一个便捷的包装器,使用单个函数explain()结合数据集和模型,支持多种可解释性方法及其可视化。为了获取关于我们用于 AutoML 测试的模型的测试数据可解释性,我们将使用aml.explain()。下面,我们使用explain模块对StackedEnsemble_BestOfFamily模型(排行榜上的最高模型)进行解释(我们继续使用在上一节中创建的数据):

exa = aml.leader.explain(test_df) 

结果如下:

图形用户界面,文本,应用程序 说明自动生成

图 16.6:由 H2O 解释模块生成的测试数据集混淆矩阵

真实值显示在行中,模型预测显示在列中。对于我们的数据,0 被正确预测了 104 次,1 被正确预测了 88 次。

部分依赖图

部分依赖图PDP)提供了变量对模型响应的边际效应的图形表示。它可以告诉我们输出标签与输入特征之间的关系。图 16.7 显示了通过 H2O explain模块在我们的合成数据集上获得的 PDP 图:

图表 说明自动生成

图 16.7:输入特征 x[1] 和 x[2] 的 PDP

为每个特征构建 PDP 图时,H2O 会将其余特征视为常数。因此,在 x[1](x[2])的 PDP 图中,特征 x[2](x[1])保持不变,测量均值响应,同时 x[1](x[2])发生变化。图表显示,两个特征在决定点是否为圆形时都起着重要作用,尤其是对于值位于[-0.5, 0.5]之间的情况。

变量重要性热图

我们还可以检查不同模型中变量的重要性:

图标 说明自动生成

图 16.8:输入特征 x[1]和 x[2]的变量重要性热图

图 16.8 显示了不同算法对两个输入特征赋予的权重。我们可以看到,那些几乎对两个特征赋予相等重要性的模型在排行榜上表现良好,而GLM_1则对这两个特征处理得差异较大,其准确率仅为约 41%。

模型相关性

不同模型之间的预测是相关的;我们可以检查这种相关性:

图表,直方图 说明自动生成

图 16.9:模型相关性

图 16.9 显示了模型相关性;它显示了不同模型在测试数据集上的预测相关性。它通过计算相同预测的频率来衡量相关性。同样,我们可以看到,除了GLM_1之外,大多数其他模型的表现几乎相同,排行榜上的准确率在 84%到 93%之间。

我们在这里讨论的内容只是冰山一角;每个框架都有关于其功能和应用的完整书籍。根据你的使用场景,你应该选择相应的框架。如果你正在为生产环境构建模型,那么 TensorFlow 更适合 Web 和边缘应用。如果你需要更好地控制训练过程和梯度更新,PyTorch 更为合适。如果你经常需要跨平台工作,那么 ONNX 会很有用。最后,像 H2O 和 OpenAI 的 GPT-3 与 DALL-E 2 等平台提供了一个低门槛进入人工智能和深度学*领域的途径。

摘要

在本章中,我们简要介绍了其他一些流行的深度学*框架、库和平台的特点和功能。我们从 Hugging Face 开始,这是一个流行的 NLP 框架。接着,我们探索了 OpenAI 的 GPT-3 和 DALL-E 2,它们都是非常强大的框架。GPT-3 API 可以用于各种与 NLP 相关的任务,而 DALL-E 2 使用 GPT-3 从文本描述中生成图像。然后,我们介绍了 PyTorch 框架。许多人认为 PyTorch 和 TensorFlow 是平起平坐的竞争对手,事实上,PyTorch 确实有许多与 TensorFlow 相媲美的功能。在本章中,我们简要讨论了 PyTorch 的一些重要模块,如 NN 模块、Optim 模块和 Autograd 模块。我们还讨论了 ONNX,这是一个开源深度学*模型格式,并介绍了如何使用它将模型从一个框架转换到另一个框架。最后,本章介绍了 H2O 及其 AutoML 和 explain 模块。

在下一章中,我们将学*图神经网络。

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,与志同道合的人交流,并与超过 2000 名成员一起学*: packt.link/keras

第十七章:图神经网络

在本章中,我们将讨论一种相对较新的神经网络类别——图神经网络GNN),它非常适合处理图数据。许多现实生活中的问题,如社交媒体、生物化学、学术文献等,天生就是“图形化”的,意味着它们的输入由可以最适合用图表示的数据组成。我们将从数学角度讲解什么是图,然后解释“图卷积”这一概念,这是 GNN 的核心思想。接着,我们将介绍一些基于基本图卷积技术变体的流行 GNN 层。我们将描述 GNN 的三个主要应用,涵盖节点分类、图分类和边预测,并通过使用 TensorFlow 和深度图书馆DGL)的示例来说明。DGL 提供了我们刚刚提到的 GNN 层以及更多的层。此外,它还提供了一些标准的图数据集,我们将在示例中使用这些数据集。随后,我们将展示如何从自己的数据构建一个与 DGL 兼容的数据集,以及如何使用 DGL 的低级消息传递 API 构建自己的层。最后,我们将探讨图的扩展,例如异构图和时间图。

本章将涵盖以下主题:

  • 图的基础

  • 图机器学*

  • 图卷积

  • 常见的图层

  • 常见的图应用

  • 图的定制

  • 未来方向

本章的所有代码文件可以在 https://packt.link/dltfchp17 找到

让我们从基础开始。

图的基础

从数学上讲,一个图G是一个数据结构,包含一组顶点(也叫节点)V,这些顶点通过一组边E相互连接,即:

一个图可以等价地表示为一个邻接矩阵A,其大小为(n, n),其中n是集合V中顶点的数量。该邻接矩阵的元素A[I, j]表示顶点i和顶点j之间的边。因此,若顶点i和顶点j之间有一条边,则元素A[I, j] = 1,否则为 0。在加权图的情况下,边可能有自己的权重,邻接矩阵会通过将边的权重设置为元素A[i, j]来反映这一点。边可以是有向的或无向的。例如,表示节点x和节点y之间友谊的边是无向的,因为xy的朋友意味着y也是x的朋友。相反,有向边可以是社交媒体中的关注网络,在这种情况下,x关注y并不意味着y关注x。对于无向图,A[I, j] = A[j, i]

邻接矩阵A的另一个有趣特性是,A^n,即* A n次乘积,揭示了节点之间的n*跳连接。

图到矩阵的等价性是双向的,这意味着邻接矩阵可以无损地转换回图的表示。由于机器学*ML)方法,包括深度学*DL)方法,消耗的输入数据是张量形式,因此这种等价性意味着图形可以有效地作为各种机器学*算法的输入表示。

每个节点还可以与其自己的特征向量关联,就像表格输入中的记录一样。假设特征向量的大小为f,那么节点集X可以表示为(n, f)。边也可以有自己的特征向量。由于图和矩阵之间的等价性,图通常由库表示为高效的基于张量的结构。我们将在本章后面详细讨论这一点。

图形机器学*

任何机器学*任务的目标都是学*从输入空间X到输出空间y的映射F。早期的机器学*方法需要特征工程来定义合适的特征,而深度学*方法则可以从训练数据本身推断特征。深度学*通过假设一个具有随机权重的模型M来工作!,并将任务表述为一个关于参数的优化问题:

并使用梯度下降法在多次迭代中更新模型权重,直到参数收敛:

不出所料,图神经网络(GNNs)也遵循这一基本模型。

然而,正如你在前几章中所看到的,机器学*(ML)和深度学*(DL)通常是针对特定结构进行优化的。例如,在处理表格数据时,你可能会直观地选择一个简单的前馈网络FFN)或“密集”网络,在处理图像数据时选择卷积神经网络CNN),而在处理像文本或时间序列这样的序列数据时选择递归神经网络RNN)。有些输入可能简化为像素格子或令牌序列这样的结构,但也不一定如此。在其自然形式下,图形是拓扑复杂、大小不确定的结构,并且不是置换不变的(即实例之间不是相互独立的)。

出于这些原因,我们需要特殊的工具来处理图数据。本章将介绍 DGL,它是一个跨平台的图形库,支持 MX-Net、PyTorch 和 TensorFlow 用户,通过使用可配置的后端,广泛被认为是最强大且易于使用的图形库之一。

图卷积——图神经网络的直觉

卷积算子有效地允许在二维平面上将相邻像素的值以特定方式聚合,这在计算机视觉中的深度神经网络中取得了成功。其一维变体在自然语言处理和音频处理领域也取得了类似的成功。正如你在第三章《卷积神经网络》中回忆的那样,网络在连续的层之间应用卷积和池化操作,并能够学*到足够多的全局特征,从而在它所训练的任务中获得成功。

从另一个角度来看,图像(或图像的每个通道)可以被视为一种网格形状的图,其中相邻的像素以特定的方式彼此连接。类似地,一串单词或音频信号也可以被看作是另一个线性图,其中相邻的词元彼此相连。在这两种情况下,深度学*架构会在输入图的相邻节点之间逐步应用卷积和池化操作,直到它学会执行任务,通常是分类任务。每一步卷积都涉及额外层次的邻居。例如,第一个卷积合并来自距离 1(直接)邻居的信号,第二个合并来自距离 2 的邻居的信号,以此类推。

图 17.1 显示了 CNN 中的 3 x 3 卷积与对应的“图卷积”操作之间的等价性。卷积算子将滤波器(本质上是一组九个可学*的模型参数)应用于输入,并通过加权和将它们合并。通过将像素邻域视为一个以中心像素为核心的九个节点的图,你可以达到相同的效果。

在这种结构上的图卷积将只是节点特征的加权和,这与 CNN 中的卷积算子相同:

图示 自动生成的说明

图 17.1:图像卷积和图卷积之间的相似之处。图像来源:CS-224W 机器学*与图,斯坦福大学。

CNN 中的卷积操作和图卷积的相应方程如下所示。正如你所看到的,在 CNN 中,卷积可以视为输入像素和它的每个邻居的加权线性组合。每个像素都以所应用的滤波器的形式带来了自己的权重。另一方面,图卷积也是输入像素和所有邻居的聚合加权线性组合。所有邻居的聚合效应会平均到卷积输出中:

因此,图卷积是我们已经熟悉的卷积的一种变体。在接下来的部分中,我们将看到如何将这些卷积组合起来构建不同类型的 GCN 层。

常见的图层

本节讨论的所有图层都使用了上述描述的图卷积操作的某种变体。像 DGL 这样的图库贡献者,在学术论文提出这些层之后不久,就会提供许多这些层的预构建版本,因此实际上你永远不需要自己实现其中的一个。这部分信息主要是为了帮助理解其底层工作原理。

图卷积网络

图卷积网络GCN)是由 Kipf 和 Welling 提出的图卷积层[1]。最初,它被提出作为一种可扩展的半监督学*方法,用于图结构数据上。他们将 GCN 描述为对节点特征向量X和底层图的邻接矩阵A的操作,并指出当A中的信息不包含在数据X中时,这种方法特别强大,例如在引文网络中,文档之间的引文链接,或在知识图谱中的关系。

GCN 结合了每个节点特征向量与其邻居的特征向量,通过一些权重(初始化为随机值)进行加权。因此,对于每个节点,邻居节点的特征之和会被加到一起。这个操作可以表示为如下:

这里的updateaggregate是不同类型的求和函数。这种对节点特征的投影被称为消息传递机制。这个消息传递的单次迭代等同于对每个节点的直接邻居进行图卷积。如果我们希望结合来自更远节点的信息,可以多次重复这个操作。

以下方程描述了 GCN 在第(l+1)层对节点i的输出。这里,N(i)是节点i的邻居集合(包括它本身),c[ij]是节点度数平方根的乘积,sigma 是激活函数。b(l)项是一个可选的偏置项:

接下来,我们将讨论图注意力网络(Graph Attention Network,简称 GAT),它是 GCN 的一个变体,其中系数是通过注意力机制学*的,而不是显式定义的。

图注意力网络

图注意力网络GAT)层是 Velickovic 等人提出的[2]。与 GCN 类似,GAT 也对其邻居的特征进行局部平均。不同之处在于,GAT 不是显式指定归一化项c[ij],而是通过自注意力机制在节点特征上学*它。对应的归一化项写作,它是基于邻居节点的隐藏特征和学*到的注意力向量计算出来的。本质上,GAT 的理念是优先考虑来自相似邻居节点的特征信号,而非来自不相似邻居节点的信号。

每个邻居 的邻域 N(i) 向节点 i 发送其自身的注意力系数向量 。以下方程组描述了 GAT 在第 (i+1) 层对节点 i 的输出。注意力 是使用 Bahdanau 的注意力模型,通过前馈网络计算得到的:

GCN 和 GAT 架构适用于小型到中型网络。下一节中描述的 GraphSAGE 架构则更适合于较大的网络。

GraphSAGE(采样与聚合)

到目前为止,我们考虑的卷积要求图中的所有节点在训练时都必须出现,因此它们是传导式的,并且不能自然地推广到未见过的节点。Hamilton、Ying 和 Leskovec [3] 提出了 GraphSAGE,这是一个通用的、归纳式的框架,能够为之前未见过的节点生成嵌入。它通过从节点的本地邻域进行采样和聚合来实现这一点。GraphSAGE 已在动态演化的网络(如引用图和 Reddit 帖子数据)中的节点分类中取得了成功。

GraphSAGE 通过采样一部分邻居而不是使用全部邻居。它可以通过随机游走定义节点邻域,并汇总重要性分数来确定最佳样本。聚合函数可以是 MEAN、GCN、POOL 或 LSTM。均值聚合只是简单地取邻居向量的元素级平均值。LSTM 聚合更具表现力,但本质上是序列性的且不具对称性;它作用于从节点邻居的随机排列中得到的无序集合。POOL 聚合既具对称性又可训练;在这里,每个邻居向量独立地通过一个全连接神经网络,并对邻居集合中的信息应用最大池化。

这个方程组展示了如何从节点 i 和它在第 l 层的邻居 N(i) 生成节点 i 在第 (l+1) 层的输出:

在了解了使用 GNN 处理大规模网络的策略后,我们将探讨如何利用图同构网络最大化 GNN 的表示能力(也就是区分能力)。

图同构网络

Xu 等人[4]提出了图同构网络GIN),作为一种比现有图层具有更强表达能力的图层。具有高表达能力的图层应能够区分一对拓扑结构相似但不完全相同的图。研究表明,GCN 和 GraphSAGE 无法区分某些图结构。他们还展示了,在区分图结构方面,SUM 聚合优于 MEAN 和 MAX 聚合。因此,GIN 图层提供了一种比 GCN 和 GraphSAGE 更好的邻居聚合表示方法。

以下方程显示了节点i和层(l+1)的输出。在这里,函数f[θ]是一个可调用的激活函数,aggregate是一个聚合函数,如 SUM、MAX 或 MEAN,且是一个可学*的参数,它将在训练过程中学*:

在介绍了几种流行的 GNN 架构后,让我们现在关注可以使用 GNN 完成的任务类型。

常见图应用

现在我们来看一下 GNN 的一些常见应用。通常,应用可以分为以下三个主要类别。在本节中,我们将展示如何使用 TensorFlow 和 DGL 构建和训练 GNN 以解决这些任务的代码示例:

  • 节点分类

  • 图分类

  • 边分类(或链接预测)

GNN 还有其他应用,如图聚类或生成图模型,但它们较为少见,在这里我们不予考虑。

节点分类

节点分类是图数据中一个流行的任务。在这里,模型被训练来预测节点类别。非图分类方法可以仅使用节点特征向量来实现,而一些预 GNN 方法如 DeepWalk 和 node2vec 可以仅使用邻接矩阵,但 GNN 是第一类能够将节点特征向量和连接信息一起使用来进行节点分类的技术。

本质上,这个思想是将一个或多个图卷积(如前一节所述)应用于图中的所有节点,将节点的特征向量投影到一个对应的输出类别向量,以此来预测节点的类别。我们的节点分类示例将使用 CORA 数据集,这是一个包含 2,708 篇科学论文的数据集,每篇论文被分类为七个类别之一。这些论文被组织成一个引用网络,包含 5,429 个链接。每篇论文由一个大小为 1,433 的词向量描述。

我们首先设置导入。如果你还没有这样做,你需要通过pip install dgl将 DGL 库安装到你的环境中。你还需要将环境变量DGLBACKEND设置为 TensorFlow。在命令行中,可以通过命令export DGLBACKEND=tensorflow来实现,在笔记本环境中,你可以尝试使用魔法命令%env DGLBACKEND=tensorflow

import dgl
import dgl.data
import matplotlib.pyplot as plt
import numpy as np
import os
import tensorflow as tf
import tensorflow_addons as tfa
from dgl.nn.tensorflow import GraphConv 

CORA 数据集被预先包装为 DGL 数据集,因此我们使用以下调用将数据集加载到内存中:

dataset = dgl.data.CoraGraphDataset() 

第一次调用时,它将记录正在下载并提取到本地文件。一旦完成,它将打印出一些关于 CORA 数据集的有用统计信息。正如你所看到的,图中有 2,708 个节点和 10,566 条边。每个节点有一个大小为 1,433 的特征向量,每个节点被分类为七个类别之一。此外,我们还看到它有 140 个训练样本、500 个验证样本和 1,000 个测试样本:

 NumNodes: 2708
  NumEdges: 10556
  NumFeats: 1433
  NumClasses: 7
  NumTrainingSamples: 140
  NumValidationSamples: 500
  NumTestSamples: 1000
Done saving data into cached files. 

由于这是一个图数据集,它预计包含与一组图相关的数据。然而,CORA 是一个单一的引用图。你可以通过len(dataset)验证这一点,它将返回1。这也意味着下游代码将作用于由dataset[0]提供的图,而不是整个数据集。节点特征将包含在字典dataset[0].ndata中作为键值对,边特征则包含在dataset[0].edata中。ndata包含train_maskval_masktest_mask键,这些布尔掩码表示哪些节点属于训练集、验证集和测试集,另有一个feat键,其中包含图中每个节点的特征向量。

我们将构建一个包含两个GraphConv层的NodeClassifier网络。每一层将通过聚合邻居信息来计算新的节点表示。GraphConv层只是简单的tf.keras.layers.Layer对象,因此可以堆叠。第一层GraphConv将输入特征的大小(1,433)映射到一个大小为 16 的隐藏特征向量,第二层GraphConv将隐藏特征向量映射到一个大小为 2 的输出类别向量,从中可以读取类别。

请注意,GraphConv只是我们可以放入NodeClassifier模型中的众多图层之一。DGL 提供了多种图卷积层,如果需要,可以用它们替换GraphConv

class NodeClassifier(tf.keras.Model):
  def __init__(self, g, in_feats, h_feats, num_classes):
    super(NodeClassifier, self).__init__()
    self.g = g
    self.conv1 = GraphConv(in_feats, h_feats, activation=tf.nn.relu)
    self.conv2 = GraphConv(h_feats, num_classes)
  def call(self, in_feat):
    h = self.conv1(self.g, in_feat)
    h = self.conv2(self.g, h)
    return h
g = dataset[0]
model = NodeClassifier(
  g, g.ndata["feat"].shape[1], 16, dataset.num_classes) 

我们将使用以下代码在 CORA 数据集上训练该模型。我们将使用AdamW优化器(这是更流行的Adam优化器的变种,能够使模型具有更好的泛化能力),学*率设置为1e-2,权重衰减为5e-4。我们将训练 200 个周期。我们还将检测是否有可用的 GPU,如果有,我们会将图分配给 GPU。

如果检测到 GPU,TensorFlow 会自动将模型移动到 GPU 上:

def set_gpu_if_available():
  device = "/cpu:0"
  gpus = tf.config.list_physical_devices("GPU")
  if len(gpus) > 0:
    device = gpus[0]
  return device
device = set_gpu_if_available()
g = g.to(device) 

我们还定义了一个do_eval()方法,它通过给定特征和用于评估拆分的布尔掩码来计算准确度:

def do_eval(model, features, labels, mask):
  logits = model(features, training=False)
  logits = logits[mask]
  labels = labels[mask]
  preds = tf.math.argmax(logits, axis=1)
  acc = tf.reduce_mean(tf.cast(preds == labels, dtype=tf.float32))
  return acc.numpy().item() 

最后,我们准备好按照如下方式设置并运行我们的训练循环:

NUM_HIDDEN = 16
LEARNING_RATE = 1e-2
WEIGHT_DECAY = 5e-4
NUM_EPOCHS = 200
with tf.device(device):
  feats = g.ndata["feat"]
  labels = g.ndata["label"]
  train_mask = g.ndata["train_mask"]
  val_mask = g.ndata["val_mask"]
  test_mask = g.ndata["test_mask"]
  in_feats = feats.shape[1]
  n_classes = dataset.num_classes
  n_edges = dataset[0].number_of_edges()
  model = NodeClassifier(g, in_feats, NUM_HIDDEN, n_classes)
  loss_fcn = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
  optimizer = tfa.optimizers.AdamW(
    learning_rate=LEARNING_RATE, weight_decay=WEIGHT_DECAY)
  best_val_acc, best_test_acc = 0, 0
  history = []
  for epoch in range(NUM_EPOCHS):
    with tf.GradientTape() as tape:
      logits = model(feats)
      loss = loss_fcn(labels[train_mask], logits[train_mask])
      grads = tape.gradient(loss, model.trainable_weights)
      optimizer.apply_gradients(zip(grads, model.trainable_weights))

    val_acc = do_eval(model, feats, labels, val_mask)
    history.append((epoch + 1, loss.numpy().item(), val_acc))
    if epoch % 10 == 0:
      print("Epoch {:3d} | train loss: {:.3f} | val acc: {:.3f}".format(
         epoch, loss.numpy().item(), val_acc)) 

训练运行的输出显示训练损失从1.9下降到0.02,验证准确率从0.13上升到0.78

Epoch   0 | train loss: 1.946 | val acc: 0.134
Epoch  10 | train loss: 1.836 | val acc: 0.544
Epoch  20 | train loss: 1.631 | val acc: 0.610
Epoch  30 | train loss: 1.348 | val acc: 0.688
Epoch  40 | train loss: 1.032 | val acc: 0.732
Epoch  50 | train loss: 0.738 | val acc: 0.760
Epoch  60 | train loss: 0.504 | val acc: 0.774
Epoch  70 | train loss: 0.340 | val acc: 0.776
Epoch  80 | train loss: 0.233 | val acc: 0.780
Epoch  90 | train loss: 0.164 | val acc: 0.780
Epoch 100 | train loss: 0.121 | val acc: 0.784
Epoch 110 | train loss: 0.092 | val acc: 0.784
Epoch 120 | train loss: 0.073 | val acc: 0.784
Epoch 130 | train loss: 0.059 | val acc: 0.784
Epoch 140 | train loss: 0.050 | val acc: 0.786
Epoch 150 | train loss: 0.042 | val acc: 0.786
Epoch 160 | train loss: 0.037 | val acc: 0.786
Epoch 170 | train loss: 0.032 | val acc: 0.784
Epoch 180 | train loss: 0.029 | val acc: 0.784
Epoch 190 | train loss: 0.026 | val acc: 0.784 

现在我们可以评估我们训练的节点分类器在保留测试集上的表现:

test_acc = do_eval(model, feats, labels, test_mask)
print("Test acc: {:.3f}".format(test_acc)) 

这将输出模型在保留测试集上的总体准确度:

Test acc: 0.779 

图分类

图分类是通过聚合所有节点特征并对其应用一个或多个图卷积来预测整个图的某个属性完成的。例如,在药物发现过程中,当试图将分子分类为具有某种特定治疗特性时,这可能会很有用。在本节中,我们将通过一个示例展示图分类。

为了运行该示例,请确保已经安装 DGL 并设置为使用 TensorFlow 后端;有关如何操作的信息,请参阅上一节中的节点分类部分。要开始示例,请导入必要的库:

import dgl.data
import tensorflow as tf
import tensorflow_addons as tfa
from dgl.nn import GraphConv
from sklearn.model_selection import train_test_split 

我们将使用 DGL 提供的蛋白质数据集。该数据集是一组图,每个图都有节点特征和一个标签。每个图表示一个蛋白质分子,图中的每个节点表示分子中的一个原子。节点特征列出了原子的化学性质。标签表示该蛋白质分子是否是酶:

dataset = dgl.data.GINDataset("PROTEINS", self_loop=True)
print("node feature dimensionality:", dataset.dim_nfeats)
print("number of graph categories:", dataset.gclasses)
print("number of graphs in dataset:", len(dataset)) 

上面的调用会将蛋白质数据集下载到本地,并打印出一些数据集的信息。如您所见,每个节点的特征向量大小为3,图的类别数量为2(酶或非酶),数据集中的图数量为1113

node feature dimensionality: 3
number of graph categories: 2
number of graphs in dataset: 1113 

我们将首先把数据集分为训练集、验证集和测试集。我们将使用训练集来训练我们的 GNN,使用验证集进行验证,并在测试集上发布最终模型的结果:

tv_dataset, test_dataset = train_test_split(
  dataset, shuffle=True, test_size=0.2)
train_dataset, val_dataset = train_test_split(
  tv_dataset, test_size=0.1)
print(len(train_dataset), len(val_dataset), len(test_dataset)) 

这将数据集分成训练集、验证集和测试集,分别包含 801、89 和 223 个图。由于我们的数据集很大,我们需要使用小批量来训练网络,以免占满 GPU 内存。因此,本示例还将展示如何使用我们的数据进行小批量处理。

接下来,我们定义用于图分类的 GNN。它由两个GraphConv层堆叠而成,这些层将节点编码为它们的隐藏表示。由于目标是为每个图预测一个单一类别,我们需要将所有节点表示聚合为图级表示,我们通过使用dgl.mean_nodes()平均节点表示来实现:

class GraphClassifier(tf.keras.Model):
  def __init__(self, in_feats, h_feats, num_classes):
    super(GraphClassifier, self).__init__()
    self.conv1 = GraphConv(in_feats, h_feats, activation=tf.nn.relu)
    self.conv2 = GraphConv(h_feats, num_classes)
  def call(self, g, in_feat):
    h = self.conv1(g, in_feat)
    h = self.conv2(g, h)
    g.ndata["h"] = h
    return dgl.mean_nodes(g, "h") 

对于训练,我们设置了训练参数和do_eval()函数:

HIDDEN_SIZE = 16
BATCH_SIZE = 16
LEARNING_RATE = 1e-2
NUM_EPOCHS = 20
device = set_gpu_if_available()
def do_eval(model, dataset):
  total_acc, total_recs = 0, 0
  indexes = tf.data.Dataset.from_tensor_slices(range(len(dataset)))
  indexes = indexes.batch(batch_size=BATCH_SIZE)
  for batched_indexes in indexes:
    graphs, labels = zip(*[dataset[i] for i in batched_indexes])
    batched_graphs = dgl.batch(graphs)
    batched_labels = tf.convert_to_tensor(labels, dtype=tf.int64)
    batched_graphs = batched_graphs.to(device)
    logits = model(batched_graphs, batched_graphs.ndata["attr"])
    batched_preds = tf.math.argmax(logits, axis=1)
    acc = tf.reduce_sum(tf.cast(batched_preds == batched_labels,
                                dtype=tf.float32))
    total_acc += acc.numpy().item()
    total_recs += len(batched_labels)
  return total_acc / total_recs 

最后,我们定义并运行我们的训练循环来训练GraphClassifier模型。我们使用Adam优化器,学*率为1e-2,损失函数为SparseCategoricalCrossentropy,进行20轮训练:

with tf.device(device):
  model = GraphClassifier(
    dataset.dim_nfeats, HIDDEN_SIZE, dataset.gclasses)
  optimizer = tf.keras.optimizers.Adam(learning_rate=LEARNING_RATE)
  loss_fcn = tf.keras.losses.SparseCategoricalCrossentropy(
    from_logits=True)
  train_indexes = tf.data.Dataset.from_tensor_slices(
    range(len(train_dataset)))
  train_indexes = train_indexes.batch(batch_size=BATCH_SIZE)
  for epoch in range(NUM_EPOCHS):
    total_loss = 0
    for batched_indexes in train_indexes:
      with tf.GradientTape() as tape:
        graphs, labels = zip(*[train_dataset[i] for i in batched_indexes])
        batched_graphs = dgl.batch(graphs)
        batched_labels = tf.convert_to_tensor(labels, dtype=tf.int32)
        batched_graphs = batched_graphs.to(device)
        logits = model(batched_graphs, batched_graphs.ndata["attr"])
        loss = loss_fcn(batched_labels, logits)
        grads = tape.gradient(loss, model.trainable_weights)
        optimizer.apply_gradients(zip(grads, model.trainable_weights))
        total_loss += loss.numpy().item()

    val_acc = do_eval(model, val_dataset)
    print("Epoch {:3d} | train_loss: {:.3f} | val_acc: {:.3f}".format(
        epoch, total_loss, val_acc)) 

输出显示,随着GraphClassifier模型训练了 20 轮,损失逐渐下降,验证准确度逐渐提高:

Epoch   0 | train_loss: 34.401 | val_acc: 0.629
Epoch   1 | train_loss: 33.868 | val_acc: 0.629
Epoch   2 | train_loss: 33.554 | val_acc: 0.618
Epoch   3 | train_loss: 33.184 | val_acc: 0.640
Epoch   4 | train_loss: 32.822 | val_acc: 0.652
Epoch   5 | train_loss: 32.499 | val_acc: 0.663
Epoch   6 | train_loss: 32.227 | val_acc: 0.663
Epoch   7 | train_loss: 32.009 | val_acc: 0.697
Epoch   8 | train_loss: 31.830 | val_acc: 0.685
Epoch   9 | train_loss: 31.675 | val_acc: 0.685
Epoch  10 | train_loss: 31.580 | val_acc: 0.685
Epoch  11 | train_loss: 31.525 | val_acc: 0.708
Epoch  12 | train_loss: 31.485 | val_acc: 0.708
Epoch  13 | train_loss: 31.464 | val_acc: 0.708
Epoch  14 | train_loss: 31.449 | val_acc: 0.708
Epoch  15 | train_loss: 31.431 | val_acc: 0.708
Epoch  16 | train_loss: 31.421 | val_acc: 0.708
Epoch  17 | train_loss: 31.411 | val_acc: 0.708
Epoch  18 | train_loss: 31.404 | val_acc: 0.719
Epoch  19 | train_loss: 31.398 | val_acc: 0.719 

最后,我们在保留的测试数据集上评估训练好的模型:

test_acc = do_eval(model, test_dataset)
print("test accuracy: {:.3f}".format(test_acc)) 

这会打印出训练好的GraphClassifier模型在保留的测试集上的准确度:

test accuracy: 0.677 

准确度显示该模型可以成功地识别一个分子是酶还是非酶的概率略低于 70%。

链接预测

链接预测是一种边分类问题,任务是预测图中两个给定节点之间是否存在边。

许多应用程序,例如社交推荐,知识图完成等,可以被归纳为链接预测,即预测两个节点之间是否存在边缘关系,无论是引用还是被引用,在引用网络中的两篇论文之间。

通常的方法是将图中的所有边都视为正例,并采样一些不存在的边作为负例,并在这些正例和负例上训练链接预测分类器进行二元分类(边是否存在)。

在运行示例之前,请确保安装了 DGL 并设置为使用 TensorFlow 后端;请参考节点分类部分获取如何执行此操作的信息。让我们从导入必要的库开始:

import dgl
import dgl.data
import dgl.function as fn
import tensorflow as tf
import itertools
import numpy as np
import scipy.sparse as sp
from dgl.nn import SAGEConv
from sklearn.metrics import roc_auc_score 

对于我们的数据,我们将重复使用我们之前用于节点分类示例的 DGL 数据集中的 CORA 引用图。我们已经知道数据集的样子,所以这里不会再详细解剖它。如果你想要刷新记忆,请参考节点分类示例获取相关细节:

dataset = dgl.data.CoraGraphDataset()
g = dataset[0] 

现在,让我们准备我们的数据。为了训练我们的链接预测模型,我们需要一组正边和一组负边。正边是 CORA 引用图中已经存在的 10,556 条边之一,负边将从图的其余部分中采样的 10,556 对节点对。此外,我们需要将正边和负边分割为训练、验证和测试集:

u, v = g.edges()
# positive edges
eids = np.arange(g.number_of_edges())
eids = np.random.permutation(eids)
test_size = int(len(eids) * 0.2)
val_size = int((len(eids) - test_size) * 0.1)
train_size = g.number_of_edges() - test_size - val_size
u = u.numpy()
v = v.numpy()
test_pos_u = u[eids[0:test_size]]
test_pos_v = v[eids[0:test_size]]
val_pos_u = u[eids[test_size:test_size + val_size]]
val_pos_v = v[eids[test_size:test_size + val_size]]
train_pos_u = u[eids[test_size + val_size:]]
train_pos_v = v[eids[test_size + val_size:]]
# negative edges
adj = sp.coo_matrix((np.ones(len(u)), (u, v)))
adj_neg = 1 - adj.todense() - np.eye(g.number_of_nodes())
neg_u, neg_v = np.where(adj_neg != 0)
neg_eids = np.random.choice(len(neg_u), g.number_of_edges())
test_neg_u = neg_u[neg_eids[:test_size]]
test_neg_v = neg_v[neg_eids[:test_size]]
val_neg_u = neg_u[neg_eids[test_size:test_size + val_size]]
val_neg_v = neg_v[neg_eids[test_size:test_size + val_size]]
train_neg_u = neg_u[neg_eids[test_size + val_size:]]
train_neg_v = neg_v[neg_eids[test_size + val_size:]]
# remove edges from training graph
test_edges = eids[:test_size]
val_edges = eids[test_size:test_size + val_size]
train_edges = eids[test_size + val_size:]
train_g = dgl.remove_edges(g, np.concatenate([test_edges, val_edges])) 

现在,我们构建一个 GNN,它将使用两个GraphSAGE层计算节点表示,每个层通过平均其邻居信息来计算节点表示:

class LinkPredictor(tf.keras.Model):
  def __init__(self, g, in_feats, h_feats):
    super(LinkPredictor, self).__init__()
    self.g = g
    self.conv1 = SAGEConv(in_feats, h_feats, 'mean')
    self.relu1 = tf.keras.layers.Activation(tf.nn.relu)
    self.conv2 = SAGEConv(h_feats, h_feats, 'mean')
  def call(self, in_feat):
    h = self.conv1(self.g, in_feat)
    h = self.relu1(h)
    h = self.conv2(self.g, h)
    return h 

然而,链接预测要求我们计算节点对的表示,DGL 建议您将节点对视为另一个图,因为您可以将节点对定义为一条边。对于链接预测,我们将有一个包含所有正例作为边的正图,以及一个包含所有负例作为边的负图。正图和负图都包含与原始图相同的节点集:

train_pos_g = dgl.graph((train_pos_u, train_pos_v), 
  num_nodes=g.number_of_nodes())
train_neg_g = dgl.graph((train_neg_u, train_neg_v), 
  num_nodes=g.number_of_nodes())
val_pos_g = dgl.graph((val_pos_u, val_pos_v), 
  num_nodes=g.number_of_nodes())
val_neg_g = dgl.graph((val_neg_u, val_neg_v), 
  num_nodes=g.number_of_nodes())
test_pos_g = dgl.graph((test_pos_u, test_pos_v), 
  num_nodes=g.number_of_nodes())
test_neg_g = dgl.graph((test_neg_u, test_neg_v), 
  num_nodes=g.number_of_nodes()) 

接下来,我们将定义一个预测器类,它将从LinkPredictor类中获取节点表示集,并使用DGLGraph.apply_edges方法计算边特征分数,这些分数是源节点特征和目标节点特征的点积(在这种情况下一起从LinkPredictor输出):

class DotProductPredictor(tf.keras.Model):
  def call(self, g, h):
    with g.local_scope():
      g.ndata['h'] = h
      # Compute a new edge feature named 'score' by a dot-product 
      # between the source node feature 'h' and destination node 
      # feature 'h'.
      g.apply_edges(fn.u_dot_v('h', 'h', 'score'))
      # u_dot_v returns a 1-element vector for each edge so you 
      # need to squeeze it.
      return g.edata['score'][:, 0] 

您还可以构建一个自定义预测器,例如具有两个密集层的多层感知器,如下面的代码所示。请注意,apply_edges方法描述了如何计算边缘分数:

class MLPPredictor(tf.keras.Model):
  def __init__(self, h_feats):
    super().__init__()
    self.W1 = tf.keras.layers.Dense(h_feats, activation=tf.nn.relu)
    self.W2 = tf.keras.layers.Dense(1)
  def apply_edges(self, edges):
    h = tf.concat([edges.src["h"], edges.dst["h"]], axis=1)
    return {
      "score": self.W2(self.W1(h))[:, 0]
    }
  def call(self, g, h):
    with g.local_scope():
      g.ndata['h'] = h
      g.apply_edges(self.apply_edges)
      return g.edata['score'] 

我们实例化了之前定义的LinkPredictor模型,选择了Adam优化器,并声明我们的损失函数为BinaryCrossEntropy(因为我们的任务是二分类)。在我们的示例中,将使用的预测头是DotProductPredictor。但是,MLPPredictor也可以作为替代品使用;只需将下面的pred变量替换为指向MLPPredictor,而不是DotProductPredictor

HIDDEN_SIZE = 16
LEARNING_RATE = 1e-2
NUM_EPOCHS = 100
model = LinkPredictor(train_g, train_g.ndata['feat'].shape[1], 
    HIDDEN_SIZE)
optimizer = tf.keras.optimizers.Adam(learning_rate=LEARNING_RATE)
loss_fcn = tf.keras.losses.BinaryCrossentropy(from_logits=True)
pred = DotProductPredictor() 

我们还为训练循环定义了一些便利函数。第一个函数计算从正图和负图返回的得分之间的损失,第二个函数根据这两个得分计算曲线下面积AUC)。AUC 是评估二分类模型的常用指标:

def compute_loss(pos_score, neg_score):
    scores = tf.concat([pos_score, neg_score], axis=0)
    labels = tf.concat([
      tf.ones(pos_score.shape[0]),
      tf.zeros(neg_score.shape[0])
    ], axis=0
)
    return loss_fcn(labels, scores)
def compute_auc(pos_score, neg_score):
    scores = tf.concat([pos_score, neg_score], axis=0).numpy()
    labels = tf.concat([
      tf.ones(pos_score.shape[0]),
      tf.zeros(neg_score.shape[0])
    ], axis=0).numpy()
    return roc_auc_score(labels, scores) 

我们现在训练我们的LinkPredictor GNN,进行 100 个周期的训练,使用以下训练循环:

for epoch in range(NUM_EPOCHS):
  in_feat = train_g.ndata["feat"]
  with tf.GradientTape() as tape:
    h = model(in_feat)
    pos_score = pred(train_pos_g, h)
    neg_score = pred(train_neg_g, h)
    loss = compute_loss(pos_score, neg_score)
    grads = tape.gradient(loss, model.trainable_weights)
    optimizer.apply_gradients(zip(grads, model.trainable_weights))
  val_pos_score = pred(val_pos_g, h)
  val_neg_score = pred(val_neg_g, h)
  val_auc = compute_auc(val_pos_score, val_neg_score)
  if epoch % 5 == 0:
    print("Epoch {:3d} | train_loss: {:.3f}, val_auc: {:.3f}".format(
      epoch, loss, val_auc)) 

这将返回以下训练日志:

Epoch   0 | train_loss: 0.693, val_auc: 0.566
Epoch   5 | train_loss: 0.681, val_auc: 0.633
Epoch  10 | train_loss: 0.626, val_auc: 0.746
Epoch  15 | train_loss: 0.569, val_auc: 0.776
Epoch  20 | train_loss: 0.532, val_auc: 0.805
Epoch  25 | train_loss: 0.509, val_auc: 0.820
Epoch  30 | train_loss: 0.492, val_auc: 0.824
Epoch  35 | train_loss: 0.470, val_auc: 0.833
Epoch  40 | train_loss: 0.453, val_auc: 0.835
Epoch  45 | train_loss: 0.431, val_auc: 0.842
Epoch  50 | train_loss: 0.410, val_auc: 0.851
Epoch  55 | train_loss: 0.391, val_auc: 0.859
Epoch  60 | train_loss: 0.371, val_auc: 0.861
Epoch  65 | train_loss: 0.350, val_auc: 0.861
Epoch  70 | train_loss: 0.330, val_auc: 0.861
Epoch  75 | train_loss: 0.310, val_auc: 0.862
Epoch  80 | train_loss: 0.290, val_auc: 0.860
Epoch  85 | train_loss: 0.269, val_auc: 0.856
Epoch  90 | train_loss: 0.249, val_auc: 0.852
Epoch  95 | train_loss: 0.228, val_auc: 0.848 

现在,我们可以将训练好的模型与保留的测试集进行评估:

pos_score = tf.stop_gradient(pred(test_pos_g, h))
neg_score = tf.stop_gradient(pred(test_neg_g, h))
print('Test AUC', compute_auc(pos_score, neg_score)) 

这将返回我们LinkPredictor GNN 的以下测试 AUC:

Test AUC 0.8266960571287392 

这一点相当令人印象深刻,因为这意味着链接预测器可以正确预测测试集中作为真实标签呈现的 82%的链接。

图的自定义

我们已经看到了如何为常见的图 ML 任务构建和训练 GNN。然而,为了方便起见,我们选择在我们的模型中使用预构建的 DGL 图卷积层。虽然不太可能,但你可能需要一个 DGL 包中没有提供的图层。DGL 提供了一个消息传递 API,允许你轻松构建自定义图层。在本节的第一部分,我们将看一个示例,展示如何使用消息传递 API 构建一个自定义的图卷积层。

我们还加载了来自 DGL 数据包的数据集供我们的示例使用。但更有可能的是,我们需要使用自己的数据。因此,在本节的第二部分,我们将看到如何将自己的数据转换为 DGL 数据集。

自定义层和消息传递

尽管 DGL 提供了许多开箱即用的图层,但可能会有一些情况,现有的图层不完全满足我们的需求,我们需要构建自己的图层。

幸运的是,所有这些图层都基于图中节点之间消息传递的共同基本概念。因此,为了构建一个自定义的 GNN 层,你需要理解消息传递范式是如何工作的。这个范式也被称为消息传递神经网络MPNN)框架[5]:

图中的每个节点u都有一个隐藏状态(最初是其特征向量),由h[u]表示。对于每对节点uv,它们是邻居节点,即由边e[u->v]连接,我们应用一个称为消息函数的函数M。消息函数M会应用于图中的每个节点。然后,我们将M的输出与所有邻居节点的输出聚合,产生消息m。这里的被称为reduce 函数。注意,尽管我们用求和符号表示 reduce 函数,但它可以是任何聚合函数。最后,我们使用获得的消息和节点的前一个状态更新节点v的隐藏状态。此步骤中应用的函数U被称为更新函数

消息传递算法会重复特定次数。之后,我们进入读取阶段,在该阶段我们从每个节点提取特征向量,表示整个图。例如,在节点分类的情况下,节点的最终特征向量可能代表节点的类别。

本节将使用 MPNN 框架实现一个 GraphSAGE 层。尽管 DGL 已经提供了实现这一功能的dgl.nn.SAGEConv,但本示例旨在展示如何使用 MPNN 创建自定义图层。GraphSAGE 层的消息传递步骤如下:

使用 MPNN 实现我们自定义 GraphSAGE 层的代码如下所示。DGL 函数update_all的调用允许你指定message_fnreduce_fn,这也是 DGL 内置的函数,而tf.concatDense层则表示最终的更新函数:

import dgl
import dgl.data
import dgl.function as fn
import tensorflow as tf
class CustomGraphSAGE(tf.keras.layers.Layer):
  def __init__(self, in_feat, out_feat):
    super(CustomGraphSAGE, self).__init__()
    # A linear submodule for projecting the input and neighbor 
    # feature to the output.
    self.linear = tf.keras.layers.Dense(out_feat, activation=tf.nn.relu)
  def call(self, g, h):
    with g.local_scope():
        g.ndata["h"] = h
        # update_all is a message passing API.
        g.update_all(message_func=fn.copy_u('h', 'm'),
                     reduce_func=fn.mean('m', 'h_N'))
        h_N = g.ndata['h_N']
        h_total = tf.concat([h, h_N], axis=1)
        return self.linear(h_total) 

在这里,我们看到update_all函数指定了一个message_func,该函数只是将节点当前的特征向量复制到消息向量m,然后对每个节点的邻域内所有消息向量进行平均。如你所见,这忠实地遵循了上述第一个 GraphSAGE 方程。DGL 提供了许多类似的内置函数(docs.dgl.ai/api/python/dgl.function.xhtml)。

一旦在第一步中计算出了邻域向量h_N,它将与输入特征向量h拼接在一起,然后通过带有 ReLU 激活的Dense层,如上面第二个 GraphSAGE 方程所述。我们已经通过CustomGraphSAGE对象实现了 GraphSAGE 层。

下一步是将其放入 GNN 中以查看它的效果。以下代码展示了一个使用我们自定义SAGEConv实现的两层CustomGNN模型:

class CustomGNN(tf.keras.Model):
  def __init__(self, g, in_feats, h_feats, num_classes):
    super(CustomGNN, self).__init__()
    self.g = g
    self.conv1 = CustomGraphSAGE(in_feats, h_feats)
    self.relu1 = tf.keras.layers.Activation(tf.nn.relu)
    self.conv2 = CustomGraphSAGE(h_feats, num_classes)
  def call(self, in_feat):
    h = self.conv1(self.g, in_feat)
    h = self.relu1(h)
    h = self.conv2(self.g, h)
    return h 

我们将运行该模型对 CORA 数据集进行节点分类,具体细节应该从之前的示例中有所了解。

上述代码假设是一个无权图,即节点之间的边具有相同的权重。这一条件适用于 CORA 数据集,其中每条边代表一篇论文对另一篇论文的引用。

然而,我们可以设想一些场景,其中边的权重可能基于某条边被触发的次数。例如,连接产品和用户的边可以用于用户推荐。

我们需要对代码进行的唯一修改是让权重在我们的消息函数中发挥作用。也就是说,如果节点u和邻居节点v之间的边发生了k次,我们应该将这条边考虑k次。以下代码展示了我们自定义的 GraphSAGE 层,它能够处理带权边:

class CustomWeightedGraphSAGE(tf.keras.layers.Layer):
  def __init__(self, in_feat, out_feat):
    super(CustomWeightedGraphSAGE, self).__init__()
    # A linear submodule for projecting the input and neighbor 
    # feature to the output.
    self.linear = tf.keras.layers.Dense(out_feat, activation=tf.nn.relu)
  def call(self, g, h, w):
    with g.local_scope():
      g.ndata['h'] = h
      g.edata['w'] = w
      g.update_all(message_func=fn.u_mul_e('h', 'w', 'm'),
                   reduce_func=fn.mean('m', 'h_N'))
      h_N = g.ndata['h_N']
      h_total = tf.concat([h, h_N], axis=1)
      return self.linear(h_total) 

这段代码期望一个额外的边属性w,它包含边的权重。你可以通过以下方式在 CORA 数据集中模拟:

g.edata["w"] = tf.cast(
   tf.random.uniform((g.num_edges(), 1), minval=3, maxval=10, 
                     dtype=tf.int32),
   dtype=tf.float32) 

CustomWeightedGraphSAGE中的message_func已经从简单地将特征向量h复制到消息向量m,变成了将hw相乘以生成消息向量m。其他部分与CustomGraphSAGE中的代码相同。新的CustomWeightedGraphSAGE层现在可以简单地放入调用类CustomGNN中,替代最初调用的CustomGraphSAGE

自定义图数据集

更常见的使用案例是使用你自己的数据来训练 GNN 模型。显然,在这种情况下,你不能使用 DGL 提供的数据集(正如我们在之前的所有示例中所使用的),你必须将自己的数据包装成自定义图数据集。

你的自定义图数据集应继承自 DGL 提供的dgl.data.DGLDataset对象,并实现以下方法:

  • __getitem__(self, i) – 从数据集中检索第i个示例。检索到的示例包含一个单一的 DGL 图及其标签(如果适用)。

  • __len__(self) – 数据集中的示例数量。

  • process(self) – 定义如何从磁盘加载和处理原始数据。

如我们之前所见,节点分类和链路预测操作在单一图上,而图分类则作用于一组图。虽然这两种情况的处理方法大致相同,但每种情况都有其特定的关注点,因此我们将在下文提供一个例子来分别演示这两种方法。

数据集中的单一图

在我们的例子中,我们将选择 Zachary 的空手道俱乐部图,它代表了一个空手道俱乐部的成员,观察了三年。随着时间的推移,管理员(Officer)和教练(Mr. Hi)之间发生了分歧,俱乐部成员在两人之间分裂并重新组合,分别在下图中以蓝色和红色节点表示。Zachary 空手道俱乐部网络可从 NetworkX 库中下载:

A picture containing plant, red  Description automatically generated

图 17.2:空手道俱乐部网络的图表示

该图包含 34 个节点,每个节点被标记为“Officer”或“Mr. Hi”,取决于它们在拆分后的分组。图中包含 78 条无向、无权重的边。两名成员之间的边表示他们在俱乐部外相互互动。为了使这个数据集在 GNN 使用中更具现实性,我们将为每个节点附加一个 10 维的随机特征向量,并将边权作为边特征。以下是将空手道俱乐部图转换为 DGL 数据集的代码,您可以将其用于后续的节点或边分类任务:

class KarateClubDataset(DGLDataset):
  def __init__(self):
    super().__init__(name="karate_club")
  def __getitem__(self, i):
    return self.graph
  def __len__(self):
    return 1
  def process(self):
    G = nx.karate_club_graph()
    nodes = [node for node in G.nodes]
    edges = [edge for edge in G.edges]
    node_features = tf.random.uniform(
        (len(nodes), 10), minval=0, maxval=1, dtype=tf.dtypes.float32)
    label2int = {"Mr. Hi": 0, "Officer": 1}
    node_labels = tf.convert_to_tensor(
        [label2int[G.nodes[node]["club"]] for node in nodes])
    edge_features = tf.random.uniform(
        (len(edges), 1), minval=3, maxval=10, dtype=tf.dtypes.int32)
    edges_src = tf.convert_to_tensor([u for u, v in edges])
    edges_dst = tf.convert_to_tensor([v for u, v in edges])
    self.graph = dgl.graph((edges_src, edges_dst), num_nodes=len(nodes))
    self.graph.ndata["feat"] = node_features
    self.graph.ndata["label"] = node_labels
    self.graph.edata["weight"] = edge_features
    # assign masks indicating the split (training, validation, test)
    n_nodes = len(nodes)
    n_train = int(n_nodes * 0.6)
    n_val = int(n_nodes * 0.2)
    train_mask = tf.convert_to_tensor(
      np.hstack([np.ones(n_train), np.zeros(n_nodes - n_train)]),
      dtype=tf.bool)
    val_mask = tf.convert_to_tensor(
      np.hstack([np.zeros(n_train), np.ones(n_val), 
                 np.zeros(n_nodes - n_train - n_val)]),
      dtype=tf.bool)
    test_mask = tf.convert_to_tensor(
      np.hstack([np.zeros(n_train + n_val), 
                 np.ones(n_nodes - n_train - n_val)]),
      dtype=tf.bool)
    self.graph.ndata["train_mask"] = train_mask
    self.graph.ndata["val_mask"] = val_mask
    self.graph.ndata["test_mask"] = test_mask 

大部分逻辑在process方法中。我们调用 NetworkX 方法将空手道俱乐部图作为 NetworkX 图获取,然后将其转换为带有节点特征和标签的 DGL 图对象。尽管空手道俱乐部图没有定义节点和边特征,我们制造了一些随机数并将其设置为这些属性。请注意,这仅用于本示例,目的是展示如果您的图包含节点和边特征,这些特征需要更新的位置。需要注意的是,数据集中只包含一个图。

此外,我们还希望将图拆分为训练、验证和测试集,以用于节点分类任务。为此,我们为每个节点分配掩码,指示其是否属于这些拆分中的某一组。我们通过将图中的节点按 60/20/20 比例拆分,并为每个拆分分配布尔掩码来简单地完成这一操作。

为了从我们的代码中实例化这个数据集,我们可以这样写:

dataset = KarateClubDataset()
g = dataset[0]
print(g) 

这将给我们以下输出(略作重新格式化以提高可读性)。主要的两个结构是ndata_schemasedata_schemas,分别可以通过g.ndatag.edata访问。在ndata_schemas中,我们有指向节点特征(feats)、节点标签(label)以及表示训练、验证和测试拆分的掩码(train_maskval_masktest_mask)的键。在edata_schemas下,有表示边权的weight属性:

Graph(num_nodes=34, 
      num_edges=78,
      ndata_schemes={
        'feat': Scheme(shape=(10,), dtype=tf.float32),
        'label': Scheme(shape=(), dtype=tf.int32),
        'train_mask': Scheme(shape=(), dtype=tf.bool),
        'val_mask': Scheme(shape=(), dtype=tf.bool),
        'test_mask': Scheme(shape=(), dtype=tf.bool)
      }
      edata_schemes={
         'weight': Scheme(shape=(1,), dtype=tf.int32)
      }
) 

请参考节点分类和链路预测的示例,以了解如何使用这种自定义数据集。

数据集中多个图的集合

支持图分类任务的数据集将包含多个图及其关联标签,每个图对应一个标签。对于我们的示例,我们将考虑一个假设的数据集,包含作为图表示的分子,任务是预测分子是否有毒(一个二元预测)。

我们将使用 NetworkX 方法 random_regular_graph() 来生成具有随机节点数和节点度数的合成图。对于每个图的每个节点,我们将附加一个随机的 10 维特征向量。每个节点将有一个标签(0 或 1),表示图是否有毒。请注意,这仅仅是对真实数据可能样貌的模拟。对于真实数据,每个图的结构以及节点向量的值(在我们的案例中是随机的)将对目标变量产生实际影响,即分子是否有毒。

下图展示了一些合成“分子”可能的样子:

图表,雷达图,描述自动生成

图 17.3:一些使用 NetworkX 生成的随机规则图的例子

这是将一组随机 NetworkX 图转换为 DGL 图数据集以进行图分类的代码。我们将生成 100 个这样的图,并将它们以 DGL 数据集的形式存储在一个列表中:

from networkx.exception import NetworkXError
class SyntheticDataset(DGLDataset):
  def __init__(self):
    super().__init__(name="synthetic")
  def __getitem__(self, i):
    return self.graphs[i], self.labels[i]
  def __len__(self):
    return len(self.graphs)
  def process(self):
    self.graphs, self.labels = [], []
    num_graphs = 0
    while(True):
      d = np.random.randint(3, 10)
      n = np.random.randint(5, 10)
      if ((n * d) % 2) != 0:
        continue
      if n < d:
        continue
      try:
        g = nx.random_regular_graph(d, n)
      except NetworkXError:
        continue
      g_edges = [edge for edge in g.edges]
      g_src = [u for u, v in g_edges]
      g_dst = [v for u, v in g_edges]
      g_num_nodes = len(g.nodes)
      label = np.random.randint(0, 2)
      # create graph and add to list of graphs and labels
      dgl_graph = dgl.graph((g_src, g_dst), num_nodes=g_num_nodes)
      dgl_graph.ndata["feats"] = tf.random.uniform(
          (g_num_nodes, 10), minval=0, maxval=1, dtype=tf.dtypes.float32)
      self.graphs.append(dgl_graph)
      self.labels.append(label)
      num_graphs += 1
      if num_graphs > 100:
        break
    self.labels = tf.convert_to_tensor(self.labels, dtype=tf.dtypes.int64) 

一旦创建完成,我们可以像这样在代码中调用它:

dataset = SyntheticDataset()
graph, label = dataset[0]   
print(graph)
print("label:", label) 

这为 DGL 数据集中的第一个图生成以下输出(稍作格式调整以便阅读)。如你所见,数据集中的第一个图有 6 个节点和 15 条边,并且包含一个大小为 10 的特征向量(通过 feats 键访问)。标签是一个 0 维张量(即标量),类型为 long(int64):

Graph(num_nodes=6, num_edges=15,
      ndata_schemes={
        'feats': Scheme(shape=(10,), dtype=tf.float32)}
      edata_schemes={})
label: tf.Tensor(0, shape=(), dtype=int64) 

如前所述,为了看到如何使用这个自定义数据集进行任务(如图分类),请参考本章早些时候关于图分类的示例。

未来方向

图神经网络是一个迅速发展的学科。到目前为止,我们已经介绍了如何在各种流行的图任务中处理静态同构图,这涵盖了许多现实世界的使用案例。然而,很可能一些图既不是同构的,也不是静态的,而且它们也不能轻易地简化成这种形式。在这一部分,我们将探讨如何处理异构和时序图。

异构图

异构图 [7],也叫做异构图,区别于我们迄今所见的图,它们可能包含不同种类的节点和边。这些不同类型的节点和边可能还包含不同类型的属性,包括具有不同维度的表示。异构图的流行例子包括包含作者和论文的引用图、包含用户和产品的推荐图,以及可以包含多种不同类型实体的知识图谱。

你可以通过手动为每种边类型分别实现消息和更新函数,使用 MPNN 框架处理异构图。每种边类型由三元组(源节点类型、边类型和目标节点类型)定义。然而,DGL 提供了使用 dgl.heterograph() API 支持异构图,其中图是作为一系列图来指定的,每个边类型对应一个图。

与异构图相关的典型学*任务与同构图的任务相似,即节点分类与回归、图分类和边分类/链接预测。用于处理异构图的一个流行图层是关系 GCNR-GCN,它作为 DGL 中的内置图层提供。

时序图

时序图[8]是 Twitter 开发的一个框架,用于处理随时间变化的动态图。虽然 GNN 模型主要关注不随时间变化的静态图,但加入时间维度使我们能够对社交网络、金融交易和推荐系统等许多本质上是动态的现象进行建模。在这些系统中,正是动态行为传递了重要的洞察。

动态图可以表示为一系列定时事件,例如节点和边的添加与删除。这些事件流输入到编码器网络中,编码器为图中的每个节点学*一个依赖于时间的编码。解码器基于该编码进行训练,以支持某些特定任务,如未来时刻的链接预测。目前,DGL 库对时序图尚不支持,主要是因为这是一个快速发展的研究领域。

从高层次来看,时序图网络TGN)编码器通过基于节点的交互和时间更新创建节点的压缩表示。每个节点的当前状态存储在 TGN 内存中,作为 RNN 的隐藏状态s[t];然而,对于每个节点i和时间点t,我们有一个单独的状态向量s[t](t)

类似于我们在 MPNN 框架中看到的消息函数,计算两节点ij的两个消息m[i]和m[j],输入为状态向量及其交互。然后,使用记忆更新器将消息和状态向量组合在一起,通常该记忆更新器实现为 RNN。研究发现,TGNs 在未来边预测和动态节点分类任务中,无论在准确度还是速度上,都优于静态图方法。

概述

在本章中,我们介绍了图神经网络,这是一组令人兴奋的技术,可以从节点特征以及节点之间的交互中学*。我们讲解了图卷积为何有效的直觉,以及它们与计算机视觉中卷积的相似之处。我们描述了一些常见的图卷积,这些卷积作为层由 DGL 提供。我们展示了如何使用 DGL 处理常见的图任务,如节点分类、图分类和链接预测。此外,如果标准的 DGL 图层不能满足我们的需求,我们已经学*了如何利用 DGL 的消息传递框架实现自己的图卷积层。我们还介绍了如何构建适用于我们自己图数据的 DGL 数据集。最后,我们探讨了一些图神经网络的前沿方向,即异构图和时序图。这些内容应当能够为你提供使用 GNNs 解决该领域有趣问题的技能。

在下一章中,我们将关注学*与深度学*项目相关的一些最佳机器学*实践。

参考文献

  1. Kipf, T. 和 Welling, M. (2017). 使用图卷积网络进行半监督分类。Arxiv 预印本,arXiv: 1609.02907 [cs.LG]。来自 arxiv.org/abs/1609.02907

  2. Velickovic, P., 等人 (2018). 图注意力网络。Arxiv 预印本,arXiv 1710.10903 [stat.ML]。来自 arxiv.org/abs/1710.10903

  3. Hamilton, W. L., Ying, R., 和 Leskovec, J. (2017). 大规模图上的归纳表示学*。Arxiv 预印本,arXiv: 1706.02216 [cs.SI]。来自 arxiv.org/abs/1706.02216

  4. Xu, K., 等人 (2018). 图神经网络有多强大?。Arxiv 预印本,arXiv: 1810.00826 [cs.LG]。来自 arxiv.org/abs/1810.00826

  5. Gilmer, J., 等人 (2017). 量子化学中的神经信息传递。Arxiv 预印本,arXiv: 1704.01212 [cs.LG]。来自 arxiv.org/abs/1704.01212

  6. Zachary, W. W. (1977). 小组冲突与分裂中的信息流模型。人类学研究杂志。来自 www.journals.uchicago.edu/doi/abs/10.1086/jar.33.4.3629752

  7. Pengfei, W. (2020). 在 DGL 中处理异构图。博客文章。来自 www.jianshu.com/p/767950b560c4

  8. Bronstein, M. (2020). 时序图网络。博客文章。来自 towardsdatascience.com/temporal-graph-networks-ab8f327f2efe

加入我们的书籍 Discord 社区

加入我们的 Discord 社区,与志同道合的人相遇,并与超过 2000 名成员一起学*,网址:packt.link/keras

第十八章:机器学*最佳实践

机器学*不仅仅是构建和训练模型。到目前为止,在本书中,我们集中介绍了不同的深度学*算法,并介绍了最新的算法、它们的强大功能以及它们的局限性。在本章中,我们将焦点从机器学*/深度学*算法转向那些可以帮助我们成为更好的机器学*工程师和科学家的实践。

本章将包括:

  • 对 AI/ML 最佳实践的需求

  • 数据最佳实践

  • 模型最佳实践

对最佳实践的需求

今天,深度学*算法不仅是一个活跃的研究领域,还成为许多商业系统和产品的重要组成部分。图 18.1展示了过去五年人工智能初创企业的投资情况。你可以看到对 AI 初创企业的兴趣持续增长。从医疗保健到虚拟助手,从清洁机器人到自动驾驶汽车,今天的人工智能是许多*期重要技术进步的推动力。AI 正在决定一个人是否应该被雇用,或者是否应该获得贷款。AI 正在创建你在社交媒体上看到的动态。有自然语言处理NLP)机器人生成内容、图像、面孔——任何你能想到的东西——有人在努力将 AI 应用其中。由于大多数团队由多个成员组成,跨领域合作,因此建立最佳实践至关重要。什么是最佳实践呢?嗯,这个问题没有定论,因为机器学*的最佳实践取决于具体的问题领域和数据集。

然而,在本章中,我们将提供一些机器学*最佳实践的通用建议:

图表,柱状图 说明自动生成

图 18.1:过去五年(2017–2022 年)AI 初创企业的投资情况

以下是一些机器学*中实施最佳实践的重要原因:

  • 它可以确保模型的构建既有效又高效。

  • 它有助于避免过拟合等问题,避免在未见过的数据上表现不佳。

  • 它可以确保模型是可解释的,并且可以轻松地向非技术受众解释。

  • 它有助于促进机器学*研究中的可重复性。

在接下来的部分中,您将了解一些FAANGFacebookAmazonAppleNetflix,和Google)公司以及 AI 影响者提倡的最佳实践。遵循这些建议可以帮助你避免常见错误,避免得出不准确或糟糕的结果。这些最佳实践将帮助确保你的 AI 服务是准确和可靠的。最后,最佳实践还可以帮助你优化 AI 服务的性能和效率。

数据最佳实践

数据在当今世界变得越来越重要。不仅是人工智能领域的人,许多世界领导者也将数据称为“新黄金”或“新石油”——基本上是将推动全球经济的商品。数据在决策过程中、交通管理、供应链问题处理、支持医疗等方面都起着重要作用。通过数据获得的洞察能够帮助企业提升效率和表现。

最重要的是,数据可以用来创造新的知识。例如,在商业领域,数据可以用来识别新的趋势。在医学领域,数据可以用来揭示疾病之间的新关系并开发新的治疗方法。然而,我们的模型只有在所训练数据的基础上才能发挥作用。因此,数据的重要性在未来可能会继续增加。随着数据变得更加易于访问和使用,它将在多个领域变得越来越重要。现在,让我们来看一些常见的瓶颈以及应对这些瓶颈的最佳方法。

特征选择

当我们开始解决任何 AI/ML 问题时,第一步是提出假设:哪些输入特征可以帮助我们分类或预测输出?选择正确的特征对任何机器学*模型至关重要,但有时很难知道选择哪些特征。如果你在模型中加入过多无关的特征,结果将不准确。如果加入的特征太少,模型可能无法从数据中学*。因此,特征选择是机器学*中的一个关键步骤,它帮助你减少噪声并提高模型的准确性:

  • 一般来说,在使用任何特征工程之前,应该从直接观察和报告的特征开始,而不是从学*到的特征开始。学*到的特征是通过外部系统(如聚类算法)或通过深度模型本身生成的特征。简化可以帮助你实现一个稳固的基准性能,之后你可以尝试更多复杂的策略。

  • 删除你未使用的特征。未使用的特征会增加技术债务,它们使代码更难阅读和维护,还可能导致意外的错误和安全漏洞。当然,跟踪哪些特征被使用、哪些没有被使用可能会很困难。然而,不要随意删除特征;要仔细进行数据分析和探索 – 理解这些特征。一个好的方法是为每个特征指定一个负责人。特征负责人将负责维护该特征并记录其理由,以便知识能够在团队之间共享。这也意味着,每当特征负责人离开团队时,责任将转交给其他成员。通过花时间理解并移除未使用的特征,你可以保持代码清晰,避免积累技术债务。

  • 我们常常认为更多的特征意味着更好的模型,但事实远非如此。与其使用你不理解的数百万个特征,不如使用具体的特征;你可以通过正则化方法去除那些仅适用于少数样本的特征。

  • 你还可以结合和修改特征来创建新的特征。你可以通过多种方式进行结合和修改。例如,你可以将连续值特征离散化为多个离散特征。你还可以通过交叉(乘法)两个或多个现有特征来创建新的合成特征。例如,如果你有“身高”和“体重”这两个特征,你可以通过将这两个特征结合起来,创建一个新的特征“BMI”。特征交叉能够提供比单个特征更强的预测能力。当两个特征各自对期望的结果具有一定的预测性时,结合后可能会显著提高预测性。这是因为合并后的特征捕捉到了单个特征无法捕捉的信息。特征交叉是一个强大的工具,有助于提高预测模型的准确性。

特征和数据

当我们从学*数据科学转向解决实际问题时,一个问题是缺乏数据。尽管互联网、移动设备和物联网设备生成了大量的数据,但获取高质量的标注数据仍然是一个大难题。标注的成本通常既高又费时,而且需要专业知识。

因此,我们需要确保有足够的数据来训练模型。根据经验法则,一个模型可以学*的输入特征数量(n)大致与你拥有的数据量(N)成正比(n << N)。在这种情况下,以下是一些可以遵循的建议:

  • 将模型学*的规模与数据量相匹配。例如,如果我们只有 1,000 个标注样本,那么就使用高度人工设计的特征。对于 1,000 个标注样本,一个好的选择是使用一打精心挑选的特征。但如果我们有数百万个样本,那么我们就可以使用大约十万个特征。如果我们有数十亿的数据样本,那么我们可以建立一个包含数百万个特征的模型。

  • 如果我们有过多的数据,我们不会随便丢弃它;相反,我们可以使用重要性加权抽样(web.stanford.edu/class/archive/cs/cs224n/cs224n.1214/reports/final_reports/report247.pdf)。其思想是根据某些分布特征为每个样本分配一个重要性权重,这些特征能够捕捉与专业领域数据的相似性。

  • 另一种应对数据不足的方法是使用数据增强。最初由 H. S. Baird 在他的文章《文档图像分析》中提出[7],它已被证明是通过简单的图像变换(如水平翻转、垂直翻转、旋转、平移等)增加图像数据的好方法。大多数深度学*框架都提供了数据生成器,可以在运行时使用这些生成器进行数据增强,如 图 18.2 所示:

背景图案 描述自动生成

图 18.2:原始图像与增强图像

虽然增强图像数据在所有主要的深度学*框架中都有现成的工具,但增强文本数据和音频数据并不那么简单。接下来,我们将介绍一些可以用来增强文本和语音数据的技术。

增强文本数据

我们可以用来增强文本数据的一些简单方法包括:

  • 同义词替换:在这种方法中,随机选择句子中的词语,并使用 WordNet 将它们替换为同义词。例如,如果我们有句子“这本书专注于使用 TensorFlow 和 Keras 的深度学*,适合新手和专家”,我们可以选择两个加粗的词进行同义词替换,得到以下句子:“这本书聚焦于使用 TensorFlow 和 Keras 的深度学*,适合初学者和专家。”

  • 反向翻译:该方法由 Sennrich 等人在 2016 年提出。其基本思想是将一句话翻译成另一种语言,然后再翻译回原语言。我们可以使用语言翻译 API 或 Python 模块,如 googletrans。以下代码片段将一句话从英语翻译成德语并再翻译回英语。为了使代码能够工作,我们需要安装 googletrans

from googletrans import Translator
translator = Translator()
text = 'I am going to the market for a walk'
translated = translator.translate(text, src='en', dest='de')
synthetic_text = translator.translate(translated.text, src='de', dest='en')
print(f'text: {text}\nTranslated: {translated.text}\nSynthetic Text: {synthetic_text.text}') 

现在我们有两个句子:“I am going to the market”和“I walk to the market”,它们属于同一类别。图 18.3 详细说明了使用反向翻译进行数据增强的过程:

图表 描述自动生成

图 18.3:使用反向翻译的数据增强

在综述论文《NLP 数据增强方法综述》中,作者列出了许多其他增强方法。这篇论文对 NLP 数据增强进行了深入分析。

*年来,随着大规模语言模型和变换器的成功,研究人员尝试将它们应用于数据增强任务。在亚马逊 Alexa AI 团队的论文《使用预训练变换器进行数据增强》中,作者展示了如何仅使用每个类别 10 个训练样本,通过预训练变换器生成合成数据。

他们实验了三种不同的预训练模型:自动编码器 LM BERT、自动回归 LM GPT2,以及预训练的seq2seq模型 BART。图 18.4展示了他们使用预训练模型生成合成数据的算法:

图示说明自动生成

图 18.4:使用预训练变换器生成合成文本数据的算法

语音数据也可以通过以下技术进行增强:

  • 时间扭曲:这里随机选择一个点,并将数据向左或向右扭曲,距离为w。距离w不是固定的,而是从均匀分布[0, W]中选择的。

  • 频率掩码:在这里,一范围频率通道[f[0], f[0]+f)]会被掩码;频率f[0]和f的选择取决于频率通道的数量和频率掩码参数F

  • 时间掩码:在这种情况下,连续的时间步长会被掩码。

这些技术由谷歌团队在 2019 年提出,发表于他们的论文《SpecAugment:一种用于自动语音识别的简单数据增强方法》中。

模型最佳实践

模型的准确性和性能对任何机器学*和深度学*项目的成功至关重要。如果一个模型的准确性不足,相关的业务用例将无法成功。因此,关注模型的准确性和性能以提高成功的机会是非常重要的。影响模型准确性和性能的因素很多,因此理解所有这些因素对于优化准确性和性能至关重要。以下列出了一些模型最佳实践,可以帮助我们充分利用模型开发流程。

基准模型

基准模型是机器学*中用于评估其他模型的工具。它通常是最简单的模型,作为更复杂模型的比较基准。目标是查看更复杂的模型是否真的比基准模型提供了任何改进。如果没有,那么使用更复杂的模型就没有意义。基准模型还可以帮助检测数据泄露。数据泄露是指测试集中的信息渗透到训练集中,导致过拟合。通过将基准模型的表现与其他模型进行比较,可以检测到何时发生了数据泄露。基准模型是机器学*的重要组成部分,为更复杂模型的性能提供了有价值的视角。因此,每当我们开始处理一个新问题时,最好思考能够拟合数据并获得基准的最简单模型。

一旦我们构建了一个令人满意的基准模型,我们需要仔细审查它。

回顾关于数据集的初步假设和初步算法选择。例如,也许当我们第一次开始处理数据时,我们假设观察到的模式最适合用高斯混合模型GMM)来解释。然而,在进一步探索后,我们可能会发现 GMM 并不能准确捕捉数据的潜在结构。在这种情况下,我们需要重新考虑我们的策略。最终,算法的选择由数据本身的性质决定。

确认模型是过拟合还是欠拟合。如果模型过拟合,可以尝试增加数据、减少模型复杂度、增加批处理大小,或者引入正则化方法,如岭回归套索回归dropout。如果模型欠拟合,可以尝试增加模型复杂度、添加更多特征,并训练更多的周期。

根据模型的性能指标分析模型。例如,如果我们构建了一个分类模型,就可以根据业务用例分析其混淆矩阵以及精度/召回率。识别出哪个类别模型预测不准确;这将帮助我们深入了解这些类别的数据。

执行超参数调优,以获得一个强有力的基准模型。建立一个强基准模型非常重要,因为它为未来的模型改进提供了基准。基准应包括所有业务和技术需求,并测试数据工程和模型部署管道。花时间开发一个强有力的基准模型,可以确保我们的机器学*项目从一开始就走在正确的轨道上。此外,一个好的基准模型可以帮助我们在模型迭代过程中识别潜在的改进领域。因此,投入时间和精力创建一个强大的基准模型是非常值得的。

预训练模型、模型 API 和 AutoML

当我们想要推出商业产品时,时间和精力往往是最重要的两个因素之一。在进行新项目时,从零开始训练一个基准模型可能非常耗时。然而,现在有很多资源可以找到预训练模型,帮助我们节省大量时间和精力。这些资源包括 GitHub、Kaggle,以及来自 Amazon、Google、OpenAI 和 Microsoft 等公司的各种基于云的 API。

此外,还有一些专业的初创公司,如 Scale AI 和 Hugging Face,提供适用于各种任务的预训练模型。通过利用这些资源,我们可以快速启动机器学*项目,而无需花费大量时间从头开始训练模型。因此,如果我们的问题是标准的分类或回归问题,或者我们拥有结构化的表格数据,我们可以使用预训练模型或由 Amazon、Google 和 Microsoft 等公司提供的 API。采用这些方法可以节省宝贵的时间和精力,并帮助我们快速开始项目。

另一个正在发展的解决方案是使用AutoML,即自动机器学*。通过使用 AutoML,我们可以创建更符合公司特定需求的定制模型。如果您的组织知识和资源有限,我们仍然可以通过利用 AutoML 在大规模上发挥机器学*的优势。这个解决方案已经帮助大小公司更高效、准确地实现其业务目标。未来,随着人们对其能力的认知不断提高,AutoML 可能会变得更加普及和受欢迎。

模型评估与验证

在本节中,我们讨论了评估模型的方法。这里我们不是在讨论传统的机器学*指标,而是专注于最终用户的体验:

  • 用户体验技巧:当我们的模型接*生产时,我们应该进一步测试它。众包是一个在发布产品之前从观众那里获得反馈的好方法。我们可以支付给人们,或者使用真实用户进行现场实验,让他们提供关于最有效方案的宝贵意见。我们可以在过程初期创建用户角色,也就是创建假设用户——例如,如果我们是一个年龄介于 19 至 40 岁之间的团队,并且我们构建了一个推荐系统,我们可以为一个 60 多岁的人创建一个用户角色。随后,我们可以通过邀请实际用户并观察他们对我们网站的反应来进行可用性测试。

  • 使用模型差异:当我们发布一个新模型时,衡量其成功的最佳方法之一就是计算它与生产中模型的差异。例如,如果我们的排序算法产生的结果比预期更好,但并不至于让人注意到或关心,那么我们应该让两个模型在整个系统中通过样本运行,并根据位置排名赋予权重。如果我们发现两个查询之间的差异非常小,那么我们知道变化不大。然而,如果差异较大,我们应该确保这种变化是积极的。在这种情况下,我们应当探索那些对称差异较大的查询;这将帮助您定性地理解变化。

  • 功利性效果比预测性效果更重要:我们可能有一个精度最高、预测最好的模型,但这还不是最终目的;问题是我们如何利用这个预测。例如,如果我们建立一个模型来对文档进行语义排序,那么最终排序的质量比预测本身更为重要。我们再举一个例子:假设你建立了一个垃圾邮件过滤器,模型预测给定信息是垃圾邮件还是正常邮件的概率;接下来我们会设定一个阻止哪些文本通过的截断值。在这种情况下,最重要的是我们允许哪些信息通过。所以,可能我们得到一个具有更好对数损失的模型,但整体性能却没有改善。在这种情况下,我们应该寻找其他特征来提高性能。

  • 寻找测量误差中的模式:在训练样本中,检查模型无法正确预测的样本。探索我们尚未考虑的特征;它们能改善对不正确样本的预测吗?不要对特征过于具体;我们可以添加十几个特征,让模型决定如何使用它们。为了可视化分类问题中的错误,我们可以使用混淆矩阵,对于回归任务,我们可以寻找损失较高的情况。

  • 在未见数据上测试:为了衡量模型的性能,可以在模型训练后收集的数据上进行测试;这样我们可以估算在生产环境中的性能。虽然这样可能会导致性能下降,但下降不应该过于严重。

性能监控是模型开发中至关重要的一部分。训练数据和生产数据之间的性能可能会有很大差异,这意味着我们必须持续监控已部署模型的行为,以确保它们在系统中没有发生任何异常。我们应该建立一个监控管道,持续监控性能、质量和偏差指标、公平性指标、模型解释以及用户交互。

模型改进

一旦构建并部署了一个可靠的模型,工作并没有结束。由于数据漂移或概念漂移,模型可能需要进行更改。数据漂移是指数据的分布随时间变化,而概念漂移是指依赖(标记)变量的属性随时间发生变化。为了应对这些变化,模型必须在新数据上重新训练,并相应地进行更新。这个过程可能既耗时又昂贵,但对于维持高性能的机器学*模型至关重要。然而,在我们开始改进模型之前,识别并衡量低性能的原因非常重要——“先测量,再优化”

数据漂移:机器学*模型的性能可能会因训练时间和部署时间的不同而有所变化。这是因为在训练和服务过程中使用的数据可能不同。为避免此问题,重要的是在部署时记录特征。这样可以帮助我们监控服务数据(生产数据)的变化。一旦数据漂移(训练数据与服务数据之间的差异)超过阈值,我们应该使用新数据重新训练模型。这将确保模型在与其部署时相同的数据上进行训练,从而提升其性能。

训练与服务偏差:训练与服务偏差可能是机器学*模型中的一个主要问题。如果模型的训练方式与实际使用方式之间存在差异,可能会导致性能不佳和不准确。训练与服务偏差的主要原因有三:训练数据和服务数据之间的差异、训练与服务之间数据的变化以及模型与算法之间的反馈循环。例如,如果我们构建了一个推荐系统来推荐电影,我们可以根据用户从推荐列表中看到的电影重新训练推荐系统。前两种原因可以通过精确的数据管理来解决,而第三种原因则需要在设计机器学*模型时特别注意。

即使经过充分的实验,我们也可能发现,在现有特性下,模型的性能已经无法进一步提升。然而,为了保持业务的持续发展,持续增长是必要的。因此,当我们发现模型性能已趋于平稳时,是时候寻找新的改进来源,而不是继续依赖现有特性。

软件开发过程从来不是“完成”的。即使产品已经发布,总会有新的特性可以添加,或者现有特性可以改进。机器学*模型也不例外。即使一个模型已经“完成”并部署到生产环境中,依然会有新的数据可以用于训练更好的模型。随着时间的推移,数据会发生变化,模型也需要在新数据上进行重新训练,以保持准确性。因此,重要的是要把机器学*模型看作是处于不断变化的状态。只有当你停止工作时,它才算“完成”。

在构建模型时,重要的是要考虑添加或删除特征的难易程度。我们能否轻松创建新的管道副本并验证其正确性?是否可以让两个或三个模型副本并行运行?这些都是在构建模型时需要考虑的重要问题。通过提前思考这些问题,我们可以节省大量后续时间和精力。

总结

本章我们重点介绍了获取模型最佳性能的策略和规则。这里的列表并非详尽无遗,由于人工智能技术仍在不断发展,未来几年可能会出现更多的规则和启发式方法。不过,如果你遵循本章中的建议,你将能够从人工智能模型的神秘性转向更可靠、稳健和可复现的行为。

在下一章中,我们将探索 TensorFlow 生态系统,并看看如何将本书中涉及的内容整合到实际的商业应用中。

参考文献

  1. Soni, N., Sharma, E. K., Singh, N., 和 Kapoor, A. (2020). 人工智能在商业中的应用:从研究和创新到市场部署。Procedia Computer Science, 167, 2200–2210。

  2. Feng, S. Y., Gangal, V., Wei, J., Chandar, S., Vosoughi, S., Mitamura, T., 和 Hovy, E. (2021). 自然语言处理中的数据增强方法综述。arXiv 预印本 arXiv:2105.03075。

  3. Sennrich, R., Haddow, B., 和 Birch, A. (2016). 通过单语数据改进神经机器翻译模型。在《第 54 届计算语言学年会论文集(第 1 卷:长篇论文)》中,页面 86-96,德国柏林。计算语言学协会。

  4. Kumar, V., Choudhary, A., 和 Cho, E. (2020). 使用预训练的变换器模型进行数据增强。arXiv 预印本 arXiv:2003.02245。

  5. Park, D. S., Chan, W., Zhang, Y., Chiu, C. C., Zoph, B., Cubuk, E. D., 和 Le, Q. V. (2019). SpecAugment:一种用于自动语音识别的简单数据增强方法。arXiv 预印本 arXiv:1904.08779。

  6. 机器学*规则:机器学*工程的最佳实践。Martin Zinkewich. developers.google.com/machine-learning/guides/rules-of-ml

  7. Baird, H. S. (1995). 文档图像分析。章节:文档图像缺陷模型,页码 315–325。IEEE 计算机学会出版社,美国洛杉矶,CA。

加入我们的书籍 Discord 社区

加入我们的 Discord 社区,与志同道合的人们交流,并与超过 2000 名成员一起学*: packt.link/keras

第十九章:TensorFlow 2 生态系统

在本章中,我们将学* TensorFlow 生态系统的不同组件。章节将详细介绍 TensorFlow Hub——一个预训练深度学*模型的仓库——以及 TensorFlow Datasets——一个用于机器学*任务的现成数据集集合。还将介绍 TensorFlow JS,这是一个用于在 Web 上训练和部署机器学*模型的解决方案。我们还将了解 TensorFlow Lite,一个开源深度学*框架,专为移动和边缘设备设计。章节将讨论一些 Android、iOS 和 Raspberry Pi 应用的示例,以及部署预训练模型的示例,如 MobileNet v1、v2、v3(为移动和嵌入式视觉应用设计的图像分类模型)、PoseNet(用于姿势估计的视觉模型,能够估算图像或视频中人的姿势)、DeepLab 分割(一个图像分割模型,将语义标签(例如,狗、猫、汽车)分配给输入图像中的每个像素)以及 MobileNet SSD 目标检测(一个图像分类模型,能够检测多个对象并给出边界框)。本章的最后将介绍联邦学*的示例,这是一种去中心化的机器学*框架,旨在尊重用户隐私。章节内容包括:

  • TensorFlow Hub

  • TensorFlow Datasets

  • TensorFlow Lite 及其在移动和边缘应用中的使用

  • 边缘的联邦学*

  • TensorFlow JS

  • 使用 Node.js 与 TensorFlow 模型

本章的所有代码文件可以在 packt.link/dltfchp19 找到

让我们从 TensorFlow Hub 开始。

TensorFlow Hub

即使你拥有一台强大的计算机,训练一个机器学*模型也可能需要几天或几周。而且,一旦你训练好模型,将其部署到不同的设备上可能既困难又费时。根据你要部署的平台,你可能需要不同的格式。

你可以把 TensorFlow Hub 当作一个包含许多预训练模型的库。它包含数百个经过训练、可以直接部署的深度学*模型。TensorFlow Hub 提供了图像分类、图像分割、目标检测、文本嵌入、文本分类、视频分类和生成等预训练模型。TF Hub 中的模型可以以 SavedModel、TFLite 和 TF.js 格式提供。我们可以直接使用这些预训练模型进行推理,或者对它们进行微调。随着用户和开发者社区的不断壮大,TensorFlow Hub 已成为寻找和分享机器学*模型的首选平台。要使用 TensorFlow Hub,我们首先需要安装它:

pip install tensorflow_hub 

安装完成后,我们可以通过以下方式简单地导入它:

import tensorflow_hub as hub 

并使用 load 函数加载模型:

model = hub.load(handle) 

这里的 handle 是一个字符串,包含我们想要使用的模型链接。如果我们希望将其作为现有模型的一部分使用,可以将其包装为 Keras 层:

hub.KerasLayer(
    handle,
    trainable=False,
    arguments=None,
    _sentinel=None,
    tags=None,
    signature=None,
    signature_outputs_as_dict=None,
    output_key=None,
    output_shape=None,
    load_options=None,
    **kwargs
) 

通过将参数 trainable 更改为 True,我们可以针对我们的特定数据对模型进行微调。

图 19.1 显示了 tfhub.dev 网站的易用 Web 界面,用于选择不同的模型。通过使用过滤器,我们可以轻松找到解决我们问题的模型。

我们可以选择需要的类型和格式,以及发布者!

图 19.1:显示不同过滤器的 tfhub.dev 网站

使用预训练模型进行推理

让我们看看如何利用 TensorFlow Hub 中的预训练模型。我们将考虑一个图像分类的示例:

  1. 让我们导入必要的模块:

    import tensorflow as tf
    import tensorflow_hub as hub
    import requests
    from PIL import Image
    from io import BytesIO
    import matplotlib.pyplot as plt
    import numpy as np 
    
  2. 我们定义了一个从 URL 加载图像的函数。该函数从网页上获取图像,并通过添加批次索引进行推理。图像还根据所选的预训练模型进行了归一化和缩放:

    def load_image_from_url(img_url, image_size):
      """Get the image from url. The image return has shape [1, height, width, num_channels]."""
      response = requests.get(img_url, headers={'User-agent': 'Colab Sample (https://tensorflow.org)'})
      image = Image.open(BytesIO(response.content))
      image = np.array(image)
      # reshape image
      img_reshaped = tf.reshape(image, [1, image.shape[0], image.shape[1], image.shape[2]]) 
      # Normalize by convert to float between [0,1]
      image = tf.image.convert_image_dtype(img_reshaped, tf.float32) 
      image_padded = tf.image.resize_with_pad(image, image_size, image_size)
      return image_padded, image 
    
  3. 另一个辅助函数,用于显示图像:

    def show_image(image, title=''):
      image_size = image.shape[1]
      w = (image_size * 6) // 320
      plt.figure(figsize=(w, w))
      plt.imshow(image[0], aspect='equal')
      plt.axis('off')
      plt.title(title)
      plt.show() 
    
  4. 我们使用的模型是 EfficientNet-B2(arxiv.org/abs/1905.11946),该模型是在 ImageNet 数据集上训练的。它提供更好的准确性,体积更小,并且推理速度更快。为了方便起见,我们选择将图像调整为 330 x 330 像素。我们使用在第 2 步中定义的辅助函数从 Wikimedia 下载图像:

    image_size = 330
    print(f"Images will be converted to {image_size}x{image_size}")
    img_url =  "https://upload.wikimedia.org/wikipedia/commons/c/c6/Okonjima_Lioness.jpg"
    image, original_image = load_image_from_url(img_url, image_size) 
    show_image(image, 'Scaled image') 
    

图 19.2:从网页上获取的用于分类的图像,缩放为 330 x 330 像素

  1. 为了完整性,我们还获取了 ImageNet 数据集的所有标签,以便从模型预测中推断标签;我们从 TensorFlow 的公共存储库下载它:

    labels_file = "https://storage.googleapis.com/download.tensorflow.org/data/ImageNetLabels.txt"
    #download labels and creates a maps
    downloaded_file = tf.keras.utils.get_file("labels.txt", origin=labels_file)
    classes = []
    with open(downloaded_file) as f:
      labels = f.readlines()
      classes = [l.strip() for l in labels] 
    
  2. 现在所有的准备工作都完成了,我们从 tfhub.dev 下载模型:

    classifier = hub.load("https://tfhub.dev/tensorflow/efficientnet/b2/classification/1") 
    
  3. 我们获取图像在第 5 步下载后的所有类别的 Softmax 概率:

    probabilities = tf.nn.softmax(classifier(image)).numpy() 
    
  4. 让我们看看顶部的预测结果:

    top_5 = tf.argsort(probabilities, axis=-1, direction="DESCENDING")[0][:5].numpy()
    show_image(image, f'{classes[top_5[0]+1]}: {probabilities[0][top_5][0]:.4f}') 
    

图 19.3:带有狮子标签预测的图像

如我们所见,通过几行代码,我们就能得到完美的推理结果——图像是一只母狮,而 ImageNet 数据集中最接*它的标签是狮子,模型正确地做出了预测。通过使用 TF Hub 的预训练模型,我们可以将精力集中在产品工作流上,从而获得更好的模型和更快的生产。

TensorFlow 数据集

TensorFlow 数据集 (TFDS) 是一个功能强大的工具,适用于所有从事机器学*的人。它提供了一系列现成可用的数据集,可以轻松地与 TensorFlow 或任何其他 Python ML 框架一起使用。所有数据集都作为 tf.data.Datasets 提供,便于在输入管道中使用。

使用 TFDS,您可以快速开始机器学*项目,节省时间,无需自行收集和准备数据。该库目前包含各种各样的数据集,包括图像分类、目标检测、文本分类等。此外,库还提供了从零开始创建新数据集的工具,这对于需要为自己项目创建自定义数据集的研究人员或开发人员非常有用。TFDS 是开源的,且以 Apache 2.0 许可证发布。要使用 TFDS,您需要安装它:

pip install tensorflow-datasets 

安装后,您可以像这样导入它:

import tensorflow_datasets as tfds 

在编写本书时,TFDS 包含了 224 个公共数据集,涵盖了广泛的任务:

datasets = tfds.list_builders()
print(f"TFDS contains {len(datasets)} datasets") 
### Output
TFDS contains 224 datasets 

在本节中,我们将向您介绍 TFDS,并展示它如何通过探索其底层结构来简化您的训练过程,并提供一些加载大量数据到机器学*模型中的最佳实践。

加载 TFDS 数据集

TFDS 中的每个数据集都有唯一的名称,并且每个数据集都与发布者和数据集版本相关联。要获取数据,您可以使用 TFDS 的 load 函数(这是一个功能强大的函数,具有很大的灵活性;您可以在www.tensorflow.org/datasets/api_docs/python/tfds/load上查看更多关于此函数的内容):

tfds.load(
    name: str,
    *,
    split: Optional[Tree[splits_lib.SplitArg]] = None,
    data_dir: Optional[str] = None,
    batch_size: tfds.typing.Dim = None,
    shuffle_files: bool = False,
    download: bool = True,
    as_supervised: bool = False,
    decoders: Optional[TreeDict[decode.partial_decode.DecoderArg]] =
None,
    read_config: Optional[tfds.ReadConfig] = None,
    with_info: bool = False,
    builder_kwargs: Optional[Dict[str, Any]] = None,
    download_and_prepare_kwargs: Optional[Dict[str, Any]] = None,
    as_dataset_kwargs: Optional[Dict[str, Any]] = None,
    try_gcs: bool = False
) 

您只需要指定数据集名称,其余参数是可选的。您可以从 TFDS 文档中了解更多关于可选参数的内容。例如,下面我们将下载著名的 MNIST 数据集:

data, info = tfds.load(name="mnist", as_supervised=True, split=['train', 'test'], with_info=True) 

上述语句将 MNIST 的训练和测试数据集都下载到变量数据中。由于 as_supervised 标志被设置为 True,标签会与数据一起下载,关于数据集的详细信息则会下载到 info 中。

首先让我们检查信息:

print(info) 
### output
tfds.core.DatasetInfo(
    name='mnist',
    version=3.0.1,
    description='The MNIST database of handwritten digits.',
    homepage='http://yann.lecun.com/exdb/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{lecun2010mnist,
      title={MNIST handwritten digit database},
      author={LeCun, Yann and Cortes, Corinna and Burges, CJ},
      journal={ATT Labs [Online]. Available: http://yann.lecun.com/exdb/mnist},
      volume={2},
      year={2010}
    }""",
    redistribution_info=,
) 

所以,我们可以看到信息是相当详细的。它告诉我们每个拆分中的样本数、如果用于监督学*时可用的键、引用细节等。这里的变量数据是一个包含两个 TFDS 数据集对象的列表——第一个是对应测试数据集,第二个是对应训练数据集。TFDS 数据集对象默认是 dict 类型。让我们从训练数据集中获取一个样本并进行探索:

data_train = data[1].take(1)
for sample, label in data_train:
  print(sample.shape)
  print(label) 
### output
(28, 28, 1)
tf.Tensor(2, shape=(), dtype=int64) 

您可以看到,样本是一个 28 x 28 x 1 形状的手写数字图像,其标签为 2。对于图像数据,TFDS 还提供了一个 show_examples 方法,您可以用它来查看数据集中的示例图像:

fig = tfds.show_examples(data[0], info) 

图像包含图表  描述自动生成

图 19.4:MNIST 数据集的测试数据集样本

使用 TFDS 构建数据管道

让我们使用 TFDS 数据管道构建一个完整的端到端示例:

  1. 和往常一样,我们首先导入必要的模块。由于我们将使用 TensorFlow 构建模型,并且使用 TFDS 获取数据集,因此现在只导入这两个:

    import tensorflow as tf
    import tensorflow_datasets as tfds 
    
  2. 使用 Keras 的顺序 API,我们构建了一个简单的卷积神经网络,包含三个卷积层和两个全连接层:

    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.Flatten(), 
      tf.keras.layers.Dense(256, activation='relu'), 
      tf.keras.layers.Dense(1, activation='sigmoid')
    ]) 
    
  3. 我们将构建一个二分类器,因此选择二元交叉熵作为损失函数,Adam 作为优化器:

    model.compile(optimizer='Adam', loss='binary_crossentropy',metrics=['accuracy']) 
    
  4. 接下来,我们来处理数据集。我们使用horses_or_humans数据集,因此使用tfds.load函数获取训练数据和验证数据:

    data = tfds.load('horses_or_humans', split='train', as_supervised=True) 
    val_data = tfds.load('horses_or_humans', split='test', as_supervised=True) 
    
  5. 图像需要进行归一化;此外,为了提高性能,我们将在训练过程中对图像进行增强:

    def normalize_img(image, label):
      """Normalizes images: 'uint8' -> 'float32'."""
      return tf.cast(image, tf.float32) / 255., label
    def augment_img(image, label):
      image, label = normalize_img(image, label)
      image = tf.image.random_flip_left_right(image)
      return image, label 
    
  6. 现在我们开始构建数据管道;首先使用cache以提高内存效率,应用预处理步骤(归一化和增强),确保在训练时数据会被打乱,定义批次大小,并使用prefetch,以便在当前批次训练时下一个批次也已准备好。我们对验证数据执行相同的步骤,唯一的区别是验证数据不需要进行增强或打乱:

    data = data.cache()
    data = data.map(augment_img, num_parallel_calls=tf.data.AUTOTUNE)
    train_data = data.shuffle(1024).batch(32)
    train_data = train_data.prefetch(tf.data.AUTOTUNE)
    val_data = val_data.map(normalize_img, num_parallel_calls=tf.data.AUTOTUNE)
    val_data = val_data.batch(32)
    val_data = val_data.cache()
    val_data = val_data.prefetch(tf.data.AUTOTUNE) 
    
  7. 最后,我们开始训练模型:

    %time history = model.fit(train_data, epochs=10, validation_data=val_data, validation_steps=1) 
    

尝试调整数据管道的不同参数,观察其如何影响训练时间。例如,可以尝试去除prefetchcache,并且不指定num_parallel_calls

TensorFlow Lite

TensorFlow Lite 是一个由 TensorFlow 设计的轻量级平台。该平台专为移动设备和嵌入式设备(如 Android、iOS 和树莓派)设计。其主要目标是通过在设备上直接进行机器学*推理来实现高效能,重点关注三个主要特点:(1)小型二进制文件和模型大小以节省内存,(2)低能耗以节省电池,(3)低延迟以提高效率。不言而喻,电池和内存是移动设备和嵌入式设备的两项重要资源。为了实现这些目标,Lite 采用了一些技术,如量化、FlatBuffers、移动解释器和移动转换器,接下来我们将简要回顾这些技术。

量化

量化是指一套将由连续值(例如实数)组成的输入约束为离散集合(例如整数)的技术。其核心思想是通过使用整数而非实数来表示内部权重,从而减少深度学*DL)模型的空间占用。当然,这意味着在空间节省的同时,模型的性能可能会有所牺牲。然而,许多实际情况已经证明,量化后的模型并不会出现显著的性能下降。TensorFlow Lite 在内部构建了一套核心操作符,支持量化和浮动点操作。

模型量化是一个应用量化的工具包。此操作应用于权重的表示,并可选择性地应用于激活,以便进行存储和计算。有两种量化类型:

  • 训练后量化对权重和训练后的激活结果进行量化。

  • 量化感知训练允许训练能够以最小精度损失进行量化的网络(仅适用于特定的 CNN)。由于这是一种相对实验性的技术,我们在本章中不讨论,但有兴趣的读者可以在[1]中找到更多信息。

TensorFlow Lite 支持将数值的精度从全浮点数降低到半精度浮点数(float16)或 8 位整数。TensorFlow 报告了关于精度、延迟和空间的多种权衡,适用于选定的 CNN 模型(见 图 19.5,来源:www.tensorflow.org/lite/performance/model_optimization):

表格 说明自动生成

图 19.5:不同量化 CNN 模型的权衡

FlatBuffers

FlatBuffers(google.github.io/flatbuffers/)是一种开源格式,经过优化用于在移动和嵌入式设备上序列化数据。该格式最初由 Google 为游戏开发和其他性能关键型应用创建。FlatBuffers 支持无需解析/解包即可访问序列化数据,以便进行快速处理。该格式通过避免在内存中进行不必要的多重复制,旨在提高内存效率和速度。FlatBuffers 支持跨多个平台和语言,如 C++、C#、C、Go、Java、JavaScript、Lobster、Lua、TypeScript、PHP、Python 和 Rust。

移动转换器

使用 TensorFlow 生成的模型需要转换为 TensorFlow Lite 模型。转换器可以引入优化,以提高二进制文件的大小和性能。例如,转换器可以去除计算图中与推理无关的所有节点,这些节点只是训练时需要的。

移动优化的解释器

TensorFlow Lite 运行在一个高度优化的解释器上,该解释器用于优化底层的计算图,而计算图又用于描述机器学*模型。在内部,解释器使用多种技术来优化计算图,通过引入静态图顺序并确保更好的内存分配。解释器核心本身大约为 ~100 KB,包含所有支持的内核时大约为 ~300 KB。

计算图是学*算法的图形表示;在这里,节点描述要执行的操作,连接节点的边表示数据的流动。这些图形为深度学*框架提供了性能效率,而如果我们在纯 NumPy 中构建神经网络,是无法实现这种效率的。

支持的平台

在 Android 上,TensorFlow Lite 推理可以使用 Java 或 C++ 执行。在 iOS 上,TensorFlow Lite 推理可以在 Swift 和 Objective-C 中运行。在 Linux 平台(如 Raspberry Pi)上,推理运行在 C++ 和 Python 中。TensorFlow Lite for microcontrollers 是 TensorFlow Lite 的一个实验性移植版,旨在运行基于 Arm Cortex-M(developer.arm.com/ip-products/processors/cortex-m)及系列处理器的微控制器上的机器学*模型,包括 Arduino Nano 33 BLE Sense(store.arduino.cc/nano-33-ble-sense-with-headers)、SparkFun Edge(www.sparkfun.com/products/15170)和 STM32F746 Discovery kit(www.st.com/en/evaluation-tools/32f746gdiscovery.xhtml)。这些微控制器常用于物联网应用。

架构

TensorFlow Lite 的架构如 图 19.6 所示(来自 www.tensorflow.org/lite/convert/index)。如您所见,tf.keras(例如,TensorFlow 2.x)和 低级 API 都得到支持。可以通过 TFLite Converter 转换标准 TensorFlow 2.x 模型,然后保存为 TFLite FlatBuffer 格式(文件名为 .tflite),然后由 TFLite 解释器 在可用设备(GPU 和 CPU)以及本地设备 API 上执行。图 19.6 中的具体功能定义了一个可以转换为 TensorFlow Lite 模型或导出为 SavedModel 的图:

Diagram  Description automatically generated

图 19.6:TensorFlow Lite 内部架构

使用 TensorFlow Lite

使用 TensorFlow Lite 包括以下步骤:

  1. 模型选择:选择一个标准的 TensorFlow 2.x 模型来解决特定任务。这可以是一个自定义构建的模型,也可以是一个预训练模型。

  2. 模型转换:选择的模型通过 TensorFlow Lite 转换器进行转换,通常只需几行 Python 代码即可调用。

  3. 模型部署:转换后的模型部署到选定的设备上,无论是手机还是物联网设备,然后通过 TensorFlow Lite 解释器运行。如前所述,提供了多种语言的 API。

  4. 模型优化:可以选择使用 TensorFlow Lite 优化框架对模型进行优化。

一个应用程序的通用示例

在本节中,我们将展示如何将模型转换为 TensorFlow Lite 并运行它。请注意,训练仍然可以在最适合您需求的环境中通过 TensorFlow 执行。然而,推理将在移动设备上运行。让我们通过以下 Python 代码片段来看看:

import tensorflow as tf
converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)
tflite_model = converter.convert()
open("converted_model.tflite", "wb").write(tflite_model) 

代码本身很容易理解。通过使用 tf.lite.TFLiteConverter.from_saved_model(saved_model_dir) 打开并转换一个标准的 TensorFlow 2.x 模型。非常简单!请注意,您无需特定安装。我们只需使用 tf.lite API (www.tensorflow.org/api_docs/python/tf/lite)。还可以应用一些优化。例如,默认情况下可以应用训练后量化:

import tensorflow as tf
converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
tflite_quant_model = converter.convert()
open("converted_model.tflite", "wb").write(tflite_quant_model) 

一旦模型转换完成,就可以将其复制到特定的设备上。当然,这一步骤对于每个设备来说有所不同。然后,可以使用您偏好的编程语言来运行模型。例如,在 Java 中,调用的代码如下:

try (Interpreter interpreter = new Interpreter(tensorflow_lite_model_file)) {
  interpreter.run(input, output);
} 

同样,非常简单!非常有用的是,您可以对异构的移动设备和物联网设备集群遵循相同的步骤。

使用 GPU 和加速器

现代手机通常配备加速器,能够加速浮动点矩阵运算。在这种情况下,解释器可以使用 Delegate 概念,特别是使用 GpuDelegate() 来利用 GPU。我们来看一个 Java 示例:

GpuDelegate delegate = new GpuDelegate();
Interpreter.Options options = (new Interpreter.Options()).addDelegate(delegate);
Interpreter interpreter = new Interpreter(tensorflow_lite_model_file, options);
try {
  interpreter.run(input, output);
} 

同样,代码也是自注释的。首先创建一个新的 GpuDelegate(),然后由解释器使用该代理来在 GPU 上运行模型。

应用程序示例

在本节中,我们将使用 TensorFlow Lite 构建一个示例应用程序,后续将其部署到 Android 上。我们将使用 Android Studio (developer.android.com/studio/) 来编译代码。第一步是克隆仓库,命令如下:

git clone https://github.com/tensorflow/examples 

然后我们打开一个现有项目(见 图 19.7),路径为 examples/lite/examples/image_classification/android

然后,您需要从 developer.android.com/studio/install 安装 Android Studio 和合适的 Java 版本。在我的情况下,我选择了 macOS 版本的 Android Studio,并通过 brew 使用以下命令安装了 Java:

brew tap adoptopenjdk/openjdk
brew cask install  homebrew/cask-versions/adoptopenjdk8 

接下来,您可以启动 sdkmanager 并安装所需的包。在我的情况下,我决定使用内部模拟器,并将应用程序部署到模拟 Google Pixel 3 XL 的虚拟设备上。所需的包在图 19.7中列出:

图形用户界面,文本 描述自动生成

图 19.7:使用 Google Pixel 3 XL 模拟器所需的包

然后,启动 Android Studio,选择 打开现有的 Android Studio 项目,如 图 19.8 所示:

图形用户界面,文本,应用程序 描述自动生成

图 19.8:打开一个新的 Android 项目

打开 Adv Manager(在 工具 菜单下)并按照如何创建虚拟设备的说明进行操作,如 图 19.9 所示:

图 19.9:创建虚拟设备

现在,您已经准备好了虚拟设备,让我们深入了解 TensorFlow Lite 模型,并看看如何使用它们。

TensorFlow Lite 中的预训练模型

对于许多有趣的应用场景,可以使用已经适合移动计算的预训练模型。这是一个活跃的研究领域,每个月都会有新的提案。预训练的 TensorFlow Lite 模型可以在 TensorFlow Hub 上找到;这些模型已准备好使用(www.tensorflow.org/lite/models/)。截至 2022 年 8 月,这些模型包括:

  • 图像分类:用于识别多个类别的对象,例如地点、植物、动物、活动和人物。

  • 目标检测:用于检测多个带有边界框的对象。

  • 语音合成:用于从文本生成语音。

  • 文本嵌入:用于嵌入文本数据。

  • 分割:识别对象的形状,并为人物、地点、动物和许多其他类别添加语义标签。

  • 风格迁移:用于将艺术风格应用于任何给定的图像。

  • 文本分类:用于将不同类别分配给文本内容。

  • 问答系统:用于为用户提供问题的答案。

在本节中,我们将讨论截至 2022 年 8 月,TensorFlow Lite 中一些经过优化的预训练模型。这些模型可用于大量的移动和边缘计算应用场景。编译示例代码非常简单。

您只需从每个示例目录导入新项目,Android Studio 会使用 Gradle(gradle.org/)同步代码并与仓库中的最新版本编译。

如果您编译了所有示例,应该能够在模拟器中看到它们(请参见图 19.10)。记得选择Build | Make Project,然后 Android Studio 会完成剩下的工作:

图 19.10:模拟的 Google Pixel 3 XL 运行 TensorFlow Lite 示例应用程序

边缘计算是一种分布式计算模型,将计算和数据带到需要它们的位置。

图像分类

截至 2022 年 8 月,预训练分类模型的可用列表相当庞大,且如图 19.11所示,它提供了在空间、准确性和性能之间进行权衡的机会(来源:www.tensorflow.org/lite/models/trained):

图 19.11:各种移动模型的空间、准确性和性能的权衡

MobileNet V1 是在 Benoit Jacob [2] 中描述的量化 CNN 模型。MobileNet V2 是由 Google 提出的先进模型 [3]。在线上,您也可以找到浮点模型,这些模型在模型大小和性能之间提供了最佳平衡。请注意,GPU 加速需要使用浮点模型。最*,基于自动化的移动神经架构搜索MNAS)方法提出了用于移动设备的 AutoML 模型 [4],超过了人工设计的模型。

我们在第十三章“AutoML 简介”中讨论了 AutoML,并且感兴趣的读者可以参考参考文献中的 MNAS 文档 [4],了解其在移动设备上的应用。

对象检测

TensorFlow Lite 格式的模型包含在 TF Hub 中。有大量预训练模型可以检测图像中的多个对象,并带有边界框。识别八十种不同类别的对象。该网络基于预训练的量化 COCO SSD MobileNet V1 模型。对于每个对象,模型提供类别、检测置信度和边界框的顶点(tfhub.dev/s?deployment-format=lite&module-type=image-object-detection)。

姿势估计

TF Hub 有一个 TensorFlow Lite 格式的预训练模型,用于在图像或视频中检测人体的各个部分。例如,可以检测鼻子、左/右眼、臀部、脚踝等许多部位。每个检测都附带一个关联的置信度分数(tfhub.dev/s?deployment-format=lite&module-type=image-pose-detection)。

智能回复

TF Hub 还有一个 TensorFlow Lite 格式的预训练模型,用于生成聊天消息的回复。这些回复是上下文相关的,类似于 Gmail 上可用的内容(tfhub.dev/tensorflow/lite-model/smartreply/1/default/1)。

分割

有预训练模型(tfhub.dev/s?deployment-format=lite&module-type=image-segmentation)用于图像分割,目标是决定输入图像中每个像素分配的语义标签(例如人、狗和猫)。分割基于 DeepLab 算法 [5]。

风格转移

TensorFlow Lite 还支持艺术风格转移(见第二十章“高级卷积神经网络”),通过基于 MobileNet V2 的神经网络将输入的风格图像减少为 100 维度风格向量,并且应用风格转换模型,将风格向量应用于内容图像以创建风格化的图像(tfhub.dev/s?deployment-format=lite&module-type=image-style-transfer)。

文本分类

有一些针对文本分类和情感分析的模型(tfhub.dev/s?deployment-format=lite&module-type=text-classification),这些模型在大型电影评论数据集 v1.0(ai.stanford.edu/~amaas/data/sentiment/)上进行训练,数据集包含了正面或负面的 IMDb 电影评论。图 19.12给出了一个文本分类示例:

图 19.12:使用 TensorFlow Lite 在 Android 上的文本分类示例

大型语言模型

有基于变压器架构的预训练大型语言模型(tfhub.dev/s?deployment-format=lite&q=bert)。这些模型基于 BERT [6]的压缩变种(参见第六章变压器),称为 MobileBERT [7],其运行速度比原版快 4 倍,且模型大小减少 4 倍。图 19.13给出了一个问答示例:

图 19.13:使用 TensorFlow Lite 和 BERT 在 Android 上的问答示例

关于使用移动 GPU 的说明

本节总结了针对移动设备和物联网的预训练模型概述。请注意,现代手机配备了内置 GPU。例如,在 Pixel 3 上,TensorFlow Lite 的 GPU 推理可以将推理速度加速到比 CPU 快 2 到 7 倍(参见图 19.14,来源:blog.tensorflow.org/2019/01/tensorflow-lite-now-faster-with-mobile.xhtml):

图 19.14:在不同手机上运行的各种学*模型 GPU 加速与 CPU 的对比

边缘计算中的联邦学*概述

如前所述,边缘计算是一种分布式计算模型,它将计算和数据带到需求的地点。

现在,让我们介绍边缘的联邦学*FL)[8],从两个使用案例开始。

假设你为移动设备开发了一个播放音乐的应用,然后你想添加推荐功能,帮助用户发现他们可能喜欢的新歌曲。有没有一种方法可以构建一个分布式模型,利用每个用户的经验,而不泄露任何私人数据?

假设你是一家汽车制造商,生产数百万辆通过 5G 网络连接的汽车,然后你想要构建一个分布式模型,用于优化每辆车的燃油消耗。有没有一种方法可以在不泄露每个用户的驾驶行为的情况下构建这样的模型?

传统的机器学*要求你拥有一个集中式的训练数据存储库,无论是在你的桌面、数据中心还是云端。联邦学*通过将计算分配到数百万个移动设备上,将训练阶段推送到边缘。这些设备是短暂的,因为它们并非始终可用进行学*过程,并且它们可能会悄无声息地消失(例如,手机可能突然关机)。关键思想是利用每个参与 FL 计算的手机的 CPU 和 GPU。每个参与分布式 FL 训练的移动设备从中央服务器下载一个(预训练的)模型,并基于每个特定移动设备收集的本地训练数据进行本地优化。这个过程类似于迁移学*过程(见第二十章高级卷积神经网络),但它是在边缘进行分布式的。每个本地更新的模型随后由数百万个边缘设备发送回中央服务器,以构建一个平均共享的模型。

当然,还有许多问题需要考虑。让我们回顾一下:

  • 电池使用:每个参与 FL 计算的移动设备应尽量减少本地电池的使用。

  • 加密通信:每个属于 FL 计算的移动设备必须使用加密通信与中央服务器更新本地构建的模型。

  • 高效通信:通常,深度学*模型通过诸如 SGD(见第一章基于 TF 的神经网络基础,以及第十四章深度学*背后的数学)等优化算法进行优化。然而,FL 与数百万设备合作,因此强烈需要最小化通信模式。谷歌推出了一种联邦平均算法 [8],据报道,与传统的 SGD 相比,这种算法可以将通信量减少 10 倍到 100 倍。此外,压缩技术 [9] 通过随机旋转和量化进一步将通信成本降低 100 倍。

  • 确保用户隐私:这可能是最重要的一点。所有在边缘获取的本地训练数据必须保留在边缘。这意味着,在移动设备上获取的训练数据不能发送到中央服务器。同样重要的是,任何在本地训练模型中学*到的用户行为必须被匿名化,以确保无法识别出具体个体执行的任何特定操作。

图 19.15 显示了一个典型的 FL 架构 [10]。FL 服务器将模型和训练计划发送给数百万个设备。训练计划包含有关更新频率和其他元数据的信息。

每个设备都进行本地训练,并将模型更新发送回全球服务。请注意,每个设备都有一个 FL 运行时,为存储数据的本地示例库中的应用进程提供联邦学*服务。FL 运行时从示例库中获取训练示例:

图 19.15:联邦学*架构示例

TensorFlow FL API

TensorFlow FederatedTTF)平台有两层:

  • 联邦学*FL),如前所述,是一个与tf.keras和非tf.keras模型配合良好的高级接口。在大多数情况下,我们将使用这个 API 进行隐私保护的分布式训练。

  • 联邦核心FC),一个高度可定制的低级接口,允许你与低级通信和联邦算法进行交互。只有在你打算实现新的和复杂的分布式学*算法时,你才需要这个 API。这个主题相当高级,我们在本书中不会详细讨论。如果你想了解更多,可以在线找到更多信息(www.tensorflow.org/federated/federated_core)。

FL API 有三个关键部分:

  1. 模型:用于封装现有模型以启用联邦学*。这可以通过tff.learning.from_keras_model()实现,或者通过子类化tff.learning.Model()实现。例如,你可以使用以下代码片段:

    keras_model = …
    keras_model.compile(...)
    keras_federated_model = tff.learning.from_compiled_keras_model(keras_model, ..) 
    
  2. 构建器:这是联邦计算发生的层。该层有两个阶段:编译阶段,将学*算法序列化为计算的抽象表示;执行阶段,运行表示的计算。

  3. 数据集:这是一个可以用于在本地模拟联邦学*的大型数据集——这是进行初步微调的有用步骤。

我们通过提到你可以在线找到 API 的详细描述以及许多编码示例来结束这个概述(www.tensorflow.org/federated/federated_learning)。可以通过 Google 提供的 Colab 笔记本开始使用(colab.research.google.com/github/tensorflow/federated/blob/v0.10.1/docs/tutorials/federated_learning_for_image_classification.ipynb)。该框架允许我们在真实环境中运行之前模拟分布式训练。负责 FL 学*的库是tensorflow_federated图 19.16讨论了在多个节点上进行联邦学*的所有步骤,可能有助于更好地理解本节讨论的内容:

图 19.16:带有多个节点的联邦学*示例(来源:upload.wikimedia.org/wikipedia/commons/e/e2/Federated_learning_process_central_case.png

下一节将介绍 TensorFlow.js,这是一个 TensorFlow 的变种,可以在 JavaScript 中本地使用。

TensorFlow.js

TensorFlow.js 是一个用于机器学*模型的 JavaScript 库,可以在常规模式或通过 Node.js 模式下工作。在本节中,我们将回顾这两种模式。

常规 TensorFlow.js

TensorFlow.js 是一个用于在浏览器中训练和使用机器学*模型的 JavaScript 库。它源自 deeplearn.js,这是一个开源的硬件加速库,用于在 JavaScript 中进行深度学*,现在它是 TensorFlow 的一个伴随库。

TensorFlow.js 最常见的用途是使预训练的机器学*/深度学*模型在浏览器中可用。这在一些情况下非常有用,比如由于网络带宽或安全问题,无法将客户端数据发送回服务器。然而,TensorFlow.js 是一个全栈的机器学*平台,除了可以构建和训练机器学*/深度学*模型外,还可以用新的客户端数据微调现有的预训练模型。

一个 TensorFlow.js 应用示例是 TensorFlow Projector (projector.tensorflow.org),它允许客户端在三维空间中可视化他们自己的数据(作为词向量),并使用提供的几种降维算法之一。TensorFlow.js 演示页面上列出了其他一些 TensorFlow.js 应用示例 (www.tensorflow.org/js/demos)。

类似于 TensorFlow,TensorFlow.js 也提供了两个主要的 API——Ops API,它提供低级别的张量操作,如矩阵乘法;以及 Layers API,它提供了 Keras 风格的神经网络高级构建模块。

在撰写本文时,TensorFlow.js 在三种不同的后端上运行。最快的(也是最复杂的)是 WebGL 后端,它提供了对 WebGL 低级别 3D 图形 API 的访问,并且可以利用 GPU 硬件加速。另一个流行的后端是 Node.js 后端,它允许在服务器端应用中使用 TensorFlow.js。最后,作为备选方案,还有基于 CPU 的纯 JavaScript 实现,可以在任何浏览器中运行。

为了更好地理解如何编写 TensorFlow.js 应用程序,我们将通过一个示例来展示如何使用 TensorFlow.js 团队提供的卷积神经网络(CNN)对 MNIST 手写数字进行分类 (storage.googleapis.com/tfjs-examples/mnist/dist/index.xhtml)。

这里的步骤与正常的监督学*模型开发流程类似——加载数据、定义、训练和评估模型。

JavaScript 在浏览器环境内工作,在 HTML 页面中。下面的 HTML 文件(命名为index.xhtml)表示该 HTML 页面。注意两个 TensorFlow.js(tf.min.js)和 TensorFlow.js 可视化库(tfjs-vis.umd.min.js)的导入——这些提供了我们在应用中使用的库函数。我们的应用程序的 JavaScript 代码来自data.jsscript.js文件,位于与index.xhtml文件相同的目录中:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!-- Import TensorFlow.js -->
  <script src="img/tf.min.js"></script>
  <!-- Import tfjs-vis -->
  <script src="img/tfjs-vis.umd.min.js"></script>
  <!-- Import the data file -->
  <script src="img/data.js" type="module"></script>
  <!-- Import the main script file -->
  <script src="img/script.js" type="module"></script>
</head>
<body>
</body>
</html> 

对于部署,我们将这三个文件(index.xhtmldata.jsscript.js)部署到一个网络服务器上,但对于开发,我们可以通过调用一个简单的、与 Python 发行版一起捆绑的 Web 服务器来启动一个 Web 服务器。这将在localhost8000端口启动一个 Web 服务器,index.xhtml文件可以通过浏览器在http://localhost:8000上进行渲染:

python -m http.server 

下一步是加载数据。幸运的是,Google 提供了一个 JavaScript 脚本,我们直接从index.xhtml文件中调用它。它从 GCP 存储中下载图像和标签,并返回已打乱并标准化的图像和标签对的批次,用于训练和测试。我们可以使用以下命令将其下载到与index.xhtml文件相同的文件夹中:

wget -cO - https://storage.googleapis.com/tfjs-tutorials/mnist_data.js > data.js 

对于 Windows 用户,你需要首先下载 Wget:eternallybored.org/misc/wget/

模型定义、训练和评估的代码都在script.js文件中指定。定义和构建网络的函数显示在下面的代码块中。正如你所看到的,它与使用tf.keras构建顺序模型的方法非常相似。唯一的不同是指定参数的方式,它使用的是名称-值对的字典,而不是参数列表。这个模型是一个顺序模型,也就是说,它是一个层的列表。最后,模型使用 Adam 优化器进行编译:

function getModel() {
  const IMAGE_WIDTH = 28;
  const IMAGE_HEIGHT = 28;
  const IMAGE_CHANNELS = 1;  
  const NUM_OUTPUT_CLASSES = 10;

  const model = tf.sequential();
  model.add(tf.layers.conv2d({
    inputShape: [IMAGE_WIDTH, IMAGE_HEIGHT, IMAGE_CHANNELS],
    kernelSize: 5,
    filters: 8,
    strides: 1,
    activation: 'relu',
    kernelInitializer: 'varianceScaling'
  }));
  model.add(tf.layers.maxPooling2d({
    poolSize: [2, 2], strides: [2, 2]
  }));
  model.add(tf.layers.conv2d({
    kernelSize: 5,
    filters: 16,
    strides: 1,
    activation: 'relu',
    kernelInitializer: 'varianceScaling'
  }));
  model.add(tf.layers.maxPooling2d({
    poolSize: [2, 2], strides: [2, 2]
  }));
  model.add(tf.layers.flatten());
  model.add(tf.layers.dense({
    units: NUM_OUTPUT_CLASSES,
    kernelInitializer: 'varianceScaling',
    activation: 'softmax'
  }));
  const optimizer = tf.train.adam();
  model.compile({
    optimizer: optimizer,
    loss: 'categoricalCrossentropy',
    metrics: ['accuracy'],
  });
  return model;
} 

然后,模型将在 10 个 epochs 内使用来自训练数据集的批次进行训练,并使用来自测试数据集的批次进行内联验证。最佳实践是从训练集中创建一个单独的验证数据集。然而,为了保持我们专注于展示如何使用 TensorFlow.js 设计端到端 DL 管道的更重要方面,我们使用了 Google 提供的外部data.js文件,该文件提供了返回仅训练和测试批次的函数。在我们的示例中,我们将使用测试数据集进行验证,并稍后进行评估。

这可能会给我们带来比使用一个未见过的(在训练过程中未见过的)测试集时更好的准确度,但对于像这样说明性的示例来说,这并不重要:

async function train(model, data) {
  const metrics = ['loss', 'val_loss', 'acc', 'val_acc'];
  const container = {
    name: 'Model Training', tab: 'Model', styles: { height: '1000px' }
  };
  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
    ];
  });
  return model.fit(trainXs, trainYs, {
    batchSize: BATCH_SIZE,
    validationData: [testXs, testYs],
    epochs: 10,
    shuffle: true,
    callbacks: fitCallbacks
  });
} 

一旦模型完成训练,我们希望进行预测并评估模型的预测。以下函数将进行预测,并计算所有测试集示例中每个类别的总体准确性,同时生成整个测试集样本的混淆矩阵:

const classNames = [
  'Zero', 'One', 'Two', 'Three', 'Four', 
  'Five', 'Six', 'Seven', 'Eight', 'Nine'];
function doPrediction(model, data, testDataSize = 500) {
  const IMAGE_WIDTH = 28;
  const IMAGE_HEIGHT = 28;
  const testData = data.nextTestBatch(testDataSize);
  const testxs = testData.xs.reshape(
    [testDataSize, IMAGE_WIDTH, IMAGE_HEIGHT, 1]);
  const labels = testData.labels.argMax([-1]);
  const preds = model.predict(testxs).argMax([-1]);
  testxs.dispose();
  return [preds, labels];
}
async function showAccuracy(model, data) {
  const [preds, labels] = doPrediction(model, data);
  const classAccuracy = await tfvis.metrics.perClassAccuracy(
    labels, preds);
  const container = {name: 'Accuracy', tab: 'Evaluation'};
  tfvis.show.perClassAccuracy(container, classAccuracy, classNames);
  labels.dispose();
}
async function showConfusion(model, data) {
  const [preds, labels] = doPrediction(model, data);
  const confusionMatrix = await tfvis.metrics.confusionMatrix(
    labels, preds);
  const container = {name: 'Confusion Matrix', tab: 'Evaluation'};
  tfvis.render.confusionMatrix(
      container, {values: confusionMatrix}, classNames);
  labels.dispose();
} 

最后,run()函数将按顺序调用所有这些函数,以构建端到端的机器学*管道:

import {MnistData} from './data.js';
async function run() { 
  const data = new MnistData();
  await data.load();
  await showExamples(data);
  const model = getModel();
  tfvis.show.modelSummary({name: 'Model Architecture', tab: 'Model'}, model);
  await train(model, data);
  await showAccuracy(model, data);
  await showConfusion(model, data);
}
document.addEventListener('DOMContentLoaded', run); 

刷新浏览器位置http://localhost:8000/index.xhtml,将调用上面的run()方法。图 19.17展示了模型架构和训练进度的图表。

左侧显示的是每个批次结束时在验证数据集上的损失和准确度值,右侧显示的是每个时期结束时在训练数据集(蓝色)和验证数据集(红色)上的损失和准确度值:

图 19.17:模型损失和准确度在训练过程中的变化

此外,以下图表展示了我们训练的模型在测试数据集上进行预测时,不同类别的准确度,以及测试数据集样本的预测类别与实际类别的混淆矩阵:

图 19.18:通过训练后的模型获得的混淆矩阵和每个类别的准确度

读者可能会对 TensorFlow 团队在 MNIST 数据集上训练 TFJS 模型的这个实时示例感兴趣:storage.googleapis.com/tfjs-examples/mnist/dist/index.xhtml

我们已经学*了如何在浏览器中使用 TensorFlow.js。下一节将解释如何将 Keras 模型转换为 TensorFlow.js 模型。

转换模型

有时将已经用tf.keras创建的模型转换成其他格式非常方便。这非常简单,可以离线使用以下命令完成,该命令将从/tmp/model.h5中获取 Keras 模型,并将 JavaScript 模型输出到/tmp/tfjs_model目录中:

tensorflowjs_converter --input_format=keras /tmp/model.h5 /tmp/tfjs_model 

要使用此命令,您需要一个安装了 TensorFlow JS 的 Python 环境,可以使用以下命令进行安装:

pip install tensorflowjs 

这将安装上述转换器。下一节将解释如何在 TensorFlow.js 中使用预训练模型。

预训练模型

TensorFlow.js 提供了大量的深度学*预训练模型,涵盖图像、视频和文本。由于这些模型托管在 npm 上,如果您熟悉 Node.js 开发,使用它们会非常简单。

表 19.1总结了截至 2022 年 8 月可用的一些预训练模型(来源:github.com/tensorflow/tfjs-models):

图片
模型
MobileNet (github.com/tensorflow/tfjs-models/tree/master/mobilenet)
PoseNet (github.com/tensorflow/tfjs-models/tree/master/posenet)
Coco SSD (github.com/tensorflow/tfjs-models/tree/master/coco-ssd)
BodyPix (github.com/tensorflow/tfjs-models/tree/master/body-pix)
DeepLab v3(github.com/tensorflow/tfjs-models/tree/master/deeplab)
音频
模型
Speech Commands (github.com/tensorflow/tfjs-models/tree/master/speech-commands)
文本
模型
Universal Sentence Encoder (github.com/tensorflow/tfjs-models/tree/master/universal-sentence-encoder)
Text Toxicity (github.com/tensorflow/tfjs-models/tree/master/toxicity)
通用工具
模型
KNN 分类器 (github.com/tensorflow/tfjs-models/tree/master/knn-classifier)

表 19.1:TensorFlow.js 上一些预训练模型的列表

每个预训练模型可以直接从 HTML 中使用。例如,这是一个使用 KNN 分类器的示例:

<html>
  <head>
    <!-- Load TensorFlow.js -->
    <script src="img/tfjs"></script>
    <!-- Load MobileNet -->
    <script src="img/mobilenet"></script>
    <!-- Load KNN Classifier -->
    <script src="img/knn-classifier"></script>
  </head> 

下一节将解释如何在 Node.js 中使用预训练模型。

Node.js

在这一节中,我们将概述如何在 Node.js 中使用 TensorFlow。让我们开始吧。

CPU 包通过以下代码行导入,这将适用于所有 macOS、Linux 和 Windows 平台:

import * as tf from '@tensorflow/tfjs-node' 

GPU 包通过以下代码行导入(截至 2019 年 11 月,这仅适用于 CUDA 环境中的 GPU):

import * as tf from '@tensorflow/tfjs-node-gpu' 

以下是一个 Node.js 代码示例,用于定义和编译一个简单的全连接模型。代码不言自明:

const model = tf.sequential();
model.add(tf.layers.dense({ units: 1, inputShape: [400] }));
model.compile({
  loss: 'meanSquaredError',
  optimizer: 'sgd',
  metrics: ['MAE']
}); 

然后可以使用典型的 Node.js 异步调用开始训练:

const xs = tf.randomUniform([10000, 400]);
const ys = tf.randomUniform([10000, 1]);
const valXs = tf.randomUniform([1000, 400]);
const valYs = tf.randomUniform([1000, 1]);
async function train() {
  await model.fit(xs, ys, {
    epochs: 100,
    validationData: [valXs, valYs],
  });
}
train(); 

在这一节中,我们讨论了如何通过样例应用程序在浏览器和后端计算中使用 TensorFlow.js,涵盖了原生 JavaScript 和 Node.js 的使用方法。

总结

在本章中,我们讨论了 TensorFlow 生态系统的不同组件。我们从 TensorFlow Hub 开始,这是许多预训练模型的聚集地。接下来,我们介绍了 TensorFlow Datasets,并学*了如何使用 TFDS 构建数据管道。我们了解了如何使用 TensorFlow Lite 为移动设备和物联网设备提供服务,并在 Android 设备上部署了实际应用。然后,我们还讨论了针对成千上万(甚至百万)移动设备的联邦学*,以考虑隐私问题。本章的最后一节专门讨论了如何使用 TensorFlow.js 将 TensorFlow 与原生 JavaScript 或 Node.js 一起使用。

下一章将讨论高级 CNN,您将学*一些高级 CNN 架构及其应用。

参考文献

  1. 量化感知训练:github.com/tensorflow/tensorflow/tree/r1.13/tensorflow/contrib/quantize

  2. Jacob, B., Kligys, S., Chen, B., Zhu, M., Tang, M., Howard, A., Adam, H., 和 Kalenichenko, D. (提交于 2017 年 12 月 15 日)。量化和神经网络的训练,以实现高效的整数运算推理arxiv.org/abs/1712.05877

  3. Sandler, M., Howard, A., Zhu, M., Zhmoginov, A., Chen, L-C. (提交于 2018 年 1 月 13 日 (v1),最后修订于 2019 年 3 月 21 日 (v4))。MobileNetV2:倒残差和线性瓶颈arxiv.org/abs/1806.08342

  4. Tan, M., Chen, B., Pang, R., Vasudevan, V., Sandler, M., Howard, A., 和 Le, Q. V. MnasNet:面向移动平台的神经网络架构搜索arxiv.org/abs/1807.11626

  5. Chen, L-C., Papandreou, G., Kokkinos, I., Murphy, K., 和 Yuille, A. L.(2017 年 5 月)。DeepLab:使用深度卷积网络、空洞卷积和全连接 CRF 进行语义图像分割arxiv.org/pdf/1606.00915.pdf

  6. Devlin, J., Chang, M-W., Lee, K., 和 Toutanova, K.(提交于 2018 年 10 月 11 日(v1),最后修改于 2019 年 5 月 24 日(v2))。BERT:用于语言理解的深度双向变换器预训练arxiv.org/abs/1810.04805

  7. 匿名作者,论文正在进行双盲审稿。(修改时间:2019 年 9 月 25 日)。MOBILEBERT: BERT 的任务无关压缩通过渐进式知识迁移。ICLR 2020 会议盲审提交,读者:所有人。openreview.net/pdf?id=SJxjVaNKwB

  8. McMahan, H. B., Moore, E., Ramage, D., Hampson, S., Arcas, B. A. y.(提交于 2016 年 2 月 17 日(v1),最后修改于 2017 年 2 月 28 日(此版本,v3))。从分散数据中高效学*深度网络arxiv.org/abs/1602.05629

  9. Konečný, J., McMahan, H. B., Yu, F. X., Richtárik, P., Suresh, A. T., 和 Bacon, D.(提交于 2016 年 10 月 18 日(v1),最后修改于 2017 年 10 月 30 日(此版本,v2))。联邦学*:提高通信效率的策略arxiv.org/abs/1610.05492

  10. Bonawitz, K. 等人(2019 年 3 月 22 日)。迈向大规模联邦学*:系统设计arxiv.org/pdf/1902.01046.pdf

加入我们的书籍 Discord 空间

加入我们的 Discord 社区,结识志同道合的人,并与 2000 多名成员共同学*:packt.link/keras

第二十章:高级卷积神经网络

本章中,我们将看到 CNN 的一些更高级的应用。我们将探索:

  • CNN 如何应用于计算机视觉、视频、文本文件、音频和音乐等领域

  • 如何使用 CNN 进行文本处理

  • 胶囊网络是什么

  • 计算机视觉

本章的所有代码文件可以在 packt.link/dltfchp20 找到。

让我们从使用 CNN 进行复杂任务开始。

组合 CNN 进行复杂任务

我们在第三章《卷积神经网络》中已经详细讨论了 CNN,现在你可能已经深信 CNN 架构对于图像分类任务的有效性。然而,你可能会惊讶地发现,基本的 CNN 架构可以通过组合和扩展的方式,解决各种更复杂的任务。在本节中,我们将查看图 20.1中提到的计算机视觉任务,并展示如何通过将 CNN 转变为更大、更复杂的架构来解决它们。

图 20.1:不同的计算机视觉任务 – 来源:人工智能与计算机视觉革命介绍 (https://www.slideshare.net/darian_f/introduction-to-the-artificial-intelligence-and-computer-vision-revolution)

分类与定位

在分类和定位任务中,除了需要报告图像中物体的类别,还需要给出物体在图像中出现的边界框坐标。这类任务假设图像中只有一个物体实例。

这可以通过在典型的分类网络中,除了“分类头”外再附加一个“回归头”来实现。回想一下,在一个分类网络中,卷积和池化操作的最终输出,称为特征图,将被送入一个全连接网络,生成一个类别概率向量。这个全连接网络被称为分类头,并通过使用分类损失函数 (L[c]),如类别交叉熵,进行调优。

类似地,回归头是另一个全连接网络,它接收特征图并生成一个向量 (x, y, w, h),表示左上角的 xy 坐标,以及边界框的宽度和高度。它通过使用连续损失函数 (L[R]),如均方误差,进行调优。整个网络通过这两种损失的线性组合进行调优,即:

这里, 是一个超参数,可以取值在 0 到 1 之间。除非该值由一些关于问题的领域知识决定,否则可以设置为 0.5。

图 20.2 显示了一个典型的分类与定位网络架构:

图 20.2:图像分类与定位的网络架构

正如你所看到的,与典型的 CNN 分类网络的唯一区别是右上角额外的回归头。

语义分割

基于基本分类思想的另一类问题是“语义分割”。其目标是将图像中的每一个像素分类为属于某一类。

一种初步的实现方法可能是为每个像素构建一个分类网络,其中输入是每个像素周围的小邻域。实际上,这种方法的性能并不好,因此对该实现的改进可能是通过卷积操作处理图像,增加特征深度,同时保持图像的宽度和高度不变。每个像素会有一个特征图,可以通过一个全连接网络来预测该像素的类别。然而,实际上,这也相当昂贵,通常不被使用。

第三种方法是使用 CNN 编码器-解码器网络,其中编码器减小图像的宽度和高度,但增加其深度(特征数量),而解码器使用反卷积操作增加图像的尺寸并减少其深度。反卷积(或上采样)是进行与普通卷积相反的过程。该网络的输入是图像,输出是分割图。该编码器-解码器架构的一个流行实现是 U-Net(一个很好的实现可以在github.com/jakeret/tf_unet找到),最初为生物医学图像分割开发,具有编码器和解码器之间额外的跳跃连接。

图 20.3 展示了 U-Net 架构:

Chart  Description automatically generated

图 20.3:U-Net 架构

目标检测

目标检测任务与分类和定位任务类似。最大的区别在于现在图像中有多个对象,并且对于每个对象,我们需要找到其类别和边界框坐标。此外,事先无法知道对象的数量或大小。正如你所想,这个问题非常复杂,且已经有大量的研究投入其中。

解决这个问题的第一种方法可能是创建许多输入图像的随机裁剪,对于每个裁剪,应用我们之前描述的分类和定位网络。然而,这种方法在计算上非常浪费,且不太可能非常成功。

更实用的方法是使用如选择性搜索(Selective Search for Object Recognition,由 Uijlings 等人编写,www.huppelen.nl/publications/selectiveSearchDraft.pdf)等工具,利用传统的计算机视觉技术来查找图像中可能包含物体的区域。这些区域被称为“区域建议”,用于检测这些区域的网络称为基于区域的 CNN,或称R-CNN。在原始的 R-CNN 中,区域被调整大小后输入网络,产生图像向量。然后,使用基于 SVM 的分类器对这些向量进行分类(见en.wikipedia.org/wiki/Support-vector_machine),外部工具提出的边界框通过图像向量上的线性回归网络进行修正。R-CNN 网络的概念表示如 图 20.4 所示:

图表,描述自动生成

图 20.4:R-CNN 网络

R-CNN 网络的下一个迭代版本叫做 Fast R-CNN。Fast R-CNN 仍然从外部工具获取区域建议,但不再将每个区域建议分别输入 CNN,而是将整个图像输入 CNN,并将区域建议投影到生成的特征图上。每个感兴趣的区域会通过感兴趣区域ROI)池化层,然后传递到一个全连接网络,生成该 ROI 的特征向量。

ROI 池化是使用 CNN 进行物体检测任务时广泛应用的一种操作。ROI 池化层使用最大池化将任何有效感兴趣区域内的特征转换为一个具有固定空间大小 H x W(其中 HW 是两个超参数)的较小特征图。然后,将该特征向量输入两个全连接网络,一个用于预测 ROI 的类别,另一个用于修正区域建议的边界框坐标。如 图 20.5 所示:

图表,描述自动生成

图 20.5:Fast R-CNN 网络架构

Fast R-CNN 比 R-CNN 快约 25 倍。下一步的改进,称为 Faster R-CNN(实现代码可见 github.com/tensorpack/tensorpack/tree/master/examples/FasterRCNN),移除了外部区域建议机制,并用一个可训练组件 —— 区域建议网络RPN) —— 替代,嵌入到网络本身。该网络的输出与特征图结合,并通过与 Fast R-CNN 网络类似的管道传递,如 图 20.6 所示。

Faster R-CNN 网络的速度是 Fast R-CNN 网络的约 10 倍,使其比 R-CNN 网络快约 250 倍:

图表,描述自动生成

图 20.6:Faster R-CNN 网络架构

另一类稍微不同的物体检测网络是 单次检测器SSD),例如 YOLOYou Only Look Once)。在这些情况下,每张图片都会被使用网格分割成预定义数量的部分。对于 YOLO 来说,使用的是一个 7 x 7 的网格,结果是 49 个子图像。每个子图像都会应用一组预定的不同纵横比的裁剪。给定 B 个边界框和 C 个物体类别,每张图片的输出是一个大小为 的向量。每个边界框都有一个置信度和坐标(xywh),每个网格会有一个预测概率,表示在其中检测到的不同物体。

YOLO 网络是一个 CNN,它执行这种转换。最终的预测和边界框通过聚合此向量中的结果来获得。在 YOLO 中,单个卷积网络预测边界框及相关类别概率。YOLO 是物体检测的更快解决方案。实现可以在 www.kaggle.com/aruchomu/yolo-v3-object-detection-in-tensorflow 找到。

实例分割

实例分割与语义分割相似——即将图像的每个像素与一个类别标签关联——但有一些重要的区别。首先,它需要区分图像中同一类别的不同实例。其次,它不要求标记图像中的每一个像素。在某些方面,实例分割也类似于物体检测,不同之处在于我们不使用边界框,而是需要找到覆盖每个物体的二进制掩码。

第二个定义揭示了 Mask R-CNN 网络背后的直觉。Mask R-CNN 是一个带有额外 CNN 的 Faster R-CNN,该 CNN 位于回归头部之前,输入为为每个 ROI 报告的边界框坐标,并将其转换为二进制掩码 [11]:

图示 描述自动生成

图 20.7:Mask R-CNN 架构

2019 年 4 月,Google 开源发布了 Mask R-CNN,并且用 TPUs 进行了预训练。你可以在以下链接找到该模型:

colab.research.google.com/github/tensorflow/tpu/blob/master/models/official/mask_rcnn/mask_rcnn_demo.ipynb

我建议你尝试一下 Colab 笔记本,看看结果如何。在 图 20.8 中,我们看到了一个图像分割的示例:

图 20.8:图像分割的一个示例

Google 还发布了另一个基于 TPUs 训练的模型,名为 DeepLab,你可以从演示中看到一张图片(图 20.9)。这个模型可以在以下链接找到:

colab.research.google.com/github/tensorflow/models/blob/master/research/deeplab/deeplab_demo.ipynb#scrollTo=edGukUHXyymr

图 20.9:图像分割的示例

在本节中,我们大致介绍了几种在计算机视觉领域流行的网络架构。请注意,所有这些架构都由相同的基本 CNN 和全连接架构组成。这种可组合性是深度学*最强大的特性之一。希望这能给你一些启示,帮助你设计适合自己计算机视觉应用的网络。

使用 tf.Keras 和 TensorFlow Hub 的应用程序

转移学*的一个好处是可以重用预训练网络,从而节省时间和资源。市面上有许多现成的网络集合,但以下两个是最常用的。

Keras 应用程序

Keras 应用程序(Keras 应用程序可以在 www.tensorflow.org/api_docs/python/tf/keras/applications 找到)包括了用于图像分类的模型,这些模型在 ImageNet 上训练过(Xception、VGG16、VGG19、ResNet、ResNetV2、ResNeXt、InceptionV3、InceptionResNetV2、MobileNet、MobileNetV2、DenseNet 和 NASNet)。此外,还有一些来自社区的其他参考实现,涉及目标检测和分割、序列学*、强化学*(见 第十一章)以及 GANs(见 第九章)。

TensorFlow Hub

TensorFlow Hub(可访问 www.tensorflow.org/hub)是一个预训练模型的替代集合。TensorFlow Hub 包含了文本分类、句子编码(见 第四章)、图像分类、特征提取、使用 GAN 生成图像以及视频分类的模块。目前,Google 和 DeepMind 都在为 TensorFlow Hub 做贡献。

让我们看一个使用 TF.Hub 的示例。在这个例子中,我们有一个使用 MobileNetv2 的简单图像分类器:

import matplotlib.pylab as plt
import tensorflow as tf
import tensorflow_hub as hub
import numpy as np
import PIL.Image as Image
classifier_url ="https://tfhub.dev/google/tf2-preview/mobilenet_v2/classification/2" #@param {type:"string"}
IMAGE_SHAPE = (224, 224)
# wrap the hub to work with tf.keras
classifier = tf.keras.Sequential([
    hub.KerasLayer(classifier_url, input_shape=IMAGE_SHAPE+(3,))
])
grace_hopper = tf.keras.utils.get_file('image.jpg','https://storage.googleapis.com/download.tensorflow.org/example_images/grace_hopper.jpg')
grace_hopper = Image.open(grace_hopper).resize(IMAGE_SHAPE)
grace_hopper = np.array(grace_hopper)/255.0
result = classifier.predict(grace_hopper[np.newaxis, ...])
predicted_class = np.argmax(result[0], axis=-1)
print (predicted_class) 

确实很简单。只需记得使用 hub.KerasLayer() 来包装任何 Hub 层。在本节中,我们讨论了如何使用 TensorFlow Hub。

接下来,我们将重点介绍其他 CNN 架构。

回答关于图像的问题(视觉问答)

神经网络的一个优点是可以将不同类型的媒体结合在一起,以提供统一的解释。例如,视觉问答VQA)结合了图像识别和文本自然语言处理。训练可以使用 VQA(VQA 数据集可以在visualqa.org/获取),它包含有关图像的开放式问题。这些问题需要理解视觉、语言和常识才能回答。以下图像来自于visualqa.org/上的一个演示。

请注意图像顶部的问题,以及随后的答案:

图形用户界面,应用程序描述自动生成

图 20.10:视觉问答示例

如果你想开始玩 VQA,首先需要获取适当的训练数据集,如 VQA 数据集、CLEVR 数据集(可在cs.stanford.edu/people/jcjohns/clevr/获取)或 FigureQA 数据集(可在datasets.maluuba.com/FigureQA获取);或者,你可以参与 Kaggle 的 VQA 挑战(可在www.kaggle.com/c/visual-question-answering参与)。然后,你可以构建一个结合 CNN 和 RNN 的模型并开始实验。例如,CNN 可以是这样的代码片段,它接受一个具有三个通道(224 x 224)的图像作为输入,并为图像生成一个特征向量:

import tensorflow as tf
from tensorflow.keras import layers, models
# IMAGE
#
# Define CNN for visual processing
cnn_model = models.Sequential()
cnn_model.add(layers.Conv2D(64, (3, 3), activation='relu', padding='same', 
        input_shape=(224, 224, 3)))
cnn_model.add(layers.Conv2D(64, (3, 3), activation='relu'))
cnn_model.add(layers.MaxPooling2D(2, 2))
cnn_model.add(layers.Conv2D(128, (3, 3), activation='relu', padding='same'))
cnn_model.add(layers.Conv2D(128, (3, 3), activation='relu'))
cnn_model.add(layers.MaxPooling2D(2, 2))
cnn_model.add(layers.Conv2D(256, (3, 3), activation='relu', padding='same'))
cnn_model.add(layers.Conv2D(256, (3, 3), activation='relu'))
cnn_model.add(layers.Conv2D(256, (3, 3), activation='relu'))
cnn_model.add(layers.MaxPooling2D(2, 2))
cnn_model.add(layers.Flatten())
cnn_model.summary()
#define the visual_model with proper input
image_input = layers.Input(shape=(224, 224, 3))
visual_model = cnn_model(image_input) 

文本可以通过 RNN 进行编码;目前,可以将其视为一个黑盒,它接受一个文本片段(问题)作为输入,并为文本生成一个特征向量:

# TEXT
#
#define the RNN model for text processing
question_input = layers.Input(shape=(100,), dtype='int32')
emdedding = layers.Embedding(input_dim=10000, output_dim=256, 
    input_length=100)(question_input)
encoded_question = layers.LSTM(256)(emdedding) 

然后,将两个特征向量(一个是图像的,另一个是文本的)合并为一个联合向量,该向量作为输入提供给密集网络,以生成组合网络:

# combine the encoded question and visual model
merged = layers.concatenate([encoded_question, visual_model])
#attach a dense network at the end
output = layers.Dense(1000, activation='softmax')(merged)
#get the combined model
vqa_model = models.Model(inputs=[image_input, question_input], outputs=output)
vqa_model.summary() 

例如,如果我们有一组标记的图像,那么我们可以学*描述图像的最佳问题和答案。选择的数量非常庞大!如果你想了解更多,我建议你调查 Maluuba,一家提供 FigureQA 数据集的初创公司,该数据集包含 100,000 个图像和 1,327,368 对问答。Maluuba 最*被微软收购,实验室由深度学*的奠基人之一 Yoshua Bengio 担任顾问。

在本节中,我们讨论了如何实现视觉问答。下一节将介绍风格迁移,这是一种用于训练神经网络创作艺术的深度学*技术。

创建一个 DeepDream 网络

CNN 的另一个有趣应用是 DeepDream,一个由 Google [8] 创建的计算机视觉程序,它利用 CNN 在图像中寻找并增强模式。结果是梦幻般的迷幻效果。与之前的示例类似,我们将使用一个预训练的网络来提取特征。然而,在这种情况下,我们希望“增强”图像中的模式,这意味着我们需要最大化一些函数。这告诉我们需要使用梯度上升,而不是梯度下降。首先,让我们看一个来自 Google 画廊的示例(可在colab.research.google.com/github/tensorflow/docs/blob/master/site/en/tutorials/generative/deepdream.ipynb获取),其中经典的西雅图景观被“接纳”了梦幻般的幻觉效果,如鸟类、卡片和奇怪的飞行物体。

Google 发布了 DeepDream 的开源代码(可在github.com/google/deepdream获取),但我们将使用一个由随机森林生成的简化示例(可在www.tensorflow.org/tutorials/generative/deepdream获取):

图 20.11:深度梦境:西雅图

让我们从一些图像预处理开始:

# Download an image and read it into a NumPy array, 
def download(url):
  name = url.split("/")[-1]
  image_path = tf.keras.utils.get_file(name, origin=url)
  img = image.load_img(image_path)
  return image.img_to_array(img)
# Scale pixels to between (-1.0 and 1.0)
def preprocess(img):
  return (img / 127.5) - 1

# Undo the preprocessing above
def deprocess(img):
  img = img.copy()
  img /= 2.
  img += 0.5
  img *= 255.
  return np.clip(img, 0, 255).astype('uint8')
# Display an image
def show(img):
  plt.figure(figsize=(12,12))
  plt.grid(False)
  plt.axis('off')
  plt.imshow(img)
# https://commons.wikimedia.org/wiki/File:Flickr_-_Nicholas_T_-_Big_Sky_(1).jpg
url = 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Flickr_-_Nicholas_T_-_Big_Sky_%281%29.jpg/747px-Flickr_-_Nicholas_T_-_Big_Sky_%281%29.jpg'
img = preprocess(download(url))
show(deprocess(img)) 

现在让我们使用预训练的 Inception 网络来提取特征。我们使用多个层,目标是最大化它们的激活值。tf.keras函数式 API 在这里对我们非常有用:

# We'll maximize the activations of these layers
names = ['mixed2', 'mixed3', 'mixed4', 'mixed5']
layers = [inception_v3.get_layer(name).output for name in names]
# Create our feature extraction model
feat_extraction_model = tf.keras.Model(inputs=inception_v3.input, outputs=layers)
def forward(img):

  # Create a batch
  img_batch = tf.expand_dims(img, axis=0)

  # Forward the image through Inception, extract activations
  # for the layers we selected above
  return feat_extraction_model(img_batch) 

损失函数是所有激活层的平均值,通过该层自身单元的数量进行归一化:

def calc_loss(layer_activations):

  total_loss = 0

  for act in layer_activations:

    # In gradient ascent, we'll want to maximize this value
    # so our image increasingly "excites" the layer
    loss = tf.math.reduce_mean(act)
    # Normalize by the number of units in the layer
    loss /= np.prod(act.shape)
    total_loss += loss
  return total_loss 

现在让我们运行梯度上升:

img = tf.Variable(img)
steps = 400
for step in range(steps):

  with tf.GradientTape() as tape:
    activations = forward(img)
    loss = calc_loss(activations)

  gradients = tape.gradient(loss, img)
  # Normalize the gradients
  gradients /= gradients.numpy().std() + 1e-8 

  # Update our image by directly adding the gradients
  img.assign_add(gradients)

  if step % 50 == 0:
    clear_output()
    print ("Step %d, loss %f" % (step, loss))
    show(deprocess(img.numpy()))
    plt.show()
# Let's see the result
clear_output()
show(deprocess(img.numpy())) 

这将把左侧的图像转换成右侧的迷幻图像:

图 20.12:深度梦境:绿地与云彩

检查网络学到了什么

一个特别有趣的研究方向是了解神经网络到底在学*什么,从而能够如此精准地识别图像。这被称为神经网络的“可解释性”。激活图谱是一种有前景的*期技术,旨在展示平均激活函数的特征可视化。通过这种方式,激活图谱生成了通过网络“眼睛”看到的全球地图。让我们来看一个可用的演示:distill.pub/2019/activation-atlas/

图 20.13:检查示例

在这张图片中,使用用于视觉分类的 InceptionV1 网络展示了许多完全实现的特征,例如电子产品、屏幕、宝丽来相机、建筑物、食物、动物耳朵、植物和水域背景。请注意,网格单元标注了它们给出最多支持的分类。网格单元的大小也根据其中平均激活的次数进行调整。这种表示方法非常强大,因为它允许我们检查网络的不同层以及激活函数如何响应输入进行激活。

在本节中,我们已经看到了许多用 CNN 处理图像的技术。接下来,我们将转向视频处理。

视频

在本节中,我们将讨论如何将 CNN 与视频结合使用,以及我们可以使用的不同技术。

用预训练网络以六种不同方式分类视频

视频分类是一个活跃的研究领域,因为处理这种类型的媒体需要大量的数据。内存需求通常会达到现代 GPU 的极限,可能需要在多台机器上进行分布式训练。目前,研究人员正在探索不同的研究方向,从第一种方法到第六种方法的复杂度逐步增加,具体如下所述。让我们来回顾一下:

  • 第一种方法是逐帧对视频进行分类,将每一帧视为一个单独的图像,并用 2D 卷积神经网络(CNN)处理。这种方法简单地将视频分类问题简化为图像分类问题。每一帧视频“发出”一个分类输出,视频的分类通过考虑每一帧最常选择的类别来确定。

  • 第二种方法是创建一个单一的网络,将 2D 卷积神经网络(CNN)与循环神经网络(RNN)结合起来(参见第九章生成模型)。其思想是,CNN 将考虑图像的组成部分,而 RNN 则考虑每个视频的序列信息。这种类型的网络可能非常难以训练,因为它有大量需要优化的参数。

  • 第三种方法是使用 3D 卷积网络(3D ConvNet),其中 3D 卷积网络是 2D 卷积网络的扩展,操作于 3D 张量(时间、图像宽度和图像高度)。这种方法是图像分类的另一种自然扩展。同样,3D 卷积网络也可能很难训练。

  • 第四种方法基于一个巧妙的想法:不是直接使用 CNN 进行分类,而是将它们用于存储每一帧视频的离线特征。其思想是,特征提取可以通过迁移学*变得非常高效,如之前的食谱所示。在提取所有特征后,它们可以作为输入集传递给 RNN,RNN 将学*跨多个帧的序列并输出最终分类。

  • 第五种方法是第四种方法的一种简单变体,其中最后一层是 MLP,而不是 RNN。在某些情况下,这种方法可能更简单,且在计算需求上更低。

  • 第六种方法是第四种方法的一种变体,其中特征提取阶段是通过一个 3D 卷积神经网络(CNN)来实现的,该网络提取空间和视觉特征。这些特征随后传递给一个 RNN 或 MLP。

确定最佳方法完全取决于你的具体应用,并没有明确的答案。前三种方法通常计算开销较大且较为笨重,而后三种方法则开销较小,且经常能够取得更好的性能。

到目前为止,我们已经探讨了 CNN 如何用于图像和视频应用。接下来的部分,我们将把这些思想应用于基于文本的上下文中。

文本文档

文本和图像有什么共同点?乍一看,似乎没有什么共同点。然而,如果我们将一个句子或文档表示为一个矩阵,那么这个矩阵与图像矩阵没有太大区别,因为每个单元格就像图像中的一个像素。那么,下一个问题是,我们如何将一段文本表示为一个矩阵呢?

其实很简单:矩阵的每一行是一个表示文本基本单元的向量。当然,现在我们需要定义什么是基本单元。一个简单的选择是将基本单元定义为一个字符。另一个选择是将基本单元定义为一个单词;还有一种选择是将相似的单词聚合在一起,然后用一个代表性的符号来表示每个聚合(有时称为簇或嵌入)。

请注意,无论我们为基本单元选择什么,必须保证从基本单元到整数 ID 的 1:1 映射,以便将文本视为矩阵。例如,如果我们有一个包含 10 行文本的文档,每行是一个 100 维的嵌入,那么我们将用一个 10 x 100 的矩阵来表示文本。在这个非常特殊的“图像”中,只有当某个句子X包含位置Y表示的嵌入时,那个“像素”才会被点亮。你可能还会注意到,文本并不是真正的矩阵,更像是一个向量,因为位于相邻行的两个单词几乎没有什么关联。实际上,这与图像有很大的区别,因为图像中位于相邻列的两个像素可能会有某种程度的相关性。

现在你可能会想:我理解我们将文本表示为一个向量,但这样做的话,我们失去了单词的位置。这个位置应该很重要,不是吗? 结果证明,在许多实际应用中,知道一个句子是否包含某个特定的基本单元(字符、单词或聚合)是非常有用的信息,即使我们没有追踪这个基本单元在句子中的确切位置。

例如,CNN 在情感分析中取得了不错的结果,在情感分析中,我们需要理解一段文本是积极的还是消极的;在垃圾邮件检测中,我们需要判断一段文本是有用的信息还是垃圾邮件;在主题分类中,我们需要了解一段文本的主题是什么。然而,CNN 并不适合词性分析POS),在词性分析中,目标是理解每个单词的逻辑角色是什么(例如,动词、副词、主语等)。CNN 也不太适合实体提取,在实体提取中,我们需要理解句子中相关实体的位置。

事实上,事实证明,位置对于最后两个使用案例非常有用。1D 卷积神经网络(ConvNets)与 2D 卷积神经网络非常相似。然而,前者操作的是单一向量,而后者操作的是矩阵。

使用 CNN 进行情感分析

让我们来看一下代码。首先,我们使用tensorflow_datasets加载数据集。在这个例子中,我们使用 IMDB,它是一个电影评论的集合:

import tensorflow as tf
from tensorflow.keras import datasets, layers, models, preprocessing
import tensorflow_datasets as tfds
max_len = 200
n_words = 10000
dim_embedding = 256
EPOCHS = 20
BATCH_SIZE =500
def load_data():
    #load data
    (X_train, y_train), (X_test, y_test) = datasets.imdb.load_data(num_words=n_words)
    # Pad sequences with max_len
    X_train = preprocessing.sequence.pad_sequences(X_train, maxlen=max_len)
    X_test = preprocessing.sequence.pad_sequences(X_test, maxlen=max_len)
    return (X_train, y_train), (X_test, y_test) 

然后,我们构建一个合适的 CNN 模型。我们使用词嵌入(参见第四章词嵌入)将文档中通常观察到的稀疏词汇映射到一个密集的特征空间,维度为dim_embedding。然后,我们使用Conv1D,接着是GlobalMaxPooling1D进行平均,再加上两个Dense层——最后一个只有一个神经元,用于输出二元选择(正面或负面评论):

def build_model():
    model = models.Sequential()
    #Input - Embedding Layer
    # the model will take as input an integer matrix of size (batch, input_length)
    # the model will output dimension (input_length, dim_embedding)
    # the largest integer in the input should be no larger
    # than n_words (vocabulary size).
    model.add(layers.Embedding(n_words,
        dim_embedding, input_length=max_len))
    model.add(layers.Dropout(0.3))
    model.add(layers.Conv1D(256, 3, padding='valid', 
        activation='relu'))
    #takes the maximum value of either feature vector from each of the n_words features
    model.add(layers.GlobalMaxPooling1D())
    model.add(layers.Dense(128, activation='relu'))
    model.add(layers.Dropout(0.5))
    model.add(layers.Dense(1, activation='sigmoid'))
    return model
(X_train, y_train), (X_test, y_test) = load_data()
model=build_model()
model.summary() 

该模型有超过 2,700,000 个参数,概述如下:

_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 embedding (Embedding)       (None, 200, 256)          2560000   

 dropout (Dropout)           (None, 200, 256)          0         

 conv1d (Conv1D)             (None, 198, 256)          196864    

 global_max_pooling1d (Globa  (None, 256)              0         
 lMaxPooling1D)                                                  

 dense (Dense)               (None, 128)               32896     

 dropout_1 (Dropout)         (None, 128)               0         

 dense_1 (Dense)             (None, 1)                 129       

=================================================================
Total params: 2,789,889
Trainable params: 2,789,889
Non-trainable params: 0 

然后,我们使用 Adam 优化器和二元交叉熵损失函数来编译并拟合模型:

model.compile(optimizer = "adam", loss = "binary_crossentropy",
  metrics = ["accuracy"]
)
score = model.fit(X_train, y_train,
  epochs= EPOCHS,
  batch_size = BATCH_SIZE,
  validation_data = (X_test, y_test)
)
score = model.evaluate(X_test, y_test, batch_size=BATCH_SIZE)
print("\nTest score:", score[0])
print('Test accuracy:', score[1]) 

最终准确率为 88.21%,这表明成功使用卷积神经网络(CNN)进行文本处理是可能的:

Epoch 19/20
25000/25000 [==============================] - 135s 5ms/sample - loss: 7.5276e-04 - accuracy: 1.0000 - val_loss: 0.5753 - val_accuracy: 0.8818
Epoch 20/20
25000/25000 [==============================] - 129s 5ms/sample - loss: 6.7755e-04 - accuracy: 0.9999 - val_loss: 0.5802 - val_accuracy: 0.8821
25000/25000 [==============================] - 23s 916us/sample - loss: 0.5802 - accuracy: 0.8821
Test score: 0.5801781857013703
Test accuracy: 0.88212 

请注意,许多其他非图像应用也可以转换为图像并使用 CNN 进行分类(例如,参见becominghuman.ai/sound-classification-using-images-68d4770df426)。

音频与音乐

我们已经将 CNN 应用于图像、视频和文本。现在,让我们来看看 CNN 的变种如何应用于音频。

所以,你可能会想,为什么学*合成音频这么困难。嗯,我们听到的每个数字声音都基于每秒 16,000 个样本(有时是 48K 或更多),并且构建一个预测模型,通过所有之前的样本来学*重现一个样本是一个非常困难的挑战。

扩张卷积神经网络(Dilated ConvNets)、WaveNet 和 NSynth

WaveNet 是一种用于生成原始音频波形的深度生成模型。这项突破性的技术由 Google DeepMind 提出(可在deepmind.com/blog/wavenet-a-generative-model-for-raw-audio/查看),用于教计算机如何说话。其结果非常令人印象深刻,网上可以找到一些合成语音的示例,其中计算机学会了用像马特·达蒙等名人的声音进行对话。有实验表明,WaveNet 提高了现有的文本转语音TTS)系统,将与人类语音的差异减少了 50%,适用于美国英语和普通话。这一比较使用的度量标准叫做平均意见分数MOS),是一种主观的配对比较测试。在 MOS 测试中,听完每个声音刺激后,受试者被要求对刺激的自然度进行五分制评分,从“差”(1 分)到“优秀”(5 分)。

更酷的是,DeepMind 展示了 WaveNet 还可以用于教计算机如何生成像钢琴音乐这样的乐器声音。

现在一些定义。TTS 系统通常分为两类:拼接式和参数化式。

拼接式 TTS 是将单个语音片段先存储,然后在需要复现语音时重新组合。然而,这种方法无法扩展,因为只能重现已存储的语音片段,无法在没有重新记忆片段的情况下复现新的说话者或不同类型的音频。

参数化 TTS 是指创建一个模型,用来存储所有要合成音频的特征。在 WaveNet 之前,使用参数化 TTS 生成的音频不如拼接式 TTS 自然。WaveNet 通过直接建模音频声音的生成,显著改善了这一点,而不是像过去那样使用中间信号处理算法。

原则上,WaveNet 可以视为一堆具有恒定步幅为 1 且没有池化层的 1D 卷积层。注意,输入和输出在结构上具有相同的维度,因此卷积神经网络(ConvNets)非常适合建模像音频声音这样的序列数据。然而,研究表明,为了在输出神经元中达到较大的感受野,需要使用大量的大型滤波器或以不可接受的方式增加网络深度。记住,层中神经元的感受野是前一层的交叉部分,神经元从中接收输入。因此,纯粹的卷积神经网络在学*如何合成音频方面并不那么有效。

WaveNet 的关键直觉是所谓的扩张因果卷积[5](有时也称为空洞卷积),这意味着在应用卷积层的滤波器时,一些输入值被跳过。“Atrous”是法语表达式“à trous”的变形,意思是“带孔的”。因此,空洞卷积就是带孔的卷积。例如,在一维情况下,一个大小为 3、扩张为 1 的滤波器w将计算以下和:w[0] x[0] + w[1] x[2] + w[2] x[4]。

简而言之,在 D-扩张卷积中,通常步幅为 1,但没有什么可以阻止你使用其他步幅。一个例子见图 20.14,展示了扩张(空洞)大小分别为 0、1、2 时的情况:

图 20.14:扩张与增大尺寸

由于引入空洞的这个简单思路,能够堆叠多个扩张卷积层,通过指数增长的滤波器学*长程输入依赖关系,而不需要一个过于深的网络。

因此,WaveNet 是一个卷积神经网络,其中卷积层具有不同的扩张因子,从而使感受野随着深度的增加而指数增长,进而有效地覆盖成千上万个音频时间步长。

在训练时,输入是来自人类发音者的录音。波形被量化为固定的整数范围。WaveNet 定义了一个初始卷积层,仅访问当前和先前的输入。然后是一个扩张卷积神经网络层的堆叠,依旧只访问当前和先前的输入。最后是一个密集层序列,结合先前的结果,接着是一个用于分类输出的 softmax 激活函数。

在每个步骤中,从网络中预测一个值并将其反馈到输入中。同时,计算下一步的预测值。损失函数是当前步骤的输出和下一步输入之间的交叉熵。图 20.15 展示了 Aaron van den Oord [9]介绍的 WaveNet 堆叠及其感受野的可视化。请注意,生成过程可能较慢,因为波形需要按顺序合成,因为* x[t]必须首先被采样,以便获得,其中x*是输入:

A picture containing diagram  Description automatically generated

图 20.15:WaveNet 内部连接

在《Parallel WaveNet [10]》中,提出了一种并行采样的方法,实现了三个数量级的加速。该方法使用两个网络作为 WaveNet 教师网络,一个较慢但能确保正确结果的教师网络和一个试图模仿教师行为的 WaveNet 学生网络;学生网络可能精度较低,但速度更快。这种方法类似于 GAN(见第九章生成模型)中使用的方法,但学生并不试图欺骗教师,正如通常在 GAN 中发生的那样。实际上,该模型不仅速度更快,而且精度更高,能够生成每秒 24,000 个采样的波形:

图示,形状 描述自动生成

图 20.16:WaveNet 学生与教师的示例

该模型已经在 Google 的生产环境中部署,并且目前正在实时为数百万用户提供 Google Assistant 查询。在 2018 年 5 月的年度 I/O 开发者大会上,宣布由于 WaveNet,新版的 Google Assistant 语音已经上线。

目前有两个 TensorFlow 中的 WaveNet 模型实现。一个是 DeepMind 的原始 WaveNet 实现,另一个叫做 Magenta NSynth。原始 WaveNet 版本可以在 github.com/ibab/tensorflow-wavenet 上获取。NSynth 是 WaveNet 的一种进化版,最*由 Google Brain 团队发布,它不同于传统的因果模型,而是旨在看到输入块的整个上下文。Magenta 可在 magenta.tensorflow.org/nsynth 上获取。

神经网络确实很复杂,如下图所示,但为了本次入门讨论的方便,只需知道该网络通过减少编码/解码阶段的误差来学*如何再现其输入:

NSynth_blog_figs_WaveNetAE_diagram.png

图 20.17:Magenta 内部架构

如果你有兴趣了解更多内容,建议查看在线 Colab 笔记本,你可以在其中使用 NSynth 生成的模型。NSynth Colab 可在 colab.research.google.com/notebooks/magenta/nsynth/nsynth.ipynb 上访问。

MuseNet 是 OpenAI 开发的一个非常新的、令人印象深刻的音频生成工具。MuseNet 使用稀疏变换器训练一个具有 72 层和 24 个注意力头的网络。MuseNet 可在 openai.com/blog/musenet/ 上访问。第六章讨论的变换器非常擅长预测序列中的下一个元素——无论是文本、图像还是声音。

在变压器中,每个输出元素都与每个输入元素连接,它们之间的权重是根据一个叫做注意力的过程动态计算的。MuseNet 可以生成最多 4 分钟的音乐作品,包含 10 种不同的乐器,并能将乡村、莫扎特、披头士等风格相结合。例如,我生成了一首贝多芬《致爱丽丝》的翻版,以 Lady Gaga 风格演绎,使用了钢琴、鼓、吉他和贝斯。你可以通过试用 MuseNet部分下提供的链接亲自尝试:

图形用户界面  描述自动生成

图 20.18:使用 MuseNet 的示例

卷积操作总结

在这一节中,我们总结了不同卷积操作。卷积层有I个输入通道,并产生O个输出通道。使用了I x O x K个参数,其中K是核中值的数量。

基本 CNN

让我们简要回顾一下什么是 CNN。CNN 输入的是图像(二维)、文本(二维)或视频(三维),并对输入应用多个滤波器。每个滤波器就像一盏手电筒,滑过输入的区域,它照射到的区域叫做感受野。每个滤波器是与输入深度相同的张量(例如,如果图像深度为三,则滤波器的深度也必须为三)。

当滤波器滑动或卷积输入图像时,滤波器中的值会与输入的值相乘。然后,将乘积汇总成一个单一的值。这个过程会对每个位置重复,产生一个激活图(也叫特征图)。当然,也可以使用多个滤波器,每个滤波器作为特征识别器。例如,对于图像,滤波器可以识别边缘、颜色、线条和曲线。关键的直觉是将滤波器的值视为权重,并在训练过程中通过反向传播进行微调。

卷积层可以通过以下配置参数进行配置:

  • 核大小:这是卷积的视野。

  • 步幅:这是核在遍历图像时的步长。

  • 填充:定义了我们如何处理样本的边界。

空洞卷积

空洞卷积(或称为 Atrous 卷积)引入了另一个配置参数:

  • 空洞率:这是核中值之间的间隔。

空洞卷积被广泛应用于多个场景,包括使用 WaveNet 进行音频处理。

转置卷积

转置卷积是一种与正常卷积方向相反的变换。例如,这在将特征图投射到更高维空间时非常有用,或者用于构建卷积自编码器(见第八章自编码器)。理解转置卷积的一种方式是,首先计算给定输入形状的正常 CNN 输出形状。然后,我们用转置卷积反转输入和输出形状。TensorFlow 2.0 支持转置卷积,通过 Conv2DTranspose 层可以使用它,例如,在生成对抗网络(GANs)(见第九章生成模型)中生成图像。

可分离卷积

可分离卷积旨在将卷积核分成多个步骤。设卷积为 y = conv(x, k),其中 y 是输出,x 是输入,k 是卷积核。假设卷积核是可分离的,k = k1.k2,其中“.”表示点积—在这种情况下,我们可以通过分别使用 k1 和 k2 做两个一维卷积来得到与 k 做二维卷积相同的结果。可分离卷积通常用于节省计算资源。

深度卷积

让我们考虑一个包含多个通道的图像。在正常的二维卷积中,滤波器的深度与输入相同,并且它允许我们混合通道来生成输出的每个元素。在深度卷积中,每个通道是分开处理的,滤波器被分割为多个通道,每个卷积分别应用,结果再重新堆叠成一个张量。

深度可分离卷积

这个卷积不应与可分离卷积混淆。在完成深度卷积后,会执行一个额外的步骤:跨通道进行 1x1 卷积。深度可分离卷积在 Xception 中得到了应用。它们也用于 MobileNet,这是一种特别适用于移动和嵌入式视觉应用的模型,因为它的模型尺寸和复杂度都较小。

在本节中,我们已经讨论了所有主要的卷积形式。下一节将讨论胶囊网络,这是一种在 2017 年提出的新型学*方法。

胶囊网络

胶囊网络(或 CapsNets)是*年来一种非常创新的深度学*网络类型。该技术在 2017 年 10 月底由 Sara Sabour、Nicholas Frost 和 Geoffrey Hinton 提出,并发布在题为《胶囊之间的动态路由》(Dynamic Routing Between Capsules)的开创性论文中(arxiv.org/abs/1710.09829)[14]。Hinton 是深度学*的奠基人,因此整个深度学*社区都为胶囊网络的进展感到兴奋。事实上,CapsNets 已经在 MNIST 分类任务中超越了最好的 CNN,这真是……令人印象深刻!!

卷积神经网络(CNN)有什么问题?

在 CNN 中,每一层“理解”图像的粒度逐渐增大。如我们在多个章节中讨论的那样,第一层最有可能识别直线、简单的曲线和边缘,而后续的层会开始理解更复杂的形状,如矩形,直到像人脸这样复杂的形式。

现在,卷积神经网络(CNN)中的一个关键操作是池化(pooling)。池化的目的是实现位置不变性,并且通常在每个 CNN 层之后使用,以便让任何问题在计算上变得可处理。然而,池化带来了一个显著的问题,因为它迫使我们丢失所有的位置信息。这是不可取的。想象一下一个面孔:它由两只眼睛、一张嘴和一个鼻子组成,重要的是这些部分之间有空间关系(例如,嘴巴在鼻子下方,而鼻子通常位于眼睛下方)。事实上,Hinton 曾说过:卷积神经网络中使用的池化操作是一个重大错误,而它之所以能如此有效,简直是一场灾难。从技术上讲,我们并不需要位置不变性,而是需要等变性(equivariance)。等变性是一个专业术语,表示我们希望理解图像中的旋转或比例变化,并希望网络能够做出相应的调整。这样,图像中不同组件之间的空间关系就不会丢失。

胶囊网络有什么新特点?

根据 Hinton 等人的说法,我们的大脑有一些叫做“胶囊”的模块,每个胶囊都专门处理某种特定类型的信息。特别地,有些胶囊在“理解”位置概念、大小概念、方向概念、变形概念、纹理等方面表现得非常好。除此之外,作者还建议,我们的大脑拥有特别高效的机制,能够动态地将每个信息片段传递给最适合处理该类型信息的胶囊。

因此,CNN 和胶囊网络(CapsNets)之间的主要区别在于,CNN 通过不断添加层来创建深度网络,而胶囊网络则是在每一层内部嵌套神经层。一个胶囊是一个神经元群体,它为网络引入了更多结构,并生成一个向量来表示图像中某个实体的存在。具体来说,Hinton 使用活动向量的长度来表示该实体存在的概率,而使用方向来表示实例化参数。当多个预测结果一致时,高层胶囊会被激活。对于每个可能的父胶囊,子胶囊会生成一个额外的预测向量。

现在出现了第二个创新:我们将在胶囊之间使用动态路由,不再使用传统的池化方法。低层胶囊更倾向于将其输出发送到与其活动向量有较大标量积的高层胶囊,预测来自低层胶囊。具有最大标量预测向量积的父胶囊将增加其胶囊联系。所有其他父胶囊将减少它们的联系。换句话说,这个想法是,如果高层胶囊同意低层胶囊的观点,它将请求发送更多这种类型的信息。如果没有达成一致,它将请求发送更少的信息。这种通过一致性方法进行的动态路由优于当前的机制,如最大池化,并且根据 Hinton 的说法,路由最终是一种解析图像的方法。实际上,最大池化忽略了除最大值以外的所有信息,而动态路由则根据低层和高层之间的一致性选择性地传播信息。

第三个不同之处是引入了新的非线性激活函数。与 CNN 中在每一层添加压缩函数不同,CapsNet 在一组嵌套的层次中添加了压缩函数。非线性激活函数在 公式 1 中表示,称为压缩函数:

其中 v[j] 是胶囊 j 的向量输出,s[j] 是其总输入。

此外,Hinton 等人表明,一个经过辨别训练的多层胶囊系统在 MNIST 上实现了最先进的性能,并且在识别高度重叠的数字方面显著优于卷积神经网络。

基于论文《胶囊间的动态路由》,一个简单的 CapsNet 架构如下所示:

Screen Shot 2017-11-03 at 7.22.09 PM.png

图 20.19:CapsNet 示例

该架构较为浅层,仅包含两层卷积层和一层全连接层。Conv1 具有 256 个 9 x 9 的卷积核,步长为 1,并采用 ReLU 激活函数。该层的作用是将像素强度转换为局部特征检测器的活动,然后将这些活动作为输入传递到 PrimaryCapsules 层。PrimaryCapsules 是一个具有 32 个通道的卷积胶囊层;每个主胶囊包含 8 个 9 x 9 的卷积单元,步长为 2。总的来说,PrimaryCapsules 具有 [32, 6, 6] 的胶囊输出(每个输出为 8 维向量),且 [6, 6] 网格中的每个胶囊与其他胶囊共享权重。最终层(DigitCaps)为每个数字类别提供一个 16 维的胶囊,每个胶囊接收来自下层所有其他胶囊的输入。路由仅发生在两个连续的胶囊层之间(例如,PrimaryCapsules 和 DigitCaps)。

摘要

在本章中,我们已经看到 CNN 在不同领域中的许多应用,从传统的图像处理和计算机视觉,到接*的视频处理、相对较远的音频处理以及文本处理。在短短几年内,CNN 已经席卷了机器学*领域。

如今,看到多模态处理已经不再罕见,其中文本、图像、音频和视频会被一同考虑,以实现更好的性能,通常通过将 CNN 与其他技术(如 RNN 和强化学*)结合来完成。当然,还有很多需要考虑的方面,CNN 最*已被应用到许多其他领域,如基因推理[13],这些领域至少从表面上看,与 CNN 的原始设计目标相距甚远。

参考文献

  1. Yosinski, J. 和 Clune, Y. B. J. 深度神经网络中的特征迁移性。神经信息处理系统进展 27, 第 3320–3328 页。

  2. Szegedy, C., Vanhoucke, V., Ioffe, S., Shlens, J., 和 Wojna, Z. (2016). 重新思考计算机视觉中的 Inception 架构。2016 年 IEEE 计算机视觉与模式识别会议(CVPR),第 2818–2826 页。

  3. Sandler, M., Howard, A., Zhu, M., Zhmonginov, A., 和 Chen, L. C. (2019). MobileNetV2: 反向残差和线性瓶颈。Google Inc.

  4. Krizhevsky, A., Sutskever, I., Hinton, G. E., (2012). 使用深度卷积神经网络进行 ImageNet 分类

  5. Huang, G., Liu, Z., van der Maaten, L., 和 Weinberger, K. Q. (2018 年 1 月 28 日). 密集连接卷积网络arxiv.org/abs/1608.06993

  6. Chollet, F. (2017). Xception: 深度学*与深度可分离卷积arxiv.org/abs/1610.02357

  7. Gatys, L. A., Ecker, A. S., 和 Bethge, M. (2016). 艺术风格的神经算法arxiv.org/abs/1508.06576

  8. Mordvintsev, A., Olah, C., 和 Tyka, M. (2015). DeepDream - 可视化神经网络的代码示例。Google 研究。

  9. van den Oord, A., Dieleman, S., Zen, H., Simonyan, K., Vinyals, O., Graves, A., Kalchbrenner, N., Senior, A., 和 Kavukcuoglu, K. (2016). WaveNet: 一种原始音频的生成模型。arXiv 预印本。

  10. van den Oord, A., Li, Y., Babuschkin, I., Simonyan, K., Vinyals, O., Kavukcuoglu, K., van den Driessche, G., Lockhart, E., Cobo, L. C., Stimberg, F., Casagrande, N., Grewe, D., Noury, S., Dieleman, S., Elsen, E., Kalchbrenner, N., Zen, H., Graves, A., King, H., Walters, T., Belov, D., 和 Hassabis, D. (2017). Parallel WaveNet: 快速高保真语音合成

  11. He, K., Gkioxari, G., Dollár, P., 和 Girshick, R. (2018). Mask R-CNN

  12. Chen, L-C., Zhu, Y., Papandreou, G., Schroff, F., 和 Adam, H. (2018). 基于空洞可分离卷积的编码器-解码器结构用于语义图像分割

  13. Flagel, L., Brandvain, Y., 和 Schrider, D.R. (2018). 卷积神经网络在群体遗传推断中的非凡有效性

  14. Sabour, S., Frosst, N., 和 Hinton, G. E. (2017). 胶囊网络中的动态路由 arxiv.org/abs/1710.09829

加入我们书籍的 Discord 空间

加入我们的 Discord 社区,结识志同道合的人,与超过 2000 名成员一起学*,网址:packt.link/keras

posted @ 2025-07-08 21:22  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报