TensorFlow-自然语言处理第二版-全-
TensorFlow 自然语言处理第二版(全)
原文:
annas-archive.org/md5/fc366c3f6d3023ea2889a905de68763e译者:飞龙
前言
TensorFlow 是开发机器学习(ML)解决方案的核心。它是一个生态系统,可以支持 ML 项目生命周期中的各个阶段,从早期的原型设计到模型的生产化。TensorFlow 提供了各种可重用的构建模块,允许您构建不仅仅是最简单的,甚至是最复杂的深度神经网络。
本书适用对象
本书面向 TensorFlow 初学者到中级用户。读者可能来自学术界,从事机器学习的前沿研究,或者来自工业界,将机器学习应用于工作中。如果您已经对 TensorFlow(或类似的框架如 Pytorch)有一些基础了解,您将从本书中获得最大的收益。这将帮助您更快地掌握本书讨论的概念和使用案例。
本书内容概述
第一章,自然语言处理简介,解释了自然语言处理是什么,以及它可能涉及的任务类型。然后我们讨论了如何使用传统方法解决 NLP 任务。这为讨论如何在 NLP 中使用深度学习及其优势铺平了道路。最后,我们讨论了本书中使用的技术工具的安装和使用。
第二章,理解 TensorFlow 2,为您提供了编写程序和在 TensorFlow 2 中运行程序的完整指南。本章将首先深入解释 TensorFlow 如何执行程序。这将帮助您理解 TensorFlow 的执行流程,并熟悉 TensorFlow 的术语。接下来,我们将讨论 TensorFlow 中的各种构建模块和可用的有用操作。最后,我们将讨论如何利用所有这些 TensorFlow 知识来实现一个简单的神经网络,用于分类手写数字图像。
第三章,Word2vec - 学习词嵌入,介绍了 Word2vec——一种学习反映单词语义的数值表示方法。但在直接进入 Word2vec 技术之前,我们首先讨论了一些用于表示单词的经典方法,如独热编码表示法,以及词频-逆文档频率(TF-IDF)频率方法。接下来,我们将介绍一种现代的学习词向量的工具,即 Word2vec,它利用神经网络学习单词表示。我们将讨论两种流行的 Word2vec 变体:skip-gram 和连续袋词模型(CBOW)。最后,我们将使用降维技术可视化所学习到的单词表示,将向量映射到更易于解释的二维平面。
第四章,高级词向量算法,介绍了一种较新的词嵌入学习技术——GloVe,它结合了文本数据中的全局和局部统计信息来寻找词向量。接下来,我们将学习一种现代的、更复杂的技术,即基于词语上下文生成动态词表示的技术,称为 ELMo。
第五章,卷积神经网络的句子分类,介绍了卷积神经网络(CNNs)。CNNs 是一类强大的深度学习模型,它能够利用输入数据的空间结构进行学习。换句话说,CNN 可以处理二维形式的图像,而多层感知机则需要将图像展开成一维向量。我们将首先详细讨论 CNN 中涉及的各种操作,例如卷积操作和池化操作。接下来,我们将通过一个例子,学习如何使用 CNN 对衣物图像进行分类。然后,我们将进入 CNN 在自然语言处理(NLP)中的应用。更准确地说,我们将研究如何将 CNN 应用于句子分类任务,其中任务是将句子分类为与人、地点、物体等相关。
第六章,递归神经网络,重点介绍了递归神经网络(RNNs)及其在语言生成中的应用。RNN 与前馈神经网络(例如 CNNs)不同,因为 RNN 具有记忆。该记忆以持续更新的系统状态形式存储。我们将从前馈神经网络的表示开始,并修改该表示,使其能够从数据序列中学习,而非单个数据点。这个过程将把前馈网络转化为 RNN。接着,我们将详细描述 RNN 内部用于计算的精确方程。然后,我们将讨论用于更新 RNN 权重的 RNN 优化过程。随后,我们将遍历不同类型的 RNN,例如一对一 RNN 和一对多 RNN。接下来,我们将讨论 RNN 的一个流行应用,即识别文本中的命名实体(例如人名、组织名等)。在这里,我们将使用一个基础的 RNN 模型进行学习。然后,我们将通过在不同尺度(例如标记嵌入和字符嵌入)中引入嵌入来进一步增强我们的模型。标记嵌入通过嵌入层生成,而字符嵌入则通过 CNN 生成。最后,我们将分析新模型在命名实体识别任务中的表现。
第七章,理解长短期记忆网络,讨论了长短期记忆网络(LSTM),首先通过直观的解释让你理解这些模型是如何工作的,然后逐步深入技术细节,帮助你自己实现它们。标准的 RNN 模型存在一个重要的局限性——无法保持长期记忆。然而,已经提出了先进的 RNN 模型(例如 LSTM 和门控循环单元(GRU)),它们能够记住多个时间步的序列。我们还将探讨 LSTM 是如何缓解长期记忆保持问题的(这被称为梯度消失问题)。接着,我们将讨论几种可以进一步改进 LSTM 模型的修改方法,例如一次性预测多个时间步并且同时读取前后序列。最后,我们将讨论 LSTM 模型的几种变体,例如 GRU 和带窥视连接的 LSTM。
第八章,LSTM 的应用——生成文本,解释了如何实现第七章中讨论的 LSTM、GRU 和带窥视连接的 LSTM,理解长短期记忆网络。此外,我们还将从定性和定量两个方面比较这些扩展的性能。我们还将讨论如何实现第七章中考察的一些扩展,例如预测多个时间步(即束搜索),以及使用词向量作为输入,而不是使用独热编码表示。
第九章,序列到序列学习—神经机器翻译,讨论了机器翻译,这一领域由于自动化翻译的必要性以及任务本身的固有难度而引起了大量关注。我们从简短的历史回顾开始,解释了机器翻译在早期是如何实现的。这一讨论以对神经机器翻译(NMT)系统的介绍结束。我们将看到当前的 NMT 系统与老旧系统(如统计机器翻译系统)相比表现如何,这将激励我们进一步学习 NMT 系统。接下来,我们将讨论支撑 NMT 系统设计的基本概念,并继续讲解技术细节。然后,我们将讨论用于评估系统的评估指标。接下来,我们将研究如何从零开始实现一个英德翻译器。然后,我们将学习如何改进 NMT 系统。我们将详细介绍其中的一种扩展,即注意力机制。注意力机制已成为序列到序列学习问题中的关键因素。最后,我们将比较应用注意力机制后性能的提升,并分析性能提升的原因。本章的最后部分将讲解如何将 NMT 系统的相同概念扩展应用于聊天机器人。聊天机器人是能够与人类进行交流的系统,广泛用于满足各种客户需求。
第十章,Transformer,讨论了 Transformer,这一在自然语言处理领域的最新突破,已超越了许多先前的先进模型。在本章中,我们将使用 Hugging Face 的 Transformers 库,轻松地利用预训练模型进行下游任务。在本章中,我们将深入了解 Transformer 架构。接下来,我们将介绍一种流行的 Transformer 模型,称为 BERT,使用它来解决问题解答任务。我们将讨论 BERT 中一些特定的组件,以便有效地将其应用于实践。然后,我们将在一个流行的问答数据集 SQUAD 上训练模型。最后,我们将对模型进行评估,并使用训练好的模型为未见过的问题生成答案。
第十一章,使用 Transformers 进行图像标题生成,探讨了另一种激动人心的应用,使用 Transformers 生成图像的标题(即描述)。这个应用有趣之处在于,它展示了如何结合两种不同类型的模型,以及如何使用多模态数据(例如图像和文本)进行学习。在这里,我们将使用一个预训练的 Vision Transformer 模型,该模型为给定的图像生成丰富的隐藏表示。这个表示与标题令牌一起输入到基于文本的 Transformer 模型中。基于文本的 Transformer 根据之前的标题令牌预测下一个标题令牌。一旦模型训练完成,我们将定性和定量地评估模型生成的标题。我们还将讨论一些用于衡量序列质量(如图像标题)的常用指标。
附录 A: 数学基础与高级 TensorFlow,介绍了各种数学数据结构(例如矩阵)和运算(例如矩阵求逆)。我们还将讨论概率中的一些重要概念。最后,我们将引导你学习如何使用 TensorBoard 可视化词嵌入。TensorBoard 是一个随 TensorFlow 提供的实用可视化工具,可以用来可视化和监控 TensorFlow 客户端中的各种变量。
如何最大化本书的学习效果
为了最大化本书的学习效果,你需要对 TensorFlow 或类似框架(如 PyTorch)有基本了解。通过网上免费提供的基础 TensorFlow 教程获得的熟悉程度应足以开始阅读本书。
本书中,基本的数学知识,包括对 n 维张量、矩阵乘法等的理解,将在学习过程中极为宝贵。最后,你需要对学习前沿的机器学习技术充满热情,这些技术正为现代自然语言处理解决方案奠定基础。
下载示例代码文件
本书的代码包托管在 GitHub 上,网址是 https://github.com/thushv89/packt_nlp_tensorflow_2。我们还提供了其他来自我们丰富图书和视频目录的代码包,地址是 https://github.com/PacktPublishing/。赶快去看看吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。你可以在此下载:https://static.packt-cdn.com/downloads/9781838641351_ColorImages.pdf。
使用的约定
本书中使用了多种文本约定。
CodeInText:表示文本中的代码词、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“运行 pip install 命令后,你应该能在 Conda 环境中使用 Jupyter Notebook。”
一段代码按以下方式设置:
def layer(x, W, b):
# Building the graph
h = tf.nn.sigmoid(tf.matmul(x,W) + b) # Operation to perform
return h
任何命令行输入或输出均按以下方式书写:
<tf.Variable 'ref:0' shape=(3, 2) dtype=float32, numpy=
array([[-1., -9.],
[ 3., 10.],
[ 5., 11.]], dtype=float32)>
粗体:表示一个新术语或重要的词汇。你在屏幕上看到的词汇(例如在菜单或对话框中)也会像这样出现在文本中,例如:“在 TensorFlow 中自动构建计算图的功能被称为AutoGraph。”
警告或重要说明将以这种形式出现。
小贴士和技巧将以这种形式出现。
联系我们
我们欢迎读者的反馈。
一般反馈:发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书名。如果你对本书的任何方面有疑问,请通过questions@packtpub.com与我们联系。
勘误:虽然我们已经尽力确保内容的准确性,但错误仍然可能发生。如果你在本书中发现错误,请向我们报告。请访问 http://www.packtpub.com/submit-errata,选择你的书籍,点击“勘误提交表格”链接并填写详细信息。
盗版:如果你在互联网上发现任何我们作品的非法复制品,请提供该位置地址或网站名称。请通过copyright@packtpub.com与我们联系,并附上该材料的链接。
如果你有兴趣成为作者:如果你在某个领域具有专长,并且有兴趣编写或参与撰写一本书,请访问 http://authors.packtpub.com。
分享你的想法
一旦你读完了《使用 TensorFlow 进行自然语言处理(第二版)》,我们非常希望听到你的想法!请点击这里直接进入该书的亚马逊评论页面并分享你的反馈。
你的评论对我们以及技术社区都非常重要,能帮助我们确保提供优质的内容。
第一章:自然语言处理简介
自然语言处理(NLP)提供了一整套急需的工具和算法,用于理解和处理当今世界大量的非结构化数据。近年来,深度学习因其在许多 NLP 任务中的卓越表现被广泛采用,尤其是在图像分类、语音识别和真实感文本生成等具有挑战性的任务中。TensorFlow 是当前最直观高效的深度学习框架之一,能够实现这些惊人的成果。本书将帮助有志成为深度学习开发者的人,使用 NLP 和 TensorFlow 处理海量数据。本章涵盖以下内容:
-
什么是自然语言处理?
-
自然语言处理的任务
-
自然语言处理的传统方法
-
自然语言处理的深度学习方法
-
技术工具简介
本章将介绍 NLP 及本书的其他内容。我们将回答“什么是自然语言处理?”这一问题。同时,我们也将探讨一些 NLP 最重要的应用案例。我们还将讨论传统方法和近年来基于深度学习的 NLP 方法,包括全连接神经网络(FCNN)。最后,我们将总结本书的其他章节和将要使用的技术工具。
什么是自然语言处理?
根据分析公司 DOMO(www.domo.com/)的数据,到 2020 年,全球每人每秒产生 1.7MB 的数据,互联网活跃用户达到 46 亿。这些数据包括大约 50 万条推文和 3060 亿封邮件每天流通。这些数字在本书写作过程中仅有一个方向,那就是不断增长!在这些数据中,大量是非结构化的文本和语音数据,因为每天都有数十亿封邮件、社交媒体内容和电话被创建和拨打。
这些统计数据为我们定义 NLP 提供了良好的基础。简而言之,NLP 的目标是使机器理解我们口语和书面语言。此外,NLP 无处不在,已经成为人类生活的重要组成部分。虚拟助手(VAs),例如谷歌助手、Cortana、Alexa 和苹果 Siri,基本上是 NLP 系统。当你向虚拟助手询问“Can you show me a good Italian restaurant nearby?”时,涉及了许多 NLP 任务。首先,虚拟助手需要将语音转化为文本(即语音转文本)。接下来,它必须理解请求的语义(例如,识别最重要的关键词,如餐厅和意大利菜),并形成一个结构化的请求(例如,菜系 = 意大利,评分 = 3–5,距离 < 10 公里)。然后,虚拟助手必须根据地点和菜系过滤餐厅,并按照评分对餐厅进行排序。为了计算餐厅的总体评分,一个好的 NLP 系统可能会查看每个用户提供的评分和文本描述。最后,一旦用户到达餐厅,虚拟助手可能会帮助用户将菜单中的意大利语条目翻译成英语。这个例子表明,NLP 已经成为人类生活的一个不可或缺的部分。
应该理解的是,NLP 是一个极具挑战性的研究领域,因为单词和语义之间有着高度复杂的非线性关系,而且要将这些信息捕捉为稳健的数值表示更为困难。更糟糕的是,每种语言都有自己独特的语法、句法和词汇。因此,处理文本数据涉及各种复杂的任务,例如文本解析(例如,分词和词干提取)、形态学分析、词义消歧和理解语言的基础语法结构。例如,在这两句话中,I went to the bank 和 I walked along the river bank,词语bank有着完全不同的含义,因为它们使用的上下文不同。为了区分或(消歧)bank这个词,我们需要理解它所使用的上下文。机器学习已经成为 NLP 的一个关键推动力,帮助通过机器完成上述任务。以下是我们讨论的 NLP 中的一些重要任务:
自然语言处理的任务
自然语言处理(NLP)在现实世界中有着广泛的应用。一个好的 NLP 系统是能够执行多种 NLP 任务的系统。当你在谷歌搜索今天的天气,或者使用谷歌翻译查看“How are you?”用法语怎么说时,你依赖的正是 NLP 中的一部分任务。我们将在此列举一些最常见的任务,本书涵盖了大部分这些任务:
-
分词:分词是将文本语料库分割成原子单位(例如单词或字符)的任务。虽然对于像英语这样的语言来说,分词可能看起来微不足道,但它仍然是一个重要任务。例如,在日语中,单词之间并没有空格或标点符号作为分隔符。
-
词义消歧 (WSD):WSD 是识别单词正确意义的任务。例如,在句子 The dog barked at the mailman 和 Tree bark is sometimes used as a medicine 中,单词 bark 有两个不同的含义。WSD 对于问答等任务至关重要。
-
命名实体识别 (NER):NER 旨在从给定的文本或文本语料库中提取实体(例如,人名、地点、组织等)。例如,句子 John gave Mary two apples at school on Monday 将被转换为 [John]name 给了 [Mary]name [two]number 个苹果,在 [school]organization 上 [Monday]time。NER 在信息检索和知识表示等领域中是一个重要话题。
-
词性标注 (PoS) 标注:PoS 标注是将单词分配到其相应词性的任务。它可以是基本标签,如名词、动词、形容词、副词和介词,也可以是更细粒度的标签,如专有名词、普通名词、短语动词、动词等。Penn Treebank 项目是一个专注于 PoS 的流行项目,它定义了一个全面的 PoS 标签列表,详见
www.ling.upenn.edu/courses/ling001/penn_treebank_pos.html。 -
句子/摘要分类:句子或摘要(例如,电影评论)分类有许多应用场景,如垃圾邮件检测、新闻文章分类(例如,政治、科技和体育)以及产品评论评级(即正面或负面)。这一任务通过使用标注数据(即由人工标注的评论,带有正面或负面标签)训练分类模型来实现。
-
文本生成:在文本生成中,学习模型(例如神经网络)通过文本语料库(大量文本文件集合)进行训练,然后预测接下来的新文本。例如,语言建模可以通过使用现有的科幻故事进行训练,生成一个全新的科幻故事。
最近,OpenAI 发布了一个名为 OpenAI-GPT-2 的语言模型,它能够生成极为真实的文本。此外,这项任务在理解语言中起着非常重要的作用,有助于下游决策支持模型的快速启动。
-
问答系统 (QA):问答技术具有很高的商业价值,这些技术是聊天机器人和虚拟助手(例如,谷歌助手和苹果 Siri)的基础。许多公司已经采用了聊天机器人来提供客户支持。聊天机器人可以用来回答并解决简单的客户问题(例如,修改客户的月度手机套餐),这些问题可以在不需要人工干预的情况下解决。问答技术涉及到自然语言处理的许多其他方面,如信息检索和知识表示。因此,开发一个问答系统是非常困难的。
-
机器翻译 (MT):机器翻译是将源语言(例如,德语)的句子/短语转换为目标语言(例如,英语)的任务。这是一个非常具有挑战性的任务,因为不同的语言具有不同的句法结构,这意味着它并不是一种一对一的转换。此外,不同语言之间的词与词之间的关系可能是多对一、一对一、一对多或多对多的。这就是机器翻译文献中的词对齐问题。
最后,为了开发一个能够帮助人类处理日常任务的系统(例如,虚拟助手或聊天机器人),许多任务需要以无缝的方式进行协同。如我们在前面的例子中所见,用户问:“你能给我推荐一家附近的意大利餐厅吗?”时,多个不同的自然语言处理任务,如语音转文本、语义分析和情感分析、问答和机器翻译等,都需要完成。在图 1.1中,我们提供了一个分层分类法,将不同的自然语言处理任务分为几种不同的类型。将一个自然语言处理任务归类为某一单一类别是一个困难的任务。因此,您可以看到有些任务跨越了多个类别。我们将这些类别分为两大类:基于语言的(浅色背景,黑色文字)和基于问题的(深色背景,白色文字)。语言学分类有两类:句法(基于结构)和语义(基于意义)。基于问题的分类则有三类:预处理任务(在输入模型之前对文本数据进行处理的任务)、判别性任务(我们试图将输入文本分配到一个或多个预定义类别的任务)和生成性任务(我们试图生成新的文本输出的任务)。当然,这只是其中一种分类方法,但它展示了将一个具体的自然语言处理任务归入特定类别的难度。

图 1.1:自然语言处理中常见任务的分类,按更广泛的类别进行划分
在理解了自然语言处理的各种任务后,我们接下来将讨论如何借助机器来解决这些任务。我们将讨论传统方法和基于深度学习的方法。
自然语言处理的传统方法
传统或经典的 NLP 解决方法是一个包含几个关键步骤的顺序流程,并且它是一种统计方法。当我们仔细观察传统的 NLP 学习模型时,会发现一系列明确的任务在进行,比如通过去除不需要的数据来预处理数据,进行特征工程以获得文本数据的良好数值表示,使用训练数据学习机器学习算法,并为新颖的、未见过的数据进行预测。在这些任务中,特征工程是获取良好性能的最耗时和最关键的步骤。
了解传统方法
传统的 NLP 任务解决方法包括一系列不同的子任务。首先,文本语料库需要经过预处理,重点是减少词汇量和干扰。
我所说的干扰是指那些使算法无法捕捉到任务所需的关键信息的事物(例如,标点符号和停用词的去除)。
接下来是几个特征工程步骤。特征工程的主要目标是使算法的学习更加轻松。通常,特征是手动设计的,并且倾向于基于人类对语言的理解。特征工程对于经典的 NLP 算法至关重要,因此,表现最好的系统通常具有最精心设计的特征。例如,对于情感分类任务,你可以用一个语法树来表示一个句子,并为树中的每个节点/子树分配正面、负面或中立标签,从而将句子分类为正面或负面。此外,特征工程阶段还可以使用外部资源,如 WordNet(一个词汇数据库,可以提供关于不同单词如何相互关联的见解——例如,同义词),来开发更好的特征。我们很快会看到一种简单的特征工程技术,称为词袋模型。
接下来,学习算法使用获得的特征以及可选的外部资源来在给定任务上表现良好。例如,对于文本摘要任务,一个包含常见短语和简洁释义的平行语料库将是一个很好的外部资源。最后,进行预测。预测过程是直接的,你只需输入新数据,并通过学习模型将输入传递以获得预测标签。传统方法的整个过程如图 1.2所示:

图 1.2:经典 NLP 的一般方法
接下来,让我们讨论一个使用自然语言处理(NLP)生成足球比赛摘要的用例。
示例 – 生成足球比赛摘要
为了深入了解传统的自然语言处理(NLP)方法,我们考虑一个基于足球比赛统计数据的自动文本生成任务。我们有多个比赛统计数据集(例如,比分、罚球和黄牌)以及由记者为该比赛生成的相应文章,作为训练数据。我们还假设对于一场特定的比赛,我们有一个映射,将每个统计参数与该参数的摘要中最相关的短语对应起来。我们在这里的任务是,给定一场新的比赛,我们需要生成一篇自然流畅的比赛摘要。当然,这可以像从训练数据中找到最匹配的统计数据并检索相应的摘要一样简单。然而,也有更复杂和优雅的文本生成方式。
如果我们结合机器学习来生成自然语言,可能会执行一系列操作,如预处理文本、特征工程、学习和预测。
预处理:文本涉及的操作包括分词(例如,将“I went home”分割为“I”、“went”、“home”),词干提取(例如,将listened转化为listen),以及去除标点符号(例如,!和;),目的是减少词汇量(即特征),从而减少数据的维度。对于英语等语言来说,分词可能显得微不足道,因为单词是孤立的;然而,对于泰语、日语和中文等语言来说,情况并非如此,因为这些语言的词语并不是始终被清晰地分隔开来。接下来,需要理解的是,词干提取也并不是一个简单的操作。表面上看,词干提取似乎是一个简单的操作,依赖于一些简单的规则,例如去除动词后的ed(例如,listened的词干结果是listen);然而,开发一个好的词干提取算法需要的不仅仅是简单的规则库,因为某些词的词干提取是棘手的(例如,使用基于规则的词干提取,argued的词干结果是argu)。此外,正确进行词干提取所需的工作量在不同语言中可能有很大不同。
特征工程用于将原始文本数据转换为具有吸引力的数字表示,从而可以在这些数据上训练模型,例如,将文本转换为词袋模型(bag-of-words)表示,或者使用 n-gram 表示法,我们稍后会讨论。然而,请记住,最先进的经典模型依赖于更为复杂的特征工程技术。
以下是一些特征工程技术:
词袋模型(Bag-of-words):这是一种基于词频创建特征表示的特征工程技术。例如,我们考虑以下句子:
-
鲍勃去市场买了一些花
-
鲍勃买了花要送给玛丽
这两句话的词汇表是:
[“Bob”, “went”, “to”, “the”, “market”, “buy”, “some”, “flowers”, “bought”, “give”, “Mary”]
接下来,我们将为每个句子创建一个大小为 V(词汇表大小)的特征向量,表示词汇表中每个单词在句子中出现的次数。在这个例子中,句子的特征向量分别如下:
[1, 1, 2, 1, 1, 1, 1, 1, 0, 0, 0]
[1, 0, 2, 1, 0, 0, 0, 1, 1, 1, 1]
bag-of-words 方法的一个重要限制是它失去了上下文信息,因为单词的顺序不再被保留。
n-gram:这是一种将文本拆分为更小组件的特征工程技术,这些组件由 n 个字母(或单词)组成。例如,2-gram 将文本拆分为两个字母(或两个单词)的实体。考虑下面这个句子:
Bob 去市场买花了
该句子的字母级 n-gram 分解如下:
[“Bo”, “ob”, “b “, “ w”, “we”, “en”, ..., “me”, “e “,” f”, “fl”, “lo”, “ow”, “we”, “er”, “rs”]
基于单词的 n-gram 分解如下:
[“Bob went”, “went to”, “to the”, “the market”, ..., “to buy”, “buy some”, “some flowers”]
这种表示方法(字母级)的优点是,词汇表会显著小于我们使用单词作为大语料库特征时的情况。
接下来,我们需要对数据进行结构化处理,以便将其输入到学习模型中。例如,我们将使用形如(统计数据,解释该统计数据的短语)的数据元组,如下所示:
总进球数 = 4,“上半场结束时,两队各打入了 2 个进球,比赛为平局”
队伍 1 = 曼联,“比赛在曼联和巴塞罗那之间进行”
队伍 1 的进球数 = 5,“曼联成功打入了 5 个进球”
学习过程可能包括三个子模块:隐马尔可夫模型(HMM)、句子规划器和话语规划器。HMM 是一种递归模型,可以用于解决时间序列问题。例如,生成文本是一个时间序列问题,因为生成的单词顺序很重要。在我们的例子中,HMM 可以通过在统计语料库和相关短语上训练来学习建模语言(即生成有意义的文本)。我们将训练 HMM,使其能够在统计数据作为输入的情况下,生成相关的文本序列。一旦训练完成,HMM 就可以用于递归推理,我们从一个种子(例如统计数据)开始,预测描述的第一个单词,然后使用预测的单词生成下一个单词,依此类推。
接下来,我们可以使用一个句子规划器来修正模型可能引入的任何语法或语法错误。例如,句子规划器可能会将短语 I go house 转换为 I go home。为此,它可以使用一个包含正确表达方式的规则数据库,例如在动词和单词 house 之间需要一个介词。
使用 HMM 和句子规划器,我们将得到语法正确的句子。接下来,我们需要以一种方式将这些短语整理起来,使得由这些短语构成的文章既易于阅读又流畅。例如,考虑以下三句话,巴塞罗那队的 10 号球员在下半场进了一个球,巴塞罗那与曼联对阵,曼联的 3 号球员在上半场领到一张黄牌;将这些句子按此顺序排列并没有太大意义。我们希望按以下顺序排列它们:巴塞罗那与曼联对阵,曼联的 3 号球员在上半场领到一张黄牌,巴塞罗那队的 10 号球员在下半场进了一个球。为了做到这一点,我们使用话语规划器;话语规划器可以组织一组信息,使其意义能够正确传达。
现在,我们可以获得一组任意的测试统计数据,并通过遵循上述工作流程来获取一篇解释这些统计数据的文章,如图 1.3所示:

图 1.3:解决语言建模任务的经典方法
在这里,需要注意的是,这只是一个非常高层次的解释,仅涵盖了最有可能出现在传统自然语言处理(NLP)中的一些主要通用组件。具体细节在很大程度上会根据我们希望解决的特定应用而有所不同。例如,某些任务可能需要额外的应用特定的关键组件(例如,机器翻译中的规则库和对齐模型)。然而,在本书中,我们不会过多强调这些细节,因为我们的主要目标是讨论更现代的自然语言处理方法。
传统方法的缺点
让我们列举出传统方法的几个关键缺点,这将为讨论深度学习的动机奠定良好的基础:
-
传统 NLP 中的预处理步骤需要在文本中嵌入的潜在有用信息(例如,标点符号和时态信息)之间做出权衡,以便通过减少词汇量使学习变得可行。尽管在现代基于深度学习的解决方案中仍然使用预处理,但由于深度网络的大量表示能力以及它们优化高端硬件(如 GPU)的能力,这对于它们来说并不像传统 NLP 工作流中那样至关重要。
-
特征工程是一个非常劳动密集的过程。为了设计一个可靠的系统,需要设计好的特征。这个过程可能非常繁琐,因为不同的特征空间需要进行广泛的探索和评估。此外,为了有效地探索鲁棒特征,还需要领域专长,而对于某些 NLP 任务来说,这种专长可能稀缺且成本高昂。
-
要使其表现良好,需要各种外部资源,而且可自由获取的资源并不多。这些外部资源通常由手动创建的信息存储在大型数据库中。为某个特定任务创建这样的资源可能需要数年时间,具体取决于任务的复杂性(例如,机器翻译规则库)。
现在,让我们讨论一下深度学习如何帮助解决 NLP 问题。
深度学习在自然语言处理中的应用
我认为可以安全地说,深度学习革新了机器学习,特别是在计算机视觉、语音识别以及当然,NLP(自然语言处理)等领域。深度模型在机器学习的许多领域中引发了一波范式的转变,因为深度模型从原始数据中学习到了丰富的特征,而不是依赖于有限的人为工程特征。这导致了烦人的、昂贵的特征工程变得过时。通过这一点,深度模型使得传统的工作流程更加高效,因为深度模型同时进行特征学习和任务学习。此外,由于深度模型中大量的参数(即权重),它可以涵盖比人工工程特征更多的特征。然而,由于模型的可解释性差,深度模型被视为黑盒。例如,理解深度模型在给定问题中学习到的“如何”和“什么”特征仍然是一个活跃的研究领域。但重要的是要理解,越来越多的研究正在专注于“深度学习模型的可解释性”。
深度神经网络本质上是一种人工神经网络,具有输入层、中间许多互联的隐藏层,最后是输出层(例如分类器或回归器)。正如你所看到的,这形成了一个从原始数据到预测的端到端模型。这些中间的隐藏层赋予了深度模型强大的能力,因为它们负责从原始数据中学习良好的特征,最终成功地完成任务。现在,让我们简要了解深度学习的历史。
深度学习的历史
让我们简要讨论深度学习的起源,以及这个领域是如何发展成机器学习中非常有前景的技术的。1960 年,Hubel 和 Weisel 进行了一项有趣的实验,发现猫的视觉皮层由简单细胞和复杂细胞组成,并且这些细胞以层级形式组织。除此之外,这些细胞对不同的刺激反应不同。例如,简单细胞对各种不同方向的边缘有反应,而复杂细胞对空间变化(例如,边缘的方向)不敏感。这激发了人们希望在机器中复制类似行为的动机,从而产生了人工神经网络的概念。
在随后的几年里,神经网络引起了许多研究人员的关注。1965 年,一种通过数据处理组法(GMDH)训练的神经网络,基于 Rosenblatt 的著名感知机,由 Ivakhnenko 等人提出。随后,在 1979 年,福岛提出了Neocognitron,为深度模型的最著名变体之一——卷积神经网络(CNNs)播下了种子。与始终接受 1D 输入的感知机不同,Neocognitron 能够通过卷积操作处理 2D 输入。
人工神经网络通过反向传播误差信号来优化网络参数,方法是计算给定层权重相对于损失的梯度。然后,通过将权重推向梯度的反方向来更新它们,以最小化损失。对于距离输出层更远的层(即计算损失的地方),算法使用链式法则来计算梯度。使用多层链式法则导致了一个实际问题,称为梯度消失问题,严格限制了神经网络的层数(深度)。距离输入层较近的层(即距离输出层较远的层)的梯度非常小,导致模型训练提前停止,从而导致欠拟合的模型。这就是梯度消失现象。
然后,在 2006 年,人们发现通过最小化重建误差(通过尝试将输入压缩为更低的维度,然后将其重建回原始维度)对深度神经网络进行预训练,能够为网络的每一层提供一个良好的初始起点;这使得从输出层到输入层的梯度能够保持一致流动。这本质上使得神经网络模型能够有更多层,而不会出现梯度消失的负面影响。此外,这些更深的模型能够在许多任务中超越传统的机器学习模型,尤其是在计算机视觉方面(例如,MNIST 手写数字数据集的测试准确率)。随着这一突破,深度学习成为了机器学习领域的流行词。
2012 年,AlexNet(由 Alex Krizhevsky、Ilya Sutskever 和 Geoffrey Hinton 创建的深度卷积神经网络)赢得了 2012 年大规模视觉识别挑战赛(LSVRC),相较于之前的最佳成绩,错误率下降了 10%。在此期间,语音识别取得了进展,使用深度神经网络的最新语音识别技术报告了很高的准确率。此外,人们开始意识到图形处理单元(GPU)能够提供更多的并行计算,从而相比中央处理单元(CPU)能够更快地训练更大、更深的网络。
深度模型通过更好的模型初始化技术(例如 Xavier 初始化)得到了进一步的改进,这使得耗时的预训练变得不再必要。同时,引入了更好的非线性激活函数,如修正线性单元(ReLUs),缓解了深层模型中梯度消失的困境。更好的优化(或学习)技术,如 Adam 优化器,自动调整神经网络模型中成千上万个参数的个体学习率,这在许多不同的机器学习领域(如目标分类和语音识别)中重新定义了最先进的性能。这些进展还使得神经网络模型能够拥有大量的隐藏层。增加隐藏层的数量(即使神经网络更深)是神经网络模型相比其他机器学习模型显著提高性能的主要因素之一。此外,更好的中间正则化方法,如批量归一化层,也提高了深度网络在许多任务中的表现。
后来,甚至更深的模型,如 ResNets、Highway Nets 和 Ladder Nets 被引入,这些模型拥有数百层和数十亿个参数。借助各种经验性和理论启发的技术,实现如此巨大的层数成为可能。例如,ResNets 使用捷径连接或跳跃连接,将远距离的层连接起来,从而最小化了前面提到的层与层之间梯度的消失问题。
深度学习和自然语言处理的现状
自 2000 年初以来,许多不同的深度模型问世。尽管它们有相似之处,例如都使用输入和参数的非线性变换,但细节可以有很大差异。例如,CNN可以直接从二维数据(例如 RGB 图像)中学习,而多层感知机模型则要求输入展开为一维向量,导致重要的空间信息丢失。
在处理文本时,作为文本的最直观解释之一是将其视为字符序列,因此学习模型应能够进行时间序列建模,从而需要对过去的记忆。为了解释这一点,可以考虑语言建模任务;单词cat的下一个单词应该与单词climbed的下一个单词不同。一个具有这种能力的流行模型被称为递归神经网络(RNN)。我们将在第六章,递归神经网络中,通过互动练习了解 RNN 是如何实现这一目标的。
需要注意的是,记忆并不是学习模型固有的简单操作。相反,记忆的持久化方式需要精心设计。
此外,术语memory不应与非顺序深度网络的学习权重混淆,后者仅查看当前输入,而顺序模型(例如 RNN)会查看学习的权重以及序列中的前一个元素,以预测下一个输出。
RNN 的一个突出缺点是它们无法记住超过几个(大约七个)时间步,因此缺乏长期记忆。长短期记忆(LSTM)网络是 RNN 的扩展,封装了长期记忆。因此,LSTM 在如今通常优于标准的 RNN。我们将在第七章,理解长短期记忆网络中深入了解它们,以便更好地理解它们。
最终,Google 最近引入了一种被称为Transformer的模型,它在许多自然语言处理任务中超过了许多先前的最先进模型,如 LSTM。此前,递归模型(如 LSTM)和卷积模型(如 CNN)主导了 NLP 领域。例如,CNN 被用于句子分类、机器翻译和序列到序列的学习任务。然而,Transformer 使用的是完全不同的方法,它既不使用递归也不使用卷积,而是采用了注意力机制。注意力机制使得模型能够一次性查看整个序列,以产生单一的输出。例如,考虑句子“The animal didn’t cross the road because it was tired。”在生成“it”一词的中间表示时,模型会从学习中知道“it”指代的是“animal”。注意力机制使 Transformer 模型能够学习这种关系。这个能力是标准的递归模型或卷积模型无法复制的。我们将在第十章,Transformer和第十一章,使用 Transformer 进行图像字幕生成中进一步探讨这些模型。
总结来说,我们可以将深度网络主要分为三类:非序列模型,这类模型在训练和预测时每次只处理一个输入(例如,图像分类);序列模型,这类模型处理任意长度的输入序列(例如,文本生成,其中每个单词是一个输入);最后是基于注意力的模型,它们一次性查看整个序列,例如 Transformer、BERT 和 XLNet,这些是基于 Transformer 架构的预训练模型。我们可以将非序列模型(也称为前馈模型)进一步分为深度模型(大约少于 20 层)和非常深的网络(可以超过数百层)。序列模型则分为短期记忆模型(例如 RNNs),这些模型只能记住短期模式,以及长期记忆模型,它们能记住更长时间的模式。在图 1.4中,我们概述了上述分类。你现在不需要完全理解这些不同的深度学习模型,但它们展示了深度学习模型的多样性:

图 1.4:常用深度学习方法的一般分类,分为几类
现在,让我们迈出第一步,理解神经网络的内部工作原理。
理解一个简单的深度模型——一个全连接神经网络
现在,让我们更仔细地看一下深度神经网络,以便更好地理解。虽然深度模型有许多不同的变体,但我们先来看其中最早的模型之一(可以追溯到 1950-60 年代),即全连接神经网络(FCNN),有时也叫做多层感知器。图 1.5展示了一个标准的三层 FCNN。
FCNN 的目标是将输入(例如,图像或句子)映射到某个标签或注释(例如,图像的物体类别)。这是通过使用输入 x 来计算 h —— x 的隐藏表示 —— 来实现的,使用如
这样的变换;这里,W 和 b 分别是 FCNN 的权重和偏置,
是 sigmoid 激活函数。神经网络在每一层使用非线性激活函数。sigmoid 激活就是一种这样的激活函数。它是对一层输出的逐元素变换,其中 x 的 sigmoid 输出由
给出。接下来,在 FCNN 的顶部放置一个分类器,它可以利用隐藏层中学习到的特征来对输入进行分类。分类器是 FCNN 的一部分,实际上是另一个隐藏层,具有一些权重 W[s] 和偏置 b[s]。另外,我们可以计算 FCNN 的最终输出为
。例如,可以使用 softmax 分类器来处理多标签分类问题。它提供了分类器层输出分数的归一化表示。也就是说,它将为分类器层中的各个类别生成一个有效的概率分布。标签被视为具有最高 softmax 值的输出节点。然后,通过此方法,我们可以定义一个分类损失,该损失通过预测的输出标签与实际输出标签之间的差异来计算。一个这样的损失函数的例子是均方损失。如果你不理解损失函数的具体细节也没关系。我们将在后续章节中讨论其中的许多内容。接下来,神经网络的参数 W、b、W[s] 和 b[s] 将通过标准的随机优化器(例如,随机梯度下降)进行优化,以减少所有输入的分类损失。图 1.5 展示了这一段中解释的过程,适用于三层 FCNN。我们将在 第三章《Word2vec——学习词嵌入》中,逐步讲解如何将这样的模型应用于 NLP 任务。

图 1.5:一个完全连接的神经网络(FCNN)示例
让我们来看一个使用神经网络进行情感分析任务的例子。假设我们有一个数据集,其中输入是一句关于电影的正面或负面评价,且对应的标签表示该句子是否真的为正面(1)或负面(0)。然后,我们得到一个测试数据集,其中包含单句的电影评论,任务是将这些新句子分类为正面或负面。
我们可以通过遵循以下工作流程来使用神经网络(可以是深度或浅层的,取决于任务的难度)来完成此任务:
-
对句子按单词进行分词。
-
将句子转换为固定大小的数字表示(例如,词袋表示)。需要固定大小的表示,因为全连接神经网络需要固定大小的输入。
-
将数字输入传递给神经网络,预测输出(正面或负面),并与真实目标进行比较。
-
使用所需的损失函数优化神经网络。
在本节中,我们更详细地探讨了深度学习。我们回顾了 NLP 的历史和当前状态。最后,我们更详细地讨论了全连接神经网络(一种深度学习模型)。
现在,既然我们已经介绍了自然语言处理(NLP),它的任务,以及这些方法如何随着时间的推移不断发展,我们不妨稍作停顿,看看本书剩余部分所需的技术工具。
技术工具介绍
本节将向你介绍在接下来的章节练习中将使用的技术工具。首先,我们将简要介绍所提供的主要工具。接下来,我们将提供如何安装每个工具的粗略指南,并附上官方网站提供的详细指南的超链接。此外,我们还将分享一些确保工具正确安装的提示。
工具描述
我们将使用 Python 作为编程/脚本语言。Python 是一种非常多用途、易于设置的编程语言,广泛应用于科学和机器学习社区。
此外,还有许多为 Python 构建的科学库,涵盖了从深度学习到概率推理再到数据可视化等多个领域。TensorFlow 就是其中一个在深度学习社区中广为人知的库,提供了许多基本和高级操作,适用于深度学习。接下来,我们将在所有练习中使用 Jupyter Notebook,因为它提供了比使用 Python 脚本更丰富和互动的编程环境。我们还将使用 pandas、NumPy 和 scikit-learn——这三个流行的 Python 库——进行各种杂项任务,如数据预处理。另一个我们将用于处理文本相关操作的库是 NLTK——Python 自然语言工具包。最后,我们将使用 Matplotlib 进行数据可视化。
安装 Anaconda 和 Python
Python 在 Windows、macOS 或 Linux 等常用操作系统中安装起来非常方便。我们将使用 Anaconda 来设置 Python,因为它会为 Python 及其必需的库做所有繁琐的设置工作。
安装 Anaconda,请按照以下步骤操作:
-
从
www.continuum.io/downloads下载 Anaconda。 -
选择适合的操作系统并下载 Python 3.7。
-
按照
docs.continuum.io/anaconda/install/上的说明安装 Anaconda。
要检查 Anaconda 是否正确安装,请打开一个终端窗口(Windows 中的命令提示符),然后运行以下命令:
conda --version
如果安装正确,当前 Anaconda 发行版的版本应该显示在终端中。
创建一个 Conda 环境
Anaconda 的一个吸引人的特性之一是它允许你创建多个 Conda 或虚拟环境。每个 Conda 环境可以有自己的环境变量和 Python 库。例如,可以创建一个 Conda 环境来运行 TensorFlow 1.x,而另一个可以运行 TensorFlow 2.x。这很棒,因为它允许你将开发环境与主机的 Python 安装中发生的任何更改分开。然后,你可以根据需要激活或取消激活 Conda 环境。
要创建一个 Conda 环境,请按照以下说明操作:
-
在终端窗口中运行 Conda 并创建
-n packt.nlp.2 python=3.7,使用命令conda create -n packt.nlp.2 python=3.7。 -
更改目录(
cd)到项目目录。 -
输入
activate packt.nlp.2在终端中激活新的 Conda 环境。如果成功激活,你应该在终端的用户提示之前看到(packt.nlp.2)。 -
使用以下选项之一安装所需的库。
-
如果有 GPU,使用
pip install -r requirements-base.txt -r requirements-tf-gpu.txt -
如果没有 GPU,使用
pip install -r requirements-base.txt -r requirements-tf.txt
接下来,我们将讨论 TensorFlow GPU 支持的一些先决条件。
TensorFlow(GPU)软件要求
如果你正在使用 TensorFlow GPU 版本,则需要满足诸如安装 CUDA 11.0 的特定软件要求。详细列表请参见www.tensorflow.org/install/gpu#software_requirements。
访问 Jupyter Notebook
运行完 pip install 命令后,你应该可以在 Conda 环境中使用 Jupyter Notebook。要检查 Jupyter Notebook 是否安装正确并且可访问,请按照以下步骤操作:
-
打开一个终端窗口。
-
如果还没有激活
packt.nlp.2Conda 环境,请运行activate packt.nlp.2 -
运行命令:
jupyter notebook
应该会打开一个看起来像图 1.6的新浏览器窗口:

图 1.6: 成功安装 Jupyter Notebook
验证 TensorFlow 安装
在本书中,我们使用的是 TensorFlow 2.7.0。重要的是,你需要安装本书中使用的确切版本,因为 TensorFlow 在从一个版本迁移到另一个版本时可能会发生许多变化。如果一切顺利,TensorFlow 应该安装在 packt.nlp.2 Conda 环境中。如果你在安装 TensorFlow 时遇到问题,可以在 www.tensorflow.org/install 找到安装指南和故障排除说明。
为了检查 TensorFlow 是否正确安装,按照以下步骤操作:
-
在 Windows 中打开命令提示符,或在 Linux 或 macOS 中打开终端。
-
激活
packt.nlp.2Conda 环境。 -
输入
python进入 Python 提示符。你现在应该会看到下面显示的 Python 版本。确保你使用的是 Python 3。 -
接下来,输入以下命令:
import tensorflow as tf print(tf. version )
如果一切顺利,你应该不会遇到任何错误(如果你的计算机没有专用 GPU,可能会有警告,但可以忽略它们),并且应该显示 TensorFlow 版本 2.7.0。
许多基于云的计算平台也可用,你可以在这些平台上设置自己的机器,并进行各种定制(操作系统、GPU 卡类型、GPU 卡数量等)。许多人正在迁移到这样的云服务,原因包括以下几个优势:
-
更多定制选项
-
更少的维护工作
-
无基础设施要求
一些流行的基于云的计算平台如下:
-
Google Colab:
colab.research.google.com/ -
谷歌云平台(GCP):
cloud.google.com/ -
亚马逊云服务(AWS):
aws.amazon.com/
Google Colab 是一个优秀的基于云的平台,允许你编写 TensorFlow 代码并免费在 CPU/GPU 硬件上执行。
总结
在本章中,我们广泛探索了 NLP,以便了解构建一个好的基于 NLP 的系统所涉及的任务类型。首先,我们解释了为什么需要 NLP,然后讨论了 NLP 的各种任务,以便大致了解每个任务的目标以及成功完成这些任务的难度。
然后,我们研究了传统的 NLP 解决方法,并通过生成足球比赛的运动总结的示例深入了解了工作流程。我们看到传统方法通常涉及繁琐且耗时的特征工程。例如,为了检查生成短语的正确性,我们可能需要为该短语生成一个解析树。接着,我们讨论了深度学习带来的范式转变,并了解了深度学习如何让特征工程的步骤变得过时。我们从时光旅行开始,回到深度学习和人工神经网络的起源,一直到现代具有数百个隐藏层的大型网络。之后,我们通过一个简单的例子展示了深度模型——多层感知机模型——以便理解这种模型中的数学奇迹(当然是从表面理解!)。
在掌握传统与现代自然语言处理(NLP)方法的基础上,我们讨论了理解本书所涵盖主题的路线图,从学习词嵌入到强大的 LSTM,再到最前沿的 Transformer!最后,我们通过安装 Python、scikit-learn、Jupyter Notebook 和 TensorFlow,设置了我们的虚拟 Conda 环境。
在下一章,你将学习 TensorFlow 的基础知识。到章末时,你应该能轻松编写一个简单的算法,该算法可以接收输入,通过定义的函数转换输入并输出结果。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,结识志同道合的人,与超过 1000 名成员一起学习:packt.link/nlp

第二章:理解 TensorFlow 2
本章将让你深入理解 TensorFlow。它是一个开源的分布式数值计算框架,也是我们将实现所有练习的主要平台。本章涵盖以下主题:
-
什么是 TensorFlow?
-
TensorFlow 的构建模块(例如,变量和操作)
-
使用 Keras 构建模型
-
实现我们的第一个神经网络
我们将通过定义一个简单的计算并尝试使用 TensorFlow 来计算它,开始学习 TensorFlow。完成这一步后,我们将研究 TensorFlow 如何执行这个计算。这将帮助我们理解框架是如何创建一个计算图来计算输出,并执行该图以获得期望的输出。接着,我们将通过使用一个类比——一个高级咖啡馆是如何运作的——来深入了解 TensorFlow 架构如何运作,了解 TensorFlow 如何执行任务。然后,我们将回顾 TensorFlow 1 的工作方式,以便更好地理解 TensorFlow 2 所提供的惊人功能。请注意,当我们单独使用“TensorFlow”这个词时,我们指的是 TensorFlow 2。如果我们提到 TensorFlow 1,则会特别说明。
在对 TensorFlow 的操作有了很好的概念性和技术性理解之后,我们将探讨框架提供的一些重要计算。首先,我们将了解如何在 TensorFlow 中定义各种数据结构,例如变量和张量,并且我们还会看到如何通过数据管道读取输入。接着,我们将学习一些与神经网络相关的操作(例如,卷积操作、定义损失和优化)。
最后,我们将在一个令人兴奋的练习中应用这些知识,实施一个可以识别手写数字图像的神经网络。你还将看到,通过使用像 Keras 这样的高级子模块,你可以非常快速和轻松地实现或原型化神经网络。
什么是 TensorFlow?
在 第一章,自然语言处理简介 中,我们简要讨论了什么是 TensorFlow。现在让我们更仔细地了解它。TensorFlow 是由 Google 发布的一个开源分布式数值计算框架,主要目的是缓解实现神经网络时的痛苦细节(例如,计算神经网络权重的导数)。TensorFlow 通过使用 计算统一设备架构(CUDA),进一步提供了高效的数值计算实现,CUDA 是 NVIDIA 推出的并行计算平台(关于 CUDA 的更多信息,请访问 blogs.nvidia.com/blog/2012/09/10/what-is-cuda-2/)。TensorFlow 的 应用程序编程接口(API)可以在 www.tensorflow.org/api_docs/python/tf/all_symbols 查到,显示了 TensorFlow 提供了成千上万的操作,让我们的生活更轻松。
TensorFlow 不是一夜之间开发出来的。这是由一群有才华、心地善良的开发者和科学家的坚持努力的结果,他们希望通过将深度学习带给更广泛的受众来有所改变。如果你感兴趣,可以查看 TensorFlow 的代码,地址是 github.com/tensorflow/tensorflow。目前,TensorFlow 拥有约 3,000 名贡献者,并且已经有超过 115,000 次提交,每天都在不断发展,变得越来越好。
开始使用 TensorFlow 2
现在让我们通过一个代码示例来学习 TensorFlow 框架中的一些基本组件。我们来编写一个执行以下计算的示例,这是神经网络中非常常见的操作:

这个计算涵盖了全连接神经网络中单个层发生的操作。这里 W 和 x 是矩阵,b 是向量。然后,“.”表示点积。sigmoid 是一个非线性变换,给定以下方程:

我们将逐步讨论如何通过 TensorFlow 来进行此计算。
首先,我们需要导入 TensorFlow 和 NumPy。NumPy 是另一个科学计算框架,提供了各种数学和其他操作来处理数据。在运行任何与 TensorFlow 或 NumPy 相关的操作之前,导入它们是必不可少的:
import tensorflow as tf
import numpy as np
首先,我们将编写一个函数,该函数可以接收 x、W 和 b 作为输入,并为我们执行这个计算:
def layer(x, W, b):
# Building the graph
h = tf.nn.sigmoid(tf.matmul(x,W) + b) # Operation to perform
return h
接下来,我们添加一个名为 tf.function 的 Python 装饰器,如下所示:
@tf.function
def layer(x, W, b):
# Building the graph
h = tf.nn.sigmoid(tf.matmul(x,W) + b) # Operation to perform
return h
简单来说,Python 装饰器就是另一个函数。Python 装饰器提供了一种干净的方式来调用另一个函数,每次调用被装饰的函数时。换句话说,每次调用 layer() 函数时,都会调用 tf.function()。这可以用于多种目的,例如:
-
记录函数中的内容和操作
-
验证另一个函数的输入和输出
当 layer() 函数通过 tf.function() 时,TensorFlow 会追踪函数中的内容(换句话说,就是操作和数据),并自动构建计算图。
计算图(也称为数据流图)构建一个 DAG(有向无环图),显示程序需要什么样的输入,以及需要进行什么样的计算。
在我们的示例中,layer() 函数通过使用输入 x、W 和 b 以及一些变换或操作(如 + 和 tf.matmul())来生成 h:

图 2.1:客户端的计算图
如果我们看一个有向无环图(DAG)的类比,假设输出是一个 蛋糕,那么 图 就是做这个蛋糕的食谱,其中包含 原料(也就是输入)。
在 TensorFlow 中自动构建计算图的特性被称为 AutoGraph。AutoGraph 不仅查看传递函数中的操作,还会仔细检查操作的流动。这意味着你可以在函数中使用 if 语句或 for/while 循环,AutoGraph 会在构建图时处理这些情况。你将在下一节中看到更多关于 AutoGraph 的内容。
在 TensorFlow 1.x 中,用户需要显式地实现计算图。这意味着用户不能像平常那样编写典型的 Python 代码,使用 if-else 语句或 for 循环,而必须使用特定的 TensorFlow 操作,如 tf.cond() 和 tf.control_dependencies(),来显式地控制操作的流。这是因为,与 TensorFlow 2.x 不同,TensorFlow 1.x 在你调用操作时并不会立即执行它们。相反,在定义它们后,需要通过 TensorFlow Session 上下文显式执行。例如,在 TensorFlow 1 中运行以下代码时,
h = tf.nn.sigmoid(tf.matmul(x,W) + b)
h 在 Session 上下文中执行之前不会有任何值。因此,h 不能像其他 Python 变量一样处理。如果你不理解 Session 是如何工作的,不要担心,我们将在接下来的章节中讨论它。
接下来,你可以立即使用这个函数,方法如下:
x = np.array([[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]],dtype=np.float32)
这里,x 是一个简单的 NumPy 数组:
init_w = tf.initializers.RandomUniform(minval=-0.1, maxval=0.1)(shape=[10,5])
W = tf.Variable(init_w, dtype=tf.float32, name='W')
init_b = tf.initializers.RandomUniform()(shape=[5])
b = tf.Variable(init_b, dtype=tf.float32, name='b')
W 和 b 是使用 tf.Variable 对象定义的 TensorFlow 变量。W 和 b 存储张量。张量本质上是一个 n 维数组。例如,一维向量或二维矩阵都称为 张量。tf.Variable 是一个可变结构,这意味着存储在该变量中的张量的值可以随时间变化。例如,变量用于存储神经网络的权重,这些权重在模型优化过程中会发生变化。
另外,请注意,对于 W 和 b,我们提供了一些重要的参数,例如以下内容:
init_w = tf.initializers.RandomUniform(minval=-0.1, maxval=0.1)(shape=[10,5])
init_b = tf.initializers.RandomUniform()(shape=[5])
这些被称为变量初始化器,是会被初始赋值给 W 和 b 变量的张量。变量必须提供一个初始值。在这里,tf.initializers.RandomUniform 表示我们在 minval (-0.1) 和 maxval (0.1) 之间均匀地抽取值并赋给张量。TensorFlow 提供了许多不同的初始化器(www.tensorflow.org/api_docs/python/tf/keras/initializers)。在定义初始化器时,定义初始化器的 shape(形状)属性也非常重要。shape 属性定义了输出张量的每个维度的大小。例如,如果 shape 是 [10, 5],这意味着它将是一个二维结构,在轴 0(行)上有 10 个元素,在轴 1(列)上有 5 个元素:
h = layer(x,W,b)
最后,h 通常被称为 TensorFlow 张量。TensorFlow 张量是一个不可变结构。一旦一个值被赋给 TensorFlow 张量,它就不能再被更改。
正如你所看到的,“张量”(tensor)这个术语有两种使用方式:
-
要引用一个 n 维数组
-
要引用 TensorFlow 中的不可变数据结构
对于这两者,底层的概念是相同的,因为它们都持有一个 n 维的数据结构,只是在使用的上下文上有所不同。我们将在讨论中交替使用这个术语来指代这些结构。
最后,你可以立即看到 h 的值,通过以下代码:
print(f"h = {h.numpy()}")
这将给出:
h = [[0.7027744 0.687556 0.635395 0.6193934 0.6113584]]
numpy() 函数从 TensorFlow 张量对象中获取 NumPy 数组。完整的代码如下。章节中的所有代码示例都可以在 ch2 文件夹中的 tensorflow_introduction.ipynb 文件中找到:
@tf.function
def layer(x, W, b):
# Building the graph
h = tf.nn.sigmoid(tf.matmul(x,W) + b) # Operation to be performed
return h
x = np.array([[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]], dtype=np.float32)
# Variable
init_w = tf.initializers.RandomUniform(minval=-0.1, maxval=0.1)(shape=[10,5])
W = tf.Variable(init_w, dtype=tf.float32, name='W')
# Variable
init_b = tf.initializers.RandomUniform()(shape=[5])
b = tf.Variable(init_b, dtype=tf.float32, name='b')
h = layer(x,W,b)
print(f"h = {h.numpy()}")
供以后参考,我们称这个示例为 sigmoid 示例。
正如你已经看到的,定义 TensorFlow 计算图并执行它是非常“Pythonic”的。这是因为 TensorFlow 执行其操作是“急切的”(eager),即在调用layer()函数后立即执行。这是 TensorFlow 中一种特殊模式,称为 急切执行 模式。在 TensorFlow 1 中这是一个可选模式,但在 TensorFlow 2 中已经成为默认模式。
还请注意,接下来的两个章节将会比较复杂且技术性较强。然而,如果你不能完全理解所有内容也不用担心,因为接下来的解释将通过一个更易于理解且全面的实际示例进行补充,这个示例将解释我们改进过的新餐厅 Café Le TensorFlow 2 中如何完成一个订单。
TensorFlow 2 架构 – 图构建过程中发生了什么?
现在,让我们来了解当你执行 TensorFlow 操作时,TensorFlow 会做些什么。
当你调用一个由 tf.function() 装饰的函数时,比如 layer() 函数,后台会发生很多事情。首先,TensorFlow 会追踪函数中所有发生的 TensorFlow 操作,并自动构建计算图。
实际上,tf.function() 会返回一个在调用时执行已构建数据流图的函数。因此,tf.function() 是一个多阶段的过程,它首先构建数据流图,然后执行它。此外,由于 TensorFlow 跟踪函数中的每一行代码,如果发生问题,TensorFlow 可以指明导致问题的确切行。
在我们的 Sigmoid 示例中,计算图或数据流图看起来像图 2.2。图的单个元素或顶点称为节点。这个图中有两种主要类型的对象:操作和张量。在前面的示例中,tf.nn.sigmoid 是一个操作,h 是一个张量:

图 2.2:客户端的计算图
上述图展示了操作的顺序以及输入如何流经这些操作。
请记住,tf.function() 或 AutoGraph 并不是一个万能的解决方案,不能将任何使用 TensorFlow 操作的任意 Python 函数转换为计算图;它有其局限性。例如,当前版本无法处理递归调用。要查看 eager 模式的完整功能列表,请参考以下链接:github.com/sourcecode369/tensorflow-1/blob/master/tensorflow/python/autograph/g3doc/reference/limitations.md。
现在我们知道 TensorFlow 擅长创建一个包含所有依赖和操作的漂亮计算图,这样它就能准确知道数据如何、何时以及在哪里流动。然而,我们还没有完全回答这个图是如何执行的。事实上,TensorFlow 在幕后做了很多工作。例如,图可能会被划分成子图,并进一步拆分成更细的部分,以实现并行化。这些子图或部分将被分配给执行指定任务的工作进程。
TensorFlow 架构——执行图时发生了什么?
计算图使用 tf.GraphDef 协议来标准化数据流图并将其发送到分布式主节点。分布式主节点将在单进程环境中执行实际的操作执行和参数更新。在分布式环境中,主节点将把这些任务委派给工作进程/设备并管理这些工作进程。tf.GraphDef 是特定于 TensorFlow 的图的标准化表示。分布式主节点可以看到图中的所有计算,并将计算分配到不同的设备(例如,不同的 GPU 和 CPU)。TensorFlow 操作有多个内核。内核是特定于设备的某个操作的实现。例如,tf.matmul() 函数会根据是在 CPU 还是 GPU 上运行进行不同的实现,因为在 GPU 上可以通过更多的并行化来实现更好的性能。
接下来,计算图将被分解为子图,并由分布式主节点进行修剪。尽管在我们的示例中,分解图 2.2中的计算图看起来过于简单,但在实际应用中,计算图可能会在包含多个隐藏层的解决方案中呈指数级增长。此外,为了更快地获得结果(例如,在多设备环境中),将计算图分解成多个部分,并去除任何冗余计算,变得尤为重要。
执行图或子图(如果图被分为多个子图)称为单个任务,每个任务被分配给一个工作进程(该进程可以是一个单独的进程或一个完整的设备)。这些工作进程可以在多进程设备中作为单个进程运行(例如,多核 CPU),或在不同的设备上运行(例如,CPU 和 GPU)。在分布式环境中,我们会有多个工作进程执行任务(例如,多个工作进程在不同的数据批次上训练模型)。相反,我们只有一组参数。那么,多个工作进程如何管理更新同一组参数呢?
为了解决这个问题,会有一个工作进程被视为参数服务器,并持有参数的主要副本。其他工作进程将复制这些参数,更新它们,然后将更新后的参数发送回参数服务器。通常,参数服务器会定义一些解决策略来处理来自多个工作进程的多个更新(例如,取平均值)。这些细节的提供是为了帮助你理解 TensorFlow 中涉及的复杂性。然而,我们的书籍将基于在单进程/单工作进程设置中使用 TensorFlow。在这种设置下,分布式主节点、工作进程和参数服务器的组织方式要简单得多,并且大部分都由 TensorFlow 使用的特殊会话实现来吸收。TensorFlow 客户端的这一通用工作流程在图 2.3中得到了展示:

图 2.3:TensorFlow 客户端的通用执行。TensorFlow 客户端从一个图开始,图被发送到分布式主节点。主节点启动工作进程来执行实际任务和参数更新。
一旦计算完成,会话将从参数服务器中将更新后的数据返回给客户端。TensorFlow 的架构如图 2.4所示:

图 2.4:TensorFlow 框架架构。此解释基于官方的 TensorFlow 文档,文档链接为:github.com/tensorflow/docs/blob/master/site/en/r1/guide/extend/architecture.md
TensorFlow 2 中引入的大部分变化都可以归结为前端的变化。也就是说,数据流图是如何构建的,以及何时执行图。图的执行方式在 TensorFlow 1 和 2 中基本保持不变。
现在我们知道了从你执行 tf.function() 这一刻起发生的端到端过程,但这只是一个非常技术性的解释,最好的理解方式是通过一个好的类比。因此,我们将尝试通过一个类比来理解 TensorFlow 2,就像我们对新升级版的 Café Le TensorFlow 2 的理解一样。
Café Le TensorFlow 2 – 通过类比来理解 TensorFlow 2
假设老板们对我们之前的 Café Le TensorFlow(这是第一版的类比)进行了翻新,并重新开业为 Café Le TensorFlow 2。镇上传闻它比以前更加奢华。记得之前那次美好的体验后,你立刻预定了座位并赶去那里占个座。
你想点一份加奶酪、不加番茄的鸡肉汉堡。然后你意识到这家咖啡馆确实很高档。这里没有服务员,每桌都有一个语音启用的平板电脑,你可以对它说出你想要的。这会被转化为厨师能理解的标准格式(例如,桌号、菜单项 ID、数量和特别要求)。
这里,你代表了 TensorFlow 2 程序。将你的语音(或 TensorFlow 操作)转化为标准格式(或 GraphDef 格式)的语音启用平板电脑功能,类似于 AutoGraph 特性。
现在到了最精彩的部分。一旦你开始说话,经理就会查看你的订单,并将各种任务分配给厨师。经理负责确保一切尽可能快地完成。厨房经理做出决策,例如需要多少厨师来制作这道菜,哪些厨师是最合适的。厨房经理代表着分布式的主节点。
每个厨师都有一个助手,负责为厨师提供所需的食材、设备等。因此,厨房经理会将订单交给一位厨师和一位助手(比如,汉堡的准备并不难),并要求他们制作这道菜。厨师查看订单后,告诉助手需要什么。然后,助手首先找到所需的物品(例如,面包、肉饼和洋葱),并将它们放在手边,以便尽快完成厨师的要求。此外,厨师可能还会要求暂时保存菜肴的中间结果(例如,切好的蔬菜),直到厨师再次需要它们。在我们的例子中,厨师是操作执行者,而助手是参数服务器。
这家咖啡馆充满了惊喜。当你说出你的订单(也就是说,调用包含 TensorFlow 操作的 Python 函数)时,你通过桌上的平板电脑实时看到订单正在被准备(也就是急切执行)。
这个视频教程的最棒之处在于,如果你看到厨师没有放足够的奶酪,你就能立刻明白为什么汉堡不如预期的好。所以,你可以选择再点一个或者给出具体的反馈。这比 TensorFlow 1 的做法要好得多,因为他们会先接受你的订单,然后你在汉堡准备好之前什么也看不见。这个过程在图 2.5中展示:

图 2.5:餐厅类比示意图
现在,让我们回顾一下 TensorFlow 1 的工作方式。
回顾:TensorFlow 1
我们多次提到过,TensorFlow 2 与 TensorFlow 1 的区别非常大。但我们仍然不知道它以前是怎样的。现在,让我们做一场时光旅行,看看同样的 sigmoid 计算在 TensorFlow 1 中是如何实现的。
警告
你无法直接在 TensorFlow 2.x 中执行以下代码。
首先,我们将定义一个 graph 对象,稍后我们将向其中添加操作和变量:
graph = tf.Graph() # Creates a graph
session = tf.InteractiveSession(graph=graph) # Creates a session
graph 对象包含了计算图,它将我们程序中定义的各种输入和输出连接起来,从而得到最终期望的输出。这就是我们之前讨论的那个图。同时,我们还会定义一个 session 对象,作为输入传递给已定义的图,用以执行这个图。换句话说,相较于 TensorFlow 2,graph 对象和 session 对象做的事情就是当你调用它们并用 tf.function() 装饰时发生的事情。
现在,我们将定义几个张量,即 x、W、b 和 h。在 TensorFlow 1 中,你可以用多种不同的方式定义张量。这里,我们将介绍三种不同的方法:
-
首先,
x是一个占位符。占位符,顾名思义,未初始化任何值。相反,我们会在图执行时动态地提供其值。如果你记得 TensorFlow 2 中的 sigmoid 练习,我们直接将x(它是一个 NumPy 数组)传递给函数layer(x, w, b)。与 TensorFlow 2 不同,在 TensorFlow 1 中,不能直接将 NumPy 数组传递给图或操作。 -
接下来,我们有变量
W和b。变量的定义方式与 TensorFlow 2 类似,只是语法上有一些小的变化。 -
最后,我们有
h,它是一个不可变的张量,由对x、W和b执行一些操作生成。请注意,你不会立即看到h的值,因为在 TensorFlow 1 中,你需要手动执行图才能查看其值。
这些张量的定义如下:
x = tf.placeholder(shape=[1,10],dtype=tf.float32,name='x')
W = tf.Variable(tf.random_uniform(shape=[10,5], minval=-0.1, maxval=0.1, dtype=tf.float32),name='W')
b = tf.Variable(tf.zeros(shape=[5],dtype=tf.float32),name='b') h = tf.nn.sigmoid(tf.matmul(x,W) + b)
TensorFlow 1 中变量的生命周期由 session 对象管理,这意味着变量在 session 存在期间会一直驻留在内存中(即使代码中不再引用它们)。然而,在 TensorFlow 2 中,变量会在代码中不再引用后很快被移除,就像在 Python 中一样。
接下来,我们将运行一个初始化操作,用于初始化图中的变量 W 和 b:
tf.global_variables_initializer().run()
现在,我们将执行计算图以获取最终所需的输出 h。这通过运行 session.run(...) 来完成,在此过程中我们将值提供给占位符作为 session.run() 命令的参数:
h_eval = session.run(h,feed_dict={x: np.random.rand(1,10)})
最后,我们关闭会话,释放 session 对象占用的任何资源:
session.close()
这是这个 TensorFlow 1 示例的完整代码:
import tensorflow as tf import numpy as np
# Defining the graph and session graph = tf.Graph() # Creates a graph
session = tf.InteractiveSession(graph=graph) # Creates a session
# Building the graph
# A placeholder is an symbolic input
x = tf.placeholder(shape=[1,10],dtype=tf.float32,name='x')
# Variable
W = tf.Variable(tf.random_uniform(shape=[10,5], minval=-0.1, maxval=0.1, dtype=tf.float32),name='W')
b = tf.Variable(tf.zeros(shape=[5],dtype=tf.float32),name='b')
h = tf.nn.sigmoid(tf.matmul(x,W) + b) # Operation to be performed
# Executing operations and evaluating nodes in the graph
tf.global_variables_initializer().run() # Initialize the variables
# Run the operation by providing a value to the symbolic input x h_eval = session.run(h,feed_dict={x: np.random.rand(1,10)})
# Closes the session to free any held resources by the session
session.close()
正如你所看到的,在 TensorFlow 2 之前,用户需要:
-
使用各种 TensorFlow 数据结构(例如,
tf.placeholder)和操作(例如,tf.matmul())定义计算图。 -
使用
session.run()执行计算图的相关部分,通过将正确的数据传递给 session 来获取结果。
总结来说,TensorFlow 1.x 存在若干限制:
-
使用 TensorFlow 1 编码并不像使用 Python 那样直观,因为你需要先定义计算图,然后再执行它。这被称为声明式编程。
-
TensorFlow 1 的设计使得将代码拆分成可管理的函数非常困难,因为用户需要在进行任何计算之前完全定义计算图。这导致了包含非常大计算图的非常大的函数或代码块。
-
由于 TensorFlow 有自己的运行时,使用
session.run()进行实时调试非常困难。 -
但是,它也有一些优点,例如通过预先声明完整的计算图带来的效率。提前知道所有计算意味着 TensorFlow 1 可以进行各种优化(例如,图修剪),从而高效地运行计算图。
在本章的这一部分,我们讨论了 TensorFlow 2 的第一个示例以及 TensorFlow 的架构。最后,我们对比了 TensorFlow 1 和 2。接下来,我们将讨论 TensorFlow 2 的各种构建模块。
输入、变量、输出和操作
现在我们从 TensorFlow 1 的旅程中回到 TensorFlow 2,让我们继续探讨构成 TensorFlow 2 程序的最常见元素。如果你浏览互联网上的任意一个 TensorFlow 客户端代码,所有与 TensorFlow 相关的代码都可以归入以下几类:
-
输入:用于训练和测试我们算法的数据。
-
变量:可变张量,主要定义我们算法的参数。
-
输出:不可变张量,存储终端和中间输出。
-
操作:对输入进行各种变换以产生期望的输出。
在我们之前的 sigmoid 示例中,可以找到所有这些类别的实例。我们列出了相应的 TensorFlow 元素以及在 表 2.1 中使用的符号:
| TensorFlow 元素 | 示例客户端中的值 |
|---|---|
| 输入 | x |
| 变量 | W 和 b |
| 输出 | h |
| 操作 | tf.matmul(...),tf.nn.sigmoid(...) |
表 2.1:到目前为止我们遇到的不同类型的 TensorFlow 原语
以下小节将更详细地解释表中列出的每个 TensorFlow 元素。
在 TensorFlow 中定义输入
你可以将数据传递给 TensorFlow 程序的方式有三种:
-
将数据作为 NumPy 数组输入
-
将数据作为 TensorFlow 张量输入
-
使用
tf.dataAPI 创建输入管道
接下来,我们将讨论几种不同的方式,你可以将数据传递给 TensorFlow 操作。
将数据作为 NumPy 数组输入
这是将数据传递给 TensorFlow 程序的最简单方式。在这里,你将一个 NumPy 数组作为输入传递给 TensorFlow 操作,结果会立即执行。这正是我们在 sigmoid 示例中所做的。如果你查看 x,它是一个 NumPy 数组。
将数据作为张量输入
第二种方法与第一种类似,但数据类型不同。在这里,我们将 x 定义为一个 TensorFlow 张量。
为了查看这个过程,让我们修改我们的 sigmoid 示例。记得我们将 x 定义为:
x = np.array([[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]], dtype=np.float32)
相反,让我们将其定义为包含特定值的张量:
x = tf.constant(value=[[0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1.0]],
dtype=tf.float32,name='x')
此外,完整的代码将如下所示:
import tensorflow as tf
@tf.function
def layer(x, W, b):
# Building the graph
h = tf.nn.sigmoid(tf.matmul(x,W) + b) # Operation to be performed
return h
# A pre-loaded input
x = tf.constant(value=[[0,0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9]],dtype=tf.float32,name='x')
# Variable
init_w = tf.initializers.RandomUniform(minval=-0.1, maxval=0.1)(shape=[10,5])
W = tf.Variable(init_w, dtype=tf.float32, name='W')
# Variable
init_b = tf.initializers.RandomUniform()(shape=[5])
b = tf.Variable(init_b, dtype=tf.float32, name='b')
h = layer(x,W,b)
print(f"h = {h}")
print(f"h is of type {type(h)}")
现在,让我们讨论如何在 TensorFlow 中定义数据管道。
使用 tf.data API 构建数据管道
tf.data 为你提供了在 TensorFlow 中构建数据管道的便捷方式。输入管道是为需要处理大量数据的更高负载程序设计的。例如,如果你有一个小数据集(例如 MNIST 数据集),它能完全加载到内存中,那么输入管道就显得多余了。然而,当处理复杂数据或问题时,可能需要处理不适合内存的大数据集,进行数据增强(例如,调整图像对比度/亮度),进行数值变换(例如,标准化)等。tf.data API 提供了便捷的函数,可以轻松加载和转换数据。此外,它简化了与模型训练相关的数据输入代码。
此外,tf.data API 提供了多种选项来增强数据管道的性能,例如多进程处理和数据预取。预取指的是在数据需要之前将数据加载到内存中并保持其准备好。我们将在接下来的章节中更详细地讨论这些方法。
在创建输入管道时,我们打算执行以下操作:
-
从数据源获取数据(例如,一个内存中的 NumPy 数组、磁盘上的 CSV 文件或单独的文件如图像)。
-
对数据应用各种变换(例如,裁剪/调整图像数据的大小)。
-
按元素/批次迭代结果数据集。由于深度学习模型是基于随机采样的数据批次进行训练的,因此批处理是必要的。由于这些模型训练的数据集通常较大,因此通常无法完全加载到内存中。
让我们使用 TensorFlow 的 tf.data API 来编写输入管道。在这个示例中,我们有三个文本文件(iris.data.1、iris.data.2 和 iris.data.3),每个文件都是 CSV 格式,包含 50 行数据,每行有 4 个浮动的数字(也就是花的各种长度)和一个由逗号分隔的字符串标签(例如,一行数据可能是 5.6,2.9,3.6,1.3,Iris-versicolor)。我们将使用 tf.data API 从这些文件中读取数据。我们还知道这些数据中有些是损坏的(就像任何现实中的机器学习项目一样)。在我们的例子中,某些数据点的长度是负数。因此,我们首先编写一个管道,逐行处理数据并打印出损坏的数据。
欲了解更多信息,请参考官方的 TensorFlow 数据导入页面 www.tensorflow.org/guide/data。
首先,像以前一样导入几个重要的库:
import tensorflow as tf
import numpy as np
接下来,我们将定义一个包含文件名的列表:
filenames = [f"./iris.data.{i}" for i in range(1,4)]
现在我们将使用 TensorFlow 提供的其中一个数据集读取器。数据集读取器接受一个文件名列表和另一个指定数据集每列数据类型的列表。如我们之前所见,我们有四个浮动数字和一个字符串:
dataset = tf.data.experimental.CsvDataset(filenames, [tf.float32, tf.float32, tf.float32, tf.float32, tf.string])
现在我们将按如下方式组织数据为输入和标签:
dataset = dataset.map(lambda x1,x2,x3,x4,y: (tf.stack([x1,x2,x3,x4]), y))
我们正在使用 lambda 函数将 x1, x2, x3, x4 分别提取到一个数据集中,而将 y 提取到另一个数据集,并使用 dataset.map() 函数。
Lambda 函数是一种特殊类型的函数,它允许你简洁地定义一些计算。使用 lambda 函数时,你无需为你的函数命名,如果你在代码中只使用某个函数一次,这会非常方便。lambda 函数的格式如下:
lambda <arguments>: <result returned after the computation>
例如,如果你需要编写一个函数来加两个数字,直接写:
lambda x, y: x+y
在这里,tf.stack() 将单个张量(在这里是单个特征)堆叠为一个张量。当使用 map 函数时,你首先需要可视化对数据集中单个项目(在我们的例子中是数据集中的一行)需要做的操作,并编写转换代码。
map 函数非常简单但功能强大。它的作用是将一组给定的输入转换为一组新的值。例如,如果你有一个包含数字的列表xx,并且想要逐个元素将其转换为平方,你可以写出类似xx_pow = map(lambda x: x**2, xx)的代码。由于项目之间没有依赖关系,这个过程非常容易并行化。
接下来,你可以像遍历普通 Python 列表那样遍历这个数据集,检查每个数据点。在这里,我们打印出所有受损的项目:
for next_element in dataset:
x, y = next_element[0].numpy(), next_element[1].numpy().decode('ascii')
if np.min(x)<0.0:
print(f"(corrupted) X => {x}\tY => {y}")
由于你不希望数据集中包含那些受损的输入,你可以使用 dataset.filter() 函数来过滤掉这些受损的条目,方法如下:
dataset = dataset.filter(lambda x,y: tf.reduce_min(x)>0)
在这里,我们检查 x 中的最小元素是否大于零;如果不是,这些元素将被从数据集中过滤掉。
另一个有用的函数是 dataset.batch()。在训练深度神经网络时,我们通常以批次而不是单个项遍历数据集。dataset.batch() 提供了一个方便的方式来做到这一点:
batch_size = 5
dataset = dataset.batch(batch_size=batch_size)
现在,如果你打印数据集中单个元素的形状,你应该会得到以下内容:
x.shape = (5, 4), y.shape = (5,)
现在我们已经检查了在 TensorFlow 中定义输入的三种不同方法,接下来让我们看看如何在 TensorFlow 中定义变量。
在 TensorFlow 中定义变量
变量在 TensorFlow 中扮演着重要角色。变量本质上是一个张量,具有特定的形状,定义了变量将具有多少维度以及每个维度的大小。然而,与常规的 TensorFlow 张量不同,变量是可变的;这意味着在定义后,变量的值可以发生变化。这是实现学习模型参数(例如,神经网络权重)所需的理想特性,因为权重会在每一步学习后略微变化。例如,如果你定义了一个变量 x = tf.Variable(0,dtype=tf.int32),你可以使用 TensorFlow 操作如 tf.assign(x,x+1) 来改变该变量的值。然而,如果你定义了一个张量 x = tf.constant(0,dtype=tf.int32),你就无法像改变变量那样改变张量的值。它应该保持为 0,直到程序执行结束。
变量创建非常简单。在我们的 sigmoid 示例中,我们已经创建了两个变量,W 和 b。在创建变量时,有几个非常重要的事项。我们将在此列出它们,并在接下来的段落中详细讨论每一个:
-
变量形状
-
初始值
-
数据类型
-
名称(可选)
变量形状是一个 [x,y,z,...] 格式的列表。列表中的每个值表示相应维度或轴的大小。例如,如果你需要一个包含 50 行和 10 列的 2D 张量作为变量,形状将是 [50,10]。
变量的维度(即 shape 向量的长度)在 TensorFlow 中被认为是张量的秩。不要将其与矩阵的秩混淆。
TensorFlow 中的张量秩表示张量的维度;对于一个二维矩阵,秩 = 2。
接下来,变量需要一个初始值来初始化。TensorFlow 为我们的便利提供了几种不同的初始化器,包括常量初始化器和正态分布初始化器。以下是你可以用来初始化变量的几种流行的 TensorFlow 初始化器:
-
tf.initializers.Zeros -
tf.initializers.Constant -
tf.initializers.RandomNormal -
tf.initializers.GlorotUniform
变量的形状可以作为初始化器的一部分提供,如下所示:
`tf.initializers.RandomUniform(minval=-`0.1`, maxval=`0.1`)(shape=[`10`,`5`])`
数据类型在确定变量的大小时起着重要作用。TensorFlow 有许多不同的数据类型,包括常用的 tf.bool、tf.uint8、tf.float32 和 tf.int32。每种数据类型都需要一定的位数来表示该类型的单个值。例如,tf.uint8 需要 8 位,而 tf.float32 需要 32 位。通常建议在计算中使用相同的数据类型,因为使用不同的数据类型可能会导致类型不匹配。因此,如果你有两个不同数据类型的张量需要转换,你必须使用 tf.cast(...) 操作显式地将一个张量转换为另一个张量的数据类型。
tf.cast(...) 操作旨在处理这种情况。例如,如果你有一个 x 变量,类型为 tf.int32,并且需要将其转换为 tf.float32,可以使用 tf.cast(x,dtype=tf.float32) 将 x 转换为 tf.float32。
最后,变量的名称将作为 ID 用于在计算图中标识该变量。如果你曾经可视化过计算图,变量将以传递给 name 关键字的参数出现。如果没有指定名称,TensorFlow 将使用默认的命名方案。
请注意,Python 变量 tf.Variable 被赋值后,在计算图中是不可见的,且不属于 TensorFlow 变量命名的一部分。考虑以下示例,你指定一个 TensorFlow 变量如下:
`a = tf.Variable(tf.zeros([`5`]),name=`'b'`)`
在这里,TensorFlow 图会通过名称 b 来识别此变量,而不是 a。
接下来,我们来讨论如何定义 TensorFlow 输出。
在 TensorFlow 中定义输出
TensorFlow 的输出通常是张量,并且是对输入、变量或两者的变换结果。在我们的示例中,h 是一个输出,其中 h = tf.nn.sigmoid(tf.matmul(x,W) + b)。也可以将这种输出传递给其他操作,形成一系列链式操作。此外,它们不一定非得是 TensorFlow 操作,你还可以使用标准的 Python 算术与 TensorFlow 结合。以下是一个示例:
x = tf.matmul(w,A)
y = x + B
在下面,我们将解释 TensorFlow 中可用的各种操作以及如何使用它们。
在 TensorFlow 中定义操作
TensorFlow 中的操作接受一个或多个输入,并生成一个或多个输出。如果你查看 TensorFlow API www.tensorflow.org/api_docs/python/tf,你会发现 TensorFlow 提供了大量的操作。在这里,我们将选取一些典型的 TensorFlow 操作进行讲解。
比较操作
比较操作用于比较两个张量。以下代码示例包括一些有用的比较操作。
为了理解这些操作的工作原理,我们来考虑两个示例张量,x 和 y:
# Let's assume the following values for x and y
# x (2-D tensor) => [[1,2],[3,4]]
# y (2-D tensor) => [[4,3],[3,2]]
x = tf.constant([[1,2],[3,4]], dtype=tf.int32)
y = tf.constant([[4,3],[3,2]], dtype=tf.int32)
# Checks if two tensors are equal element-wise and returns a boolean
# tensor
# x_equal_y => [[False,False],[True,False]]
x_equal_y = tf.equal(x, y, name=None)
# Checks if x is less than y element-wise and returns a boolean tensor
# x_less_y => [[True,True],[False,False]]
x_less_y = tf.less(x, y, name=None)
# Checks if x is greater or equal than y element-wise and returns a
# boolean tensor
# x_great_equal_y => [[False,False],[True,True]]
x_great_equal_y = tf.greater_equal(x, y, name=None)
# Selects elements from x and y depending on whether, # the condition is satisfied (select elements from x) # or the condition failed (select elements from y)
condition = tf.constant([[True,False],[True,False]],dtype=tf.bool)
# x_cond_y => [[1,3],[3,2]]
x_cond_y = tf.where(condition, x, y, name=None)
接下来,我们来看一些数学运算。
数学运算
TensorFlow 允许你对张量执行从简单到复杂的数学操作。我们将讨论一些在 TensorFlow 中提供的数学操作。完整的操作集可以在www.tensorflow.org/versions/r2.0/api_docs/python/tf/math找到:
# Let's assume the following values for x and y
# x (2-D tensor) => [[1,2],[3,4]]
# y (2-D tensor) => [[4,3],[3,2]]
x = tf.constant([[1,2],[3,4]], dtype=tf.float32)
y = tf.constant([[4,3],[3,2]], dtype=tf.float32)
# Add two tensors x and y in an element-wise fashion
# x_add_y => [[5,5],[6,6]]
x_add_y = tf.add(x, y)
# Performs matrix multiplication (not element-wise)
# x_mul_y => [[10,7],[24,17]]
x_mul_y = tf.matmul(x, y)
# Compute natural logarithm of x element-wise # equivalent to computing ln(x)
# log_x => [[0,0.6931],[1.0986,1.3863]]
log_x = tf.log(x)
# Performs reduction operation across the specified axis
# x_sum_1 => [3,7]
x_sum_1 = tf.reduce_sum(x, axis=[1], keepdims=False)
# x_sum_2 => [[4,6]]
x_sum_2 = tf.reduce_sum(x, axis=[0], keepdims=True)
# Segments the tensor according to segment_ids (items with same id in
# the same segment) and computes a segmented sum of the data
data = tf.constant([1,2,3,4,5,6,7,8,9,10], dtype=tf.float32)
segment_ids = tf.constant([0,0,0,1,1,2,2,2,2,2 ], dtype=tf.int32)
# x_seg_sum => [6,9,40]
x_seg_sum = tf.segment_sum(data, segment_ids)
现在,我们将来看一下散布操作。
更新(散布)张量中的值
散布操作,指的是更改张量某些索引处的值,在科学计算问题中非常常见。最初,TensorFlow 通过一个令人生畏的tf.scatter_nd()函数提供了这一功能,这个函数可能比较难理解。
然而,在最近的 TensorFlow 版本中,你可以通过使用类似于 NumPy 的语法进行数组索引和切片来执行散布操作。让我们看几个例子。假设你有一个 TensorFlow 变量v,它是一个[3,2]的矩阵:
`v = tf.Variable(tf.constant([[`1`,`9`],[`3`,`10`],[`5`,`11`]],dtype=tf.float32),name=`'ref'`)`
你可以通过以下方式更改此张量的第 0 行:
`v[`0`].assign([-`1`, -`9`])`
这将导致:
<tf.Variable 'ref:0' shape=(3, 2) dtype=float32, numpy=
array([[-1., -9.],
[ 3., 10.],
[ 5., 11.]], dtype=float32)>
你可以通过以下方式更改索引[1,1]处的值:
`v[`1`,`1`].assign(-`10`)`
这将导致:
<tf.Variable 'ref:0' shape=(3, 2) dtype=float32, numpy=
array([[ 1., 9.],
[ 3., -10.],
[ 5., 11.]], dtype=float32)>
你可以通过以下方式进行行切片:
`v[`1`:,`0`].assign([-`3`,-`5`])`
这将导致:
<tf.Variable 'ref:0' shape=(3, 2) dtype=float32, numpy=
array([[ 1., 9.],
[-3., 10.],
[-5., 11.]], dtype=float32)>
重要的是要记住,散布操作(通过assign()操作执行)只能在tf.Variables上执行,后者是可变结构。请记住,tf.Tensor/tf.EagerTensor是不可变对象。
从张量中收集(聚集)值
聚集操作与散布操作非常相似。请记住,散布是将值分配给张量,而聚集则是检索张量的值。让我们通过一个例子来理解这一点。假设你有一个 TensorFlow 张量t:
`t = tf.constant([[`1`,`9`],[`3`,`10`],[`5`,`11`]],dtype=tf.float32)`
你可以通过以下方式获取t的第 0 行:
`t[`0`].numpy()`
这将返回:
[1\. 9.]
你也可以通过以下方式进行行切片:
`t[`1`:,`0`].numpy()`
这将返回:
[3\. 5.]
与散布操作不同,聚集操作既适用于tf.Variable也适用于tf.Tensor结构。
与神经网络相关的操作
现在,让我们来看一些我们在接下来的章节中将大量使用的有用的神经网络相关操作。我们将在这里讨论的操作从简单的逐元素变换(即激活)到计算一组参数对另一个值的偏导数。我们还将作为练习实现一个简单的神经网络。
神经网络使用的非线性激活函数
非线性激活使神经网络在多个任务上表现出色。通常,在神经网络的每一层输出后(除了最后一层),会有一个非线性激活转换(即激活层)。非线性转换帮助神经网络学习数据中存在的各种非线性模式。这对于复杂的现实问题非常有用,因为这些问题中的数据往往比线性模式更复杂。如果没有层与层之间的非线性激活,深度神经网络将仅仅是堆叠在一起的多个线性层。而且,一组线性层本质上可以压缩成一个更大的线性层。
总结来说,如果没有非线性激活,我们就无法创建一个具有多层的神经网络。
让我们通过一个例子来观察非线性激活的重要性。首先,回忆一下我们在 sigmoid 例子中看到的神经网络计算。如果我们忽略 b,它将是:
h = sigmoid(W*x)
假设有一个三层神经网络(具有W1、W2和W3作为层权重),每一层执行前一层的计算;我们可以将整个计算过程总结如下:
h = sigmoid(W3*sigmoid(W2*sigmoid(W1*x)))
然而,如果我们去除非线性激活(即 sigmoid),我们将得到如下结果:
h = (W3 * (W2 * (W1 *x))) = (W3*W2*W1)*x
所以,如果没有非线性激活,三层网络可以简化为一个线性层。
现在,我们列出两种在神经网络中常用的非线性激活(即 sigmoid 和 ReLU)以及它们如何在 TensorFlow 中实现:
# Sigmoid activation of x is given by 1 / (1 + exp(-x))
tf.nn.sigmoid(x,name=None)
# ReLU activation of x is given by max(0,x)
tf.nn.relu(x, name=None)
这些计算的函数形式在图 2.6中可视化:

图 2.6:sigmoid(左)和 ReLU(右)激活的函数形式
接下来,我们将讨论卷积操作。
卷积操作
卷积操作是一种广泛使用的信号处理技术。对于图像,卷积用于产生不同的效果(如模糊),或从图像中提取特征(如边缘)。使用卷积进行边缘检测的例子如图 2.7所示。其实现方式是将卷积滤波器移动到图像上,每个位置产生不同的输出(稍后在本节中会看到图 2.8)。具体而言,在每个位置,我们对卷积滤波器中的元素与图像块(与卷积滤波器大小相同)进行逐元素相乘,并将乘积求和:

图 2.7:使用卷积操作进行图像边缘检测(来源:en.wikipedia.org/wiki/Kernel_(image_processing))
以下是卷积操作的实现:
x = tf.constant(
[[
[[1],[2],[3],[4]],
[[4],[3],[2],[1]],
[[5],[6],[7],[8]],
[[8],[7],[6],[5]]
]],
dtype=tf.float32)
x_filter = tf.constant(
[ [ [[0.5]],[[1]] ],
[ [[0.5]],[[1]] ]
],
dtype=tf.float32)
x_stride = [1,1,1,1]
x_padding = 'VALID'
x_conv = tf.nn.conv2d(
input=x, filters=x_filter, strides=x_stride, padding=x_padding
)
在这里,显得过多的方括号可能会让你认为通过去掉这些冗余的括号,例子会更容易理解。不幸的是,情况并非如此。对于tf.nn.conv2d(...)操作,TensorFlow 要求input、filters和strides必须符合精确的格式。我们现在将更详细地介绍tf.conv2d(input, filters, strides, padding)中的每个参数:
-
input:这通常是一个 4D 张量,其中各维度应该按顺序排列为
[batch_size, height, width, channels]:-
batch_size:这是单个数据批次中的数据量(例如图像和单词等输入)。我们通常以批量方式处理数据,因为在学习过程中使用的是大型数据集。在每个训练步骤中,我们会随机抽取一个小批量数据,这些数据大致代表了整个数据集。通过这种方式进行多次步骤,我们可以很好地逼近整个数据集。这个
batch_size参数与我们在 TensorFlow 输入管道示例中讨论的参数是一样的。 -
height and width:这是输入的高度和宽度。
-
channels:这是输入的深度(例如,对于 RGB 图像,通道数为 3—每种颜色一个通道)。
-
-
filters:这是一个 4D 张量,表示卷积操作的卷积窗口。滤波器的维度应该是
[height, width, in_channels, out_channels]:-
height and width:这是滤波器的高度和宽度(通常小于输入的高度和宽度)。
-
in_channels:这是输入层的通道数。
-
out_channels:这是层输出中产生的通道数。
-
-
strides:这是一个包含四个元素的列表,其中元素为
[batch_stride, height_stride, width_stride, channels_stride]。strides参数表示在卷积窗口单次移动时跳过的元素数。通常情况下,你不需要担心batch_stride和channels_stride。如果你不完全理解strides,可以使用默认值1。 -
padding:这可以是
['SAME', 'VALID']之一。它决定了如何处理卷积操作在输入边界附近的情况。VALID操作在没有填充的情况下进行卷积。如果我们用大小为h的卷积窗口对长度为n的输入进行卷积,那么输出的大小将是(n-h+1 < n)。输出大小的减少可能会严重限制神经网络的深度。SAME则会在边界处填充零,使得输出的高度和宽度与输入相同。
为了更好地理解滤波器大小、步幅和填充,请参考图 2.8:

图 2.8:卷积操作。注意卷积核如何在输入上移动,以在每个位置计算值。
接下来,我们将讨论池化操作。
池化操作
池化操作的行为类似于卷积操作,但最终输出是不同的。我们不再输出滤波器和图像块的逐元素乘积的和,而是对该位置的图像块选择最大元素(参见图 2.9):
x = tf.constant(
[[
[[1],[2],[3],[4]],
[[4],[3],[2],[1]],
[[5],[6],[7],[8]],
[[8],[7],[6],[5]]
]],
dtype=tf.float32)
x_ksize = [1,2,2,1]
x_stride = [1,2,2,1]
x_padding = 'VALID'
x_pool = tf.nn.max_pool2d(
input=x, ksize=x_ksize,
strides=x_stride, padding=x_padding
)
# Returns (out) => [[[[ 4.],[ 4.]],[[ 8.],[ 8.]]]]

图 2.9:最大池化操作
定义损失
我们知道,为了让神经网络学到有用的东西,需要定义损失函数。损失函数表示预测值与实际目标之间的差距。TensorFlow 中有几个函数可以自动计算损失,以下代码展示了其中两个。tf.nn.l2_loss 函数是均方误差损失,而 tf.nn.softmax_cross_entropy_with_logits 是另一种损失函数,实际上在分类任务中表现更好。这里的 logits 指的是神经网络的未归一化输出(即神经网络最后一层的线性输出):
# Returns half of L2 norm of t given by sum(t**2)/2
x = tf.constant([[2,4],[6,8]],dtype=tf.float32)
x_hat = tf.constant([[1,2],[3,4]],dtype=tf.float32)
# MSE = (1**2 + 2**2 + 3**2 + 4**2)/2 = 15
MSE = tf.nn.l2_loss(x-x_hat)
# A common loss function used in neural networks to optimize the network
# Calculating the cross_entropy with logits (unnormalized outputs of the last layer)
# instead of probabilsitic outputs leads to better numerical stabilities
y = tf.constant([[1,0],[0,1]],dtype=tf.float32)
y_hat = tf.constant([[3,1],[2,5]],dtype=tf.float32)
# This function alone doesn't average the cross entropy losses of all data points,
# You need to do that manually using reduce_mean function
CE = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=y_hat,labels=y))
在这里,我们讨论了与神经网络密切相关的几个重要操作,如卷积操作和池化操作。接下来,我们将讨论如何使用 TensorFlow 中的子库 Keras 来构建模型。
Keras:TensorFlow 的模型构建 API
Keras 最初作为一个独立的库开发,提供了高层次的构建模块,便于构建模型。它最初是平台无关的,支持多种软件(例如 TensorFlow 和 Theano)。
然而,TensorFlow 收购了 Keras,现在它已成为 TensorFlow 中构建模型的一个不可或缺的部分,简化了模型构建过程。
Keras 的主要关注点是模型构建。为此,Keras 提供了几个不同的 API,具有不同的灵活性和复杂性。选择合适的 API 需要了解每个 API 的局限性,并积累相应的经验。Keras 提供的 API 包括:
-
顺序 API – 最易于使用的 API。在这个 API 中,你只需将层按顺序堆叠在一起以创建模型。
-
功能性 API – 功能性 API 通过允许你定义自定义模型,提供了更多的灵活性,这些模型可以有多个输入层/多个输出层。
-
子类化 API – 子类化 API 使你能够将自定义的可重用层/模型定义为 Python 类。这是最灵活的 API,但它需要对 API 和原始 TensorFlow 操作有深入的了解才能正确使用。
不要将 Keras 的 TensorFlow 子模块(www.tensorflow.org/api_docs/python/tf/keras)与外部的 Keras 库(keras.io/)混淆。它们在起源上有相似之处,但并不是相同的。如果在开发过程中把它们当成相同的东西,你会遇到奇怪的问题。在本书中,我们专门使用 tf.keras。
Keras 中最本质的概念之一是模型由一个或多个以特定方式连接的层组成。在这里,我们将简要介绍使用不同 API 开发模型的代码样子。你不需要完全理解下面的代码,而是要关注代码风格,识别三种方法之间的差异。
顺序 API
在使用顺序 API 时,你只需将模型定义为一个层的列表。在这里,列表中的第一个元素最接近输入,而最后一个元素则是输出层:
model = tf.keras.Sequential([
tf.keras.layers.Dense(500, activation='relu', shape=(784, )),
tf.keras.layers.Dense(250, activation='relu'),
tf.keras.layers.Dense(10, activation='softmax')
])
在上面的代码中,我们有三层。第一层有 500 个输出节点,并接受一个 784 元素的向量作为输入。第二层自动连接到第一层,而最后一层则连接到第二层。这些层都是全连接层,其中所有输入节点都连接到所有输出节点。
功能性 API
在功能性 API 中,我们采取不同的做法。我们首先定义一个或多个输入层,以及进行计算的其他层。然后,我们自己将输入与输出连接起来,如下所示的代码所示:
inp = tf.keras.layers.Input(shape=(784,))
out_1 = tf.keras.layers.Dense(500, activation='relu')(inp)
out_2 = tf.keras.layers.Dense(250, activation='relu')(out_1)
out = tf.keras.layers.Dense(10, activation='softmax')(out_2)
model = tf.keras.models.Model(inputs=inp, outputs=out)
在代码中,我们首先定义一个输入层,它接受一个 784 元素长的向量。输入将传递给一个具有 500 个节点的 Dense 层。该层的输出被赋值给out_1。然后,out_1被传递到另一个 Dense 层,该层输出out_2。接着,一个具有 10 个节点的 Dense 层输出最终的结果。最后,模型被定义为一个tf.keras.models.Model对象,它接受两个参数:
-
inputs – 一个或多个输入层
-
outputs – 由任何
tf.keras.layers类型对象生成的一个或多个输出
这个模型与上一节中定义的模型相同。功能性 API 的一个好处是,你可以创建更复杂的模型,因为你不再局限于将层作为列表。由于这种灵活性,你可以有多个输入连接到多个层,并以多种不同方式连接,甚至可能产生多个输出。
子类化 API
最后,我们将使用子类化 API 来定义一个模型。通过子类化,你将模型定义为一个继承自基础对象tf.keras.Model的 Python 对象。在使用子类化时,你需要定义两个重要的函数:__init__(),它将指定成功执行计算所需的任何特殊参数、层等;以及call(),它定义了模型中需要执行的计算:
class MyModel(tf.keras.Model):
def __init__(self, num_classes):
super().__init__()
self.hidden1_layer = tf.keras.layers.Dense(500, activation='relu')
self.hidden2_layer = tf.keras.layers.Dense(250, activation='relu')
self.final_layer = tf.keras.layers.Dense(num_classes, activation='softmax')
def call(self, inputs):
h = self.hidden1_layer(inputs)
h = self.hidden2_layer(h)
y = self.final_layer(h)
return y
model = MyModel(num_classes=10)
在这里,你可以看到我们的模型有三层,就像我们定义的所有前置模型一样。接下来,call函数定义了这些层如何连接,从而生成最终输出。子类化 API 被认为是最难掌握的,主要是因为该方法提供了很大的自由度。然而,一旦你掌握了这个 API,它的回报是巨大的,因为它使你能够定义非常复杂的模型/层作为单元计算,并且可以在后续重用。现在你已经理解了每个 API 的工作原理,让我们使用 Keras 来实现一个神经网络并在数据集上训练它。
实现我们的第一个神经网络
太棒了!现在你已经了解了 TensorFlow 的架构和基础,是时候继续前进并实现稍微复杂一点的东西了。让我们来实现一个神经网络。具体来说,我们将实现一个全连接神经网络模型(FCNN),这是我们在第一章《自然语言处理介绍》中讨论过的内容。
神经网络引入的一个重要步骤是实现一个能够分类数字的神经网络。为此任务,我们将使用著名的 MNIST 数据集,可以从yann.lecun.com/exdb/mnist/下载。
你可能对我们使用计算机视觉任务而不是自然语言处理任务感到有些怀疑。然而,视觉任务的实现通常需要较少的预处理,且更易于理解。
由于这是我们第一次接触神经网络,我们将学习如何使用 Keras 实现这个模型。Keras 是一个高层次的子模块,它在 TensorFlow 之上提供了一个抽象层。因此,使用 Keras 实现神经网络比使用 TensorFlow 的原始操作要轻松得多。为了完整运行这些示例,你可以在Ch02-Understanding-TensorFlow文件夹中的tensorflow_introduction.ipynb文件中找到完整的练习。下一步是准备数据。
准备数据
首先,我们需要下载数据集。TensorFlow 提供了便捷的函数来下载数据,MNIST 就是其中之一。我们将在数据准备过程中执行四个重要步骤:
-
下载数据并将其存储为
numpy.ndarray对象。我们将在ch2目录下创建一个名为 data 的文件夹并将数据存储在其中。 -
对图像进行重塑,使得数据集中的 2D 灰度图像转换为 1D 向量。
-
对图像进行标准化,使其具有零均值和单位方差(也叫做白化)。
-
对整数类别标签进行独热编码。独热编码指的是将整数类别标签表示为一个向量的过程。例如,如果你有 10 个类别且类别标签为 3(标签范围为 0-9),那么你的独热编码向量将是
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]。
以下代码为我们执行这些功能:
os.makedirs('data', exist_ok=True)
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data(
path=os.path.join(os.getcwd(), 'data', 'mnist.npz')
)
# Reshaping x_train and x_test tensors so that each image is represented
# as a 1D vector
x_train = x_train.reshape(x_train.shape[0], -1)
x_test = x_test.reshape(x_test.shape[0], -1)
# Standardizing x_train and x_test tensors
x_train = (
x_train - np.mean(x_train, axis=1, keepdims=True)
)/np.std(x_train, axis=1, keepdims=True)
x_test = (
x_test - np.mean(x_test, axis=1, keepdims=True)
)/np.std(x_test, axis=1, keepdims=True)
# One hot encoding y_train and y_test
y_onehot_train = np.zeros((y_train.shape[0], num_labels), dtype=np.float32)
y_onehot_train[np.arange(y_train.shape[0]), y_train] = 1.0
y_onehot_test = np.zeros((y_test.shape[0], num_labels), dtype=np.float32)
y_onehot_test[np.arange(y_test.shape[0]), y_test] = 1.0
你可以看到,我们使用了 TensorFlow 提供的tf.keras.datasets.mnist.load_data()函数来下载训练和测试数据。数据将下载到Ch02-Understanding-TensorFlow文件夹中的一个名为data的文件夹内。这将提供四个输出张量:
-
x_train– 一个大小为 60000 x 28 x 28 的张量,每张图像为 28 x 28 -
y_train– 一个大小为 60000 的向量,其中每个元素是一个介于 0-9 之间的类别标签 -
x_test– 一个大小为 10000 x 28 x 28 的张量 -
y_test– 一个大小为 10000 的向量
一旦数据被下载,我们将 28 x 28 大小的图像重塑为 1D 向量。这是因为我们将实现一个全连接神经网络。全连接神经网络将 1D 向量作为输入。因此,图像中的所有像素将按照像素序列进行排列,以便输入到模型中。最后,如果你查看x_train和x_test张量中值的范围,它们将处于 0-255 之间(典型的灰度范围)。我们将通过减去每张图像的均值并除以标准差,将这些值转换为零均值单位方差的范围。
使用 Keras 实现神经网络
现在我们来看看如何使用 Keras 实现我们在第一章,自然语言处理简介中讨论的那种神经网络。该网络是一个全连接神经网络,具有三层,分别包含 500、250 和 10 个节点。前两层将使用 ReLU 激活函数,而最后一层则使用 softmax 激活函数。为了实现这一点,我们将使用 Keras 提供的最简单的 API——Sequential API。
你可以在Ch02-Understanding-TensorFlow文件夹中的tensorflow_introduction.ipynb文件中找到完整的练习:
model = tf.keras.Sequential([
tf.keras.layers.Dense(500, activation='relu'),
tf.keras.layers.Dense(250, activation='relu'),
tf.keras.layers.Dense(10, activation='softmax')
])
你可以看到,在 Keras 的 Sequential API 中只需要一行代码,就能定义我们刚才定义的模型。Keras 提供了多种层类型。你可以在www.tensorflow.org/api_docs/python/tf/keras/layers查看所有可用的层列表。对于全连接网络,我们只需要使用 Dense 层,它模拟全连接网络中隐藏层的计算。定义好模型后,你需要用适当的损失函数、优化器和可选的性能指标来编译模型:
optimizer = tf.keras.optimizers.RMSprop()
loss_fn = tf.keras.losses.CategoricalCrossentropy()
model.compile(optimizer=optimizer, loss=loss_fn, metrics=['acc'])
定义并编译好模型后,我们就可以在准备好的数据上训练我们的模型了。
训练模型
在 Keras 中训练模型非常简单。一旦数据准备好,你只需要调用model.fit()函数并传入所需的参数:
batch_size = 100
num_epochs = 10
train_history = model.fit(
x=x_train,
y=y_onehot_train,
batch_size=batch_size,
epochs= num_epochs,
validation_split=0.2
)
model.fit()接受几个重要的参数。我们将在这里详细介绍它们:
-
x– 输入张量。在我们的例子中,这是一个大小为 60000 x 784 的张量。 -
y– 一热编码标签张量。在我们的例子中,这是一个大小为 60000 x 10 的张量。 -
batch_size– 深度学习模型通过批次数据进行训练(换句话说,以随机方式),而不是一次性输入完整数据集。批次大小定义了单个批次中包含多少样本。批次大小越大,通常模型的准确率会越好。 -
epochs– 深度学习模型会多次以批次方式遍历数据集。遍历数据集的次数称为训练周期数。在我们的例子中,这被设置为 10。 -
validation_split– 在训练深度学习模型时,使用验证集来监控性能,验证集作为真实世界性能的代理。validation_split定义了要用于验证子集的完整数据集的比例。在我们的例子中,这被设置为总数据集大小的 20%。
下面是我们训练模型过程中,训练损失和验证准确率随训练周期变化的情况(图 2.10):

图 2.10:随着模型训练的进行,训练损失和验证准确率在 10 个训练周期中的变化
接下来是用一些未见过的数据来测试我们的模型。
测试模型
测试模型也很简单。在测试过程中,我们会测量模型在测试数据集上的损失和准确率。为了在数据集上评估模型,Keras 提供了一个方便的函数叫做evaluate():
test_res = model.evaluate(
x=x_test,
y=y_onehot_test,
batch_size=batch_size
)
model.evaluate() 函数期望的参数已经在我们讨论 model.fit() 时覆盖过了:
-
x– 输入张量。在我们的例子中,这是一个 10000 x 784 的张量。 -
y– 独热编码标签张量。在我们的例子中,这是一个 10000 x 10 的张量。 -
batch_size– 批次大小定义了单个批次中包含多少样本。批次大小越大,通常模型的准确率会越好。
你将得到一个损失为 0.138 和准确率为 98% 的结果。由于模型中以及训练过程中存在的各种随机性,你将不会得到完全相同的值。
在这一节中,我们演示了一个从头到尾的神经网络训练示例。我们准备了数据,使用这些数据训练了模型,最后在一些未见过的数据上进行了测试。
总结
在这一章中,你通过理解我们将实现算法的主要基础平台(TensorFlow),迈出了解决 NLP 任务的第一步。首先,我们讨论了 TensorFlow 架构的底层细节。接着,我们讨论了一个有意义的 TensorFlow 程序的基本组成部分。我们深入了解了 TensorFlow 2 中的一些新特性,比如 AutoGraph 功能。然后,我们讨论了 TensorFlow 中一些更有趣的元素,如数据管道和各种 TensorFlow 操作。
具体而言,我们通过一个 TensorFlow 示例程序(sigmoid 示例)来对 TensorFlow 架构进行解释。在这个 TensorFlow 程序中,我们使用了 AutoGraph 功能来生成一个 TensorFlow 图;即,通过在执行 TensorFlow 操作的函数上使用 tf.function() 装饰器。然后,创建了一个 GraphDef 对象来表示该图,并将其发送到分布式主节点。分布式主节点查看图,决定使用哪些组件进行相关计算,并将其拆分成多个子图,以加速计算。最后,工作节点执行子图并立即返回结果。
接下来,我们讨论了构成典型 TensorFlow 客户端的各个元素:输入、变量、输出和操作。输入是我们提供给算法用于训练和测试的数据。我们讨论了三种不同的输入馈送方式:使用 NumPy 数组、将数据预加载为 TensorFlow 张量,以及使用 tf.data 定义输入管道。然后,我们讨论了 TensorFlow 变量,它们与其他张量的不同之处,以及如何创建和初始化它们。接着,我们讨论了如何使用变量创建中间和最终输出。
最后,我们讨论了几种可用的 TensorFlow 操作,包括数学运算、矩阵操作和神经网络相关的操作,这些将在本书后续章节中使用。
后来,我们讨论了 Keras,TensorFlow 中一个支持构建模型的子模块。我们了解到,有三种不同的 API 可以用来构建模型:Sequential API、Functional API 和 Sub-classing API。我们得知,Sequential API 是最易用的,而 Sub-classing API 则需要更多的工作。然而,Sequential API 在可实现的模型类型上非常有限制。
最后,我们使用之前学到的所有概念实现了一个神经网络。我们使用三层神经网络对 MNIST 手写数字数据集进行了分类,并且使用了 Keras(TensorFlow 中的高级子模块)来实现该模型。
在下一章中,我们将看到如何使用本章中实现的全连接神经网络来学习单词的语义和数值表示。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,结识志同道合的人,并与超过 1000 名成员一起学习: packt.link/nlp

第三章:Word2vec – 学习词嵌入
在本章中,我们将讨论 NLP 中一个至关重要的话题——Word2vec,一种数据驱动的技术,用于学习语言中词或符号的强大数值表示(即向量)。语言是复杂的,这要求我们在构建解决 NLP 问题的模型时具备良好的语言理解能力。将词转换为数值表示时,许多方法无法充分捕捉词所携带的语义和上下文信息。例如,词forest的特征表示应与oven的表示有很大不同,因为这两个词很少在类似的语境中使用,而forest和jungle的表示应该非常相似。无法捕捉到这些信息会导致模型性能不佳。
Word2vec 试图通过大量文本学习词表示来克服这个问题。
Word2vec 被称为分布式表示,因为词的语义通过完整表示向量的激活模式来捕获,这与表示向量中的单一元素(例如,将向量中的单一元素设置为 1,其余为 0 以表示单个词)不同。
在本章中,我们将学习几个 Word2vec 算法的工作原理。但首先,我们将讨论解决此问题的经典方法及其局限性。然后,这促使我们研究基于神经网络的 Word2vec 算法,这些算法在找到良好的词表示时能够提供最先进的性能。
我们将在一个数据集上训练一个模型,并分析模型学习到的表示。我们使用 t-SNE(一种用于高维数据可视化的技术)将这些学习到的词嵌入可视化,在图 3.1的二维画布上展示。如果仔细观察,你会发现相似的事物被放置得很近(例如,中间聚集的数字):

图 3.1:使用 t-SNE 可视化学习到的词嵌入示例
t-分布式随机邻居嵌入(t-SNE)
这是一种降维技术,将高维数据投影到二维空间。这使我们能够想象高维数据在空间中的分布,因为人类通常不太擅长直观理解超过三维的数据。你将在下一章详细了解 t-SNE。
本章通过以下几个主要主题涵盖此信息:
-
什么是词表示或词义?
-
学习词表示的经典方法
-
Word2vec – 基于神经网络的词表示学习方法
-
跳字模型算法
-
连续词袋模型算法
到本章结束时,你将全面了解单词表示的历史如何发展到 Word2vec,如何使用两种不同的 Word2vec 算法,以及 Word2vec 在 NLP 中的至关重要性。
什么是单词表示或意义?
“meaning”这个词是什么意思?这是一个哲学性的问题,更多的是哲学性的问题,而非技术性的问题。所以,我们不会尝试去找出这个问题的最佳答案,而是接受一个更为谦逊的答案,即meaning是通过一个词所传达的想法或与之相关的某种表示。例如,当你听到“cat”这个词时,你会在脑海中浮现出一只会喵喵叫、有四条腿、有尾巴的动物的画面;接着,如果你听到“dog”这个词,你又会想象出一只会汪汪叫、比猫体型大、有四条腿、有尾巴的动物。在这个新空间中(即脑海中的画面),你比仅仅通过文字理解,更容易看出猫和狗之间的相似性。由于自然语言处理(NLP)的主要目标是在人类语言任务中实现类似人类的表现,因此探索为机器表示单词的有原则的方式是明智的。为了实现这一目标,我们将使用可以分析给定文本语料库并生成单词的良好数值表示的算法(即,词嵌入),这样那些处于相似语境中的单词(例如,one和two,I和we)将具有相似的数值表示,而与之无关的单词(例如,cat和volcano)则会有不同的表示。
首先,我们将讨论一些经典的方法来实现这一点,然后转向理解近期更为复杂的利用神经网络学习特征表示并取得最先进表现的方法。
经典的单词表示学习方法
在本节中,我们将讨论一些用于数值表示单词的经典方法。了解单词向量的替代方法非常重要,因为这些方法在实际应用中仍然被使用,尤其是在数据有限的情况下。
更具体地,我们将讨论一些常见的表示方法,如独热编码和词频-逆文档频率(TF-IDF)。
独热编码表示
表示单词的一个简单方法是使用独热编码表示。这意味着,如果我们有一个大小为V的词汇表,对于每个第i个单词w[i],我们将用一个长度为V的向量[0, 0, 0, …, 0, 1, 0, …, 0, 0, 0]来表示这个单词,其中第i个元素是 1,其他元素为 0。例如,考虑以下句子:
Bob 和 Mary 是好朋友。
每个单词的独热编码表示可能如下所示:
Bob: [1,0,0,0,0,0]
and: [0,1,0,0,0,0]
Mary: [0,0,1,0,0,0]
are: [0,0,0,1,0,0]
good: [0,0,0,0,1,0]
friends: [0,0,0,0,0,1]
然而,正如你可能已经发现的那样,这种表示有许多缺点。
这种表示方式并没有编码单词之间的相似性,完全忽略了单词使用的上下文。让我们考虑单词向量之间的点积作为相似性度量。两个向量越相似,它们的点积就越高。例如,car 和 automobile 的表示将具有 0 的相似性距离,而 car 和 pencil 的表示也将具有相同的值。
对于大词汇量的情况,这种方法变得非常低效。此外,对于典型的自然语言处理任务,词汇量很容易超过 50,000 个单词。因此,对于 50,000 个单词,词表示矩阵将生成一个非常稀疏的 50,000 × 50,000 矩阵。
然而,一热编码在最先进的词嵌入学习算法中仍然发挥着重要作用。我们使用一热编码将单词表示为数字,并将其输入神经网络,以便神经网络能够更好地学习单词的更小的数字特征表示。
一热编码(one-hot encoding)也被称为局部表示(与分布式表示相对),因为特征表示是通过向量中单个元素的激活来决定的。
现在我们将讨论另一种表示单词的技术,称为 TF-IDF 方法。
TF-IDF 方法
TF-IDF 是一种基于频率的方法,考虑到单词在语料库中出现的频率。这是一种词表示,表示了一个特定单词在给定文档中的重要性。直观地说,单词出现的频率越高,说明这个单词在文档中的重要性越大。例如,在关于猫的文档中,单词 cats 会比在不涉及猫的文档中出现得更频繁。然而,仅仅计算频率是不够的,因为像 this 和 is 这样的单词在文档中非常频繁,但并没有提供太多信息。TF-IDF 考虑了这一点,并给这些常见单词分配接近零的值。
再次强调,TF 代表词频(term frequency),IDF 代表逆文档频率(inverse document frequency):
TF(w[i]) = w[i] 出现的次数 / 单词总数
IDF(w[i]) = log(文档总数 / 包含 w[i] 的文档数量)
TF-IDF(w[i]) = TF(w[i]) x IDF(w[i])
让我们做一个快速练习。考虑两个文档:
-
文档 1: This is about cats. Cats are great companions.
-
文档 2: This is about dogs. Dogs are very loyal.
现在让我们来做一些计算:
TF-IDF (cats, doc1) = (2/8) * log(2/1) = 0.075
TF-IDF (this, doc2) = (1/8) * log(2/2) = 0.0
因此,单词 cats 是有信息量的,而 this 不是。这就是我们在衡量单词重要性时所需要的期望行为。
共现矩阵
共现矩阵不同于 one-hot 编码表示,它编码了词汇的上下文信息,但需要维持一个 V × V 的矩阵。为了理解共现矩阵,我们来看两个例子句子:
-
杰瑞和玛丽是朋友。
-
杰瑞为玛丽买花。
共现矩阵将如下所示。我们仅显示矩阵的一半,因为它是对称的:
| Jerry | and | Mary | are | friends | buys | flowers | for | |
|---|---|---|---|---|---|---|---|---|
| Jerry | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 |
| and | 0 | 1 | 0 | 0 | 0 | 0 | 0 | |
| Mary | 0 | 1 | 0 | 0 | 0 | 1 | ||
| are | 0 | 1 | 0 | 0 | 0 | |||
| friends | 0 | 0 | 0 | 0 | ||||
| buys | 0 | 1 | 0 | |||||
| flowers | 0 | 1 | ||||||
| for | 0 |
然而,很容易看出,维持这样的共现矩阵是有成本的,因为随着词汇表大小的增加,矩阵的大小会呈多项式增长。此外,增加一个大于 1 的上下文窗口大小也并不简单。一种选择是使用加权计数,其中词汇在上下文中的权重随着与目标词的距离而减小。
如你所见,这些方法在表示能力上非常有限。
例如,在 one-hot 编码方法中,所有单词之间的向量距离是相同的。TF-IDF 方法用一个单一的数字表示一个词,无法捕捉词汇的语义。最后,计算共现矩阵非常昂贵,并且提供的关于词汇上下文的信息有限。
我们在这里结束关于词汇简单表示的讨论。在接下来的部分中,我们将通过实例首先培养对词嵌入的直观理解。然后我们将定义一个损失函数,以便使用机器学习来学习词嵌入。此外,我们还将讨论两种 Word2vec 算法,分别是 skip-gram 和 连续词袋(CBOW) 算法。
对 Word2vec 的直观理解 —— 一种学习词汇表示的方法
“你将通过一个词汇的伴侣知道它的含义。”
– J.R. Firth
这句话由 J. R. Firth 在 1957 年说出,它是 Word2vec 的基础,因为 Word2vec 技术利用给定词汇的上下文来学习其语义。
Word2vec 是一种开创性的方法,它允许计算机在没有任何人工干预的情况下学习词汇的含义。此外,Word2vec 通过观察给定词汇周围的词汇来学习词汇的数值表示。
我们可以通过想象一个真实场景来测试前面引述的正确性。想象你正在参加考试,在第一题中遇到这句话:“玛丽是一个非常固执的孩子。她固执的天性总是让她惹上麻烦。”现在,除非你非常聪明,否则你可能不知道 pervicacious 的意思。在这种情况下,你会自动被迫查看周围的词组。在我们的例子中,pervicacious 被 固执,天性 和 麻烦 包围。看这三个词就足够判断 pervicacious 其实意味着固执的状态。我认为这足以证明上下文对单词含义的重要性。
现在,让我们讨论一下 Word2vec 的基础知识。正如前面提到的,Word2vec 通过观察给定单词的上下文来学习该单词的含义,并以数字的形式表示它。
通过 上下文,我们指的是单词前后固定数量的词。假设我们有一个包含 N 个单词的假设语料库。用数学表示,这可以表示为一个单词序列,记为 w[0],w[1],…,w[i] 和 w[N],其中 w[i] 是语料库中的第 i 个单词。
接下来,如果我们想找到一个能够学习单词含义的好算法,给定一个单词,我们的算法应该能够正确预测上下文单词。
这意味着,对于任何给定的单词 w[i],以下概率应该很高:

为了得到等式的右边,我们需要假设,在给定目标单词(w[i])的情况下,上下文单词彼此之间是独立的(例如,w[i-2] 和 w[i-1] 是独立的)。尽管这并不完全正确,但这种近似使得学习问题变得可行,并且在实践中效果良好。让我们通过一个例子来理解这些计算。
练习:queen = king - he + she 吗?
在继续之前,让我们做一个小练习,了解如何通过最大化之前提到的概率来找到单词的良好含义(或表示)。考虑以下一个非常小的语料库:
曾经有一位非常富有的国王。他有一位美丽的王后。她非常善良。
为了简化练习,让我们手动预处理,去除标点符号和无信息的词:
曾经有富有的国王他有美丽的王后她很善良
现在,让我们为每个单词及其上下文单词形成一组元组,格式为(目标单词 --> 上下文单词 1,上下文单词 2)。我们假设上下文窗口大小为两边各 1:
was --> 富有
富有 --> 曾经有, 国王
国王 --> 富有, 他
he --> 国王, had
有 --> 他, 美丽的
美丽的 --> 有, 王后
王后 --> 美丽的, 她
她 --> 王后, 是
是 --> 她, 善良
善良 --> 曾经有
记住,我们的目标是能够根据左边的单词预测右边的单词。为此,对于一个给定的单词,右边语境中的单词应该与左边语境中的单词在数值或几何上有高度的相似性。换句话说,感兴趣的单词应该通过周围的单词来传达。现在,让我们考虑实际的数值向量,以了解这一过程是如何运作的。为了简单起见,让我们只考虑加粗的元组。让我们从假设rich这个单词的情况开始:
rich --> [0,0]
为了能够正确预测was和king,这两个词应与rich有较高的相似度。将使用欧几里得距离来衡量单词之间的距离。
让我们尝试以下king和rich的值:
king --> [0,1]
was --> [-1,0]
这一点是可行的,如下所示:
Dist(rich,king) = 1.0
Dist(rich,was) = 1.0
这里,Dist表示两个单词之间的欧几里得距离。如图 3.3所示:

图 3.2:单词“rich”,“was”和“king”的词向量位置
现在让我们考虑以下元组:
king --> rich, he
我们已经建立了king和rich之间的关系。然而,这还没有完成;我们看到的关系越多,这两个单词之间的距离应该越近。因此,让我们首先调整king的向量,使其更接近rich:
king --> [0,0.8]
接下来,我们需要将单词he添加到图中。单词he应该更接近king。这是我们目前关于单词he的所有信息:he --> [0.5,0.8]。
目前,带有单词的图表看起来像图 3.4:

图 3.3:单词“rich”,“was”,“king”和“he”的词向量位置
现在让我们继续下两个元组:queen --> beautiful, she 和 she --> queen, was。请注意,我交换了元组的顺序,这样我们更容易理解示例:
she --> queen, was
现在,我们将需要使用我们先前的英语知识来继续。
将单词she与was保持与he与was相同的距离是一个合理的决定,因为它们在was这个单词的语境中的使用是等价的。因此,让我们使用这个:
she --> [0.5,0.6]
接下来,我们将使用与单词she接近的queen:queen --> [0.0,0.6]。
如图 3.5所示:

图 3.4:单词“rich”,“was”,“king”,“he”,“she”和“queen”的词向量位置
接下来,我们只有以下元组:
queen --> beautiful, she
这里找到了单词beautiful。它应该与单词queen和she保持大致相同的距离。让我们使用以下表示:
beautiful --> [0.25,0]
现在,我们有了以下图表,描绘了词之间的关系。当我们观察图 3.6时,它似乎是对单词含义的非常直观的表示:

图 3.5:词向量在“rich”,“was”,“king”,“he”,“she”,“queen”,“beautiful”这些词上的位置
现在,让我们来看一下从一开始就萦绕在我们心中的问题。这些方程式中的数量是等价的吗:皇后 = 国王 - 他 + 她?好了,我们现在拥有了解开这个谜题所需的所有资源。让我们先尝试方程式的右侧:
= 国王 – 他 + 她
= [0,0.8] – [0.5,0.8] + [0.5,0.6]
= [0,0.6]
最终一切都得到了验证。如果你看我们为单词queen得到的词向量,你会发现这与我们之前推导出的答案完全相同。
注意,这是一种粗略的方式来展示如何学习词嵌入,并且这可能与使用算法学习到的词嵌入的确切位置有所不同。
另外,请记住,这个练习在规模上不现实,与真实世界语料库的样子相比有很大的简化。因此,您无法仅通过手动计算几个数字来推导出这些值。复杂的函数逼近器,如神经网络,替我们完成了这项工作。但是,为了使用神经网络,我们需要以数学上严谨的方式来表述问题。然而,这个练习是展示词向量能力的一个很好的方法。
现在,我们对 Word2vec 如何帮助我们学习词表示有了更好的理解,接下来让我们看一下 Word2vec 在接下来的两节中使用的实际算法。
跳字模型算法
我们要讲解的第一个算法被称为跳字模型算法:一种 Word2vec 算法。如我们在多个地方讨论过的,单词的含义可以从其上下文单词中推断出来。然而,开发一个利用这种方式来学习单词含义的模型并不是完全直接的。由 Mikolov 等人在 2013 年提出的跳字模型算法,正是利用文本中单词的上下文来学习良好的词嵌入。
让我们一步步讲解跳字模型算法。首先,我们将讨论数据准备过程。了解数据的格式使我们能够更好地理解算法。然后,我们将讨论算法本身。最后,我们将使用 TensorFlow 实现该算法。
从原始文本到半结构化文本
首先,我们需要设计一个机制来提取可以输入到学习模型的数据集。这样的数据集应该是(目标词,上下文词)格式的元组集合。此外,这一过程需要以无监督的方式进行。也就是说,不需要人工为数据手动标注标签。总之,数据准备过程应该完成以下工作:
-
捕捉给定单词的周围单词(即上下文)
-
以无监督的方式运行
skip-gram 模型采用以下方法设计数据集:
-
对于给定的单词 w[i],假设其上下文窗口大小为 m。所谓的上下文窗口大小,是指在单侧考虑的上下文单词数量。因此,对于 w[i],上下文窗口(包括目标单词 w[i])的大小将为 2m+1,并且将呈现如下形式: [w[i-m], …, w[i-1], w[i], w[i+1], …, w[i+m]]。
-
接下来,(目标词, 上下文词) 的元组将被构建为 […, (w[i], w[i-m]), …, (w[i],w[i-1]), (w[i],w[i+1]), …, (w[i],w[i+m]), …];其中,
和 N 是文本中的单词数。让我们使用以下句子,设定上下文窗口大小(m)为 1:
The dog barked at the mailman。
对于这个例子,数据集将如下所示:
[(dog, The), (dog, barked), (barked, dog), (barked, at), …, (the, at), (the, mailman)]
一旦数据转化为 (目标词, 上下文词) 的格式,我们就可以使用神经网络来学习词向量。
理解 skip-gram 算法
首先,我们需要确定学习词向量所需的变量和符号。为了存储词向量,我们需要两个 V × D 的矩阵,其中 V 是词汇表的大小,D 是词向量的维度(即表示单个单词的向量中的元素个数)。D 是一个用户定义的超参数。D 越大,学习到的词向量就越具有表现力。我们需要两个矩阵,一个用于表示上下文单词,另一个用于表示目标单词。这些矩阵将被称为 上下文 嵌入空间(或上下文嵌入层) 和 目标 嵌入空间(或目标嵌入层),或者通常称为嵌入空间(或嵌入层)。
每个单词将在范围[1,V+1]内用唯一的 ID 表示。这些 ID 将传递给嵌入层以查找对应的向量。为了生成这些 ID,我们将使用 TensorFlow 中可用的一个名为 Tokenizer 的特殊对象。让我们参考一个示例目标-上下文元组(w[i], w[j]),其中目标词 ID 是w[i],而上下文词之一是w[j]。w[i]的相应目标嵌入是t[i],而w[j]的相应上下文嵌入是c[j]。每个目标-上下文元组都伴随一个标签(0 或 1),由y[i]表示,真实的目标-上下文对将获得标签 1,而负(或假)目标-上下文候选将获得标签 0。通过对不在给定目标词上下文中出现的单词进行抽样,很容易生成负目标-上下文候选。稍后我们将详细讨论这一点。
此时,我们已经定义了必要的变量。接下来,对于每个输入w[i],我们将从上下文嵌入层中查找对应于输入的嵌入向量。这个操作为我们提供了c[i],这是一个D大小的向量(即,一个D长的嵌入向量)。我们对输入w[j]执行相同操作,使用上下文嵌入空间检索c[j]。随后,我们使用以下转换计算(w[i] ,w[i])的预测输出:
logit(w[i], w[i]) = c[i] .t[j]
ŷ[ij] = sigmoid(logit(w[i], w[i]))
这里,logit(w[i], w[i])表示未归一化的分数(即 logits),ŷ[i]是单值预测输出(表示上下文词属于目标词上下文的概率)。
我们将同时展示跳字模型的概念(图 3.7)和实现(图 3.8)。以下是符号的总结:
-
V: 词汇表的大小
-
D: 这是嵌入层的维度
-
w[i]: 目标词
-
w[j]: 上下文词
-
t[i]: 单词w[i]的目标嵌入
-
c[j]: 单词w[j]的上下文嵌入
-
y[i]: 这是与x[i]对应的单热编码输出词
-
ŷ[i]: 这是x[i]的预测输出
-
logit(w[i], w[j]): 这是输入x[i]的未归一化分数

图 3.6: 跳字模型的概念

图 3.7: 跳字模型的实现
使用现有和派生的实体,我们现在可以使用交叉熵损失函数来计算给定数据点[(w[i], w[j]), y[i]]的损失。
对于二元标签,单个样本的交叉熵损失为
:

其中
是
的预测标签。对于多类分类问题,我们通过计算每个类别的项
来推广损失:

其中
表示
索引的值,
是表示数据点标签的一维独热编码向量。
通常,在训练神经网络时,这个损失值是针对给定批次中的每个样本计算的,然后取平均以计算批次的损失值。最后,批次的损失值在数据集中的所有批次上取平均,以计算最终的损失值。
为什么原始的词嵌入论文使用了两个嵌入层?
原始论文(由 Mikolov 等人,2013 年)使用了两个不同的 V × D 嵌入空间来表示目标空间中的单词(作为目标使用的单词)和上下文空间中的单词(作为上下文单词使用的单词)。这样做的一个动机是单词在自己的上下文中出现的频率较低。因此,我们希望尽量减少这种情况发生的概率。
例如,对于目标单词 dog,很少可能在它的上下文中也出现单词 dog(P(dog|dog) ~ 0)。直观地说,如果我们将数据点(w[i]=dog 和 w[j]=dog)输入神经网络,我们要求神经网络如果预测 dog 为 dog 的上下文单词时,给出较高的损失值。
换句话说,我们要求单词 dog 的词嵌入与单词 dog 的词嵌入之间有非常大的距离。这会产生一个强烈的矛盾,因为同一单词的嵌入之间的距离应该是 0。因此,如果我们只有一个嵌入空间,无法实现这一点。
然而,拥有两个独立的目标单词和上下文单词的嵌入空间使我们能够具备这一特性,因为这样我们为同一个单词拥有了两个独立的嵌入向量。在实践中,只要避免将输入输出元组相同,输入和输出都是同一个单词时,我们可以使用单一的嵌入空间,并且不需要两个独立的嵌入层。
现在让我们使用 TensorFlow 来实现数据生成过程。
使用 TensorFlow 实现和运行 skip-gram 算法
现在我们将深入探索 TensorFlow,并从头到尾实现该算法。首先,我们将讨论我们要使用的数据以及 TensorFlow 如何帮助我们将数据转换为模型所接受的格式。我们将使用 TensorFlow 实现 skip-gram 算法,最后训练模型并在准备好的数据上进行评估。
使用 TensorFlow 实现数据生成器
首先,我们将研究如何以模型接受的正确格式生成数据。在这个练习中,我们将使用mlg.ucd.ie/datasets/bbc.html中提供的 BBC 新闻文章数据集。该数据集包含 2,225 篇新闻文章,涵盖 5 个主题:商业、娱乐、政治、体育和科技,这些文章是在 2004 年至 2005 年间发布在 BBC 网站上的。
我们在下面编写了download_data()函数,用于将数据下载到指定的文件夹并从压缩格式中提取数据:
def download_data(url, data_dir):
"""Download a file if not present, and make sure it's the right
size."""
os.makedirs(data_dir, exist_ok=True)
file_path = os.path.join(data_dir, 'bbc-fulltext.zip')
if not os.path.exists(file_path):
print('Downloading file...')
filename, _ = urlretrieve(url, file_path)
else:
print("File already exists")
extract_path = os.path.join(data_dir, 'bbc')
if not os.path.exists(extract_path):
with zipfile.ZipFile(
os.path.join(data_dir, 'bbc-fulltext.zip'),
'r'
) as zipf:
zipf.extractall(data_dir)
else:
print("bbc-fulltext.zip has already been extracted")
该函数首先创建data_dir,如果它不存在的话。接下来,如果bbc-fulltext.zip文件不存在,它将从提供的 URL 下载。如果bbc-fulltext.zip尚未解压,它将被解压到data_dir。
我们可以如下调用这个函数:
url = 'http://mlg.ucd.ie/files/datasets/bbc-fulltext.zip'
download_data(url, 'data')
接下来,我们将专注于将新闻文章中的数据(以.txt格式)读取到内存中。为此,我们将定义read_data()函数,该函数接受一个数据目录路径(data_dir),并读取数据目录中的.txt文件(不包括 README 文件):
def read_data(data_dir):
news_stories = []
print("Reading files")
for root, dirs, files in os.walk(data_dir):
for fi, f in enumerate(files):
if 'README' in f:
continue
print("."*fi, f, end='\r')
with open(os.path.join(root, f), encoding='latin-1') as f:
story = []
for row in f:
story.append(row.strip())
story = ' '.join(story)
news_stories.append(story)
print(f"\nDetected {len(news_stories)} stories")
return news_stories
定义好read_data()函数后,我们来使用它读取数据并打印一些样本以及一些统计信息:
news_stories = read_data(os.path.join('data', 'bbc'))
print(f"{sum([len(story.split(' ')) for story in news_stories])} words found in the total news set")
print('Example words (start): ',news_stories[0][:50])
print('Example words (end): ',news_stories[-1][-50:])
这将打印出以下内容:
Reading files
............. 361.txt
Detected 2225 stories
865163 words found in the total news set
Example words (start): Windows worm travels with Tetris Users are being
Example words (end): is years at Stradey as "the best time of my life."
正如我们在本节开始时所说的,系统中包含 2,225 个故事,总字数接近一百万。接下来的步骤,我们需要将每个故事(以长字符串的形式)进行分词,转换成一个令牌(或单词)列表。同时,我们还将对文本进行一些预处理:
-
将所有字符转换为小写
-
移除标点符号
所有这些都可以通过tensorflow.keras.preprocessing.text.Tokenizer对象来实现。我们可以如下定义一个 Tokenizer:
from tensorflow.keras.preprocessing.text import Tokenizer
tokenizer = Tokenizer(
num_words=None,
filters='!"#$%&()*+,-./:;<=>?@[\\]^_'{|}~\t\n',
lower=True,
split=' '
)
在这里,您可以看到定义 Tokenizer 时常用的一些关键字参数及其默认值:
-
num_words– 定义词汇表的大小。默认为None,表示它会考虑文本语料库中出现的所有单词。如果设置为整数 n,它将只考虑语料库中出现的 n 个最常见单词。 -
filters– 定义在预处理过程中需要排除的字符。默认情况下,它定义了一个包含大多数常见标点符号和符号的字符串。 -
lower– 定义是否需要将文本转换为小写。 -
split– 定义用于分词的字符。
一旦定义了 Tokenizer,您可以调用其fit_on_texts()方法并传入一个字符串列表(每个字符串都是一篇新闻文章),这样 Tokenizer 就会学习词汇表并将单词映射到唯一的 ID:
tokenizer.fit_on_texts(news_stories)
让我们花点时间分析一下 Tokenizer 在文本拟合后产生的结果。一旦 Tokenizer 被拟合,它将填充两个重要的属性:word_index和index_word。其中,word_index是一个字典,将每个单词映射到一个唯一的 ID。index_word属性是word_index的反向映射,即一个字典,将每个唯一的单词 ID 映射到相应的单词:
n_vocab = len(tokenizer.word_index.items())+1
print(f"Vocabulary size: {n_vocab}")
print("\nWords at the top")
print('\t', dict(list(tokenizer.word_index.items())[:10]))
print("\nWords at the bottom")
print('\t', dict(list(tokenizer.word_index.items())[-10:]))
请注意,我们是如何通过word_index字典的长度来推导词汇表大小的。我们需要额外加 1,因为 ID 0 是保留的 ID,不会用于任何单词。这样将输出以下内容:
Vocabulary size: 32361
Words at the top
{'the': 1, 'to': 2, 'of': 3, 'and': 4, 'a': 5, 'in': 6, 'for': 7, 'is': 8, 'that': 9, 'on': 10}
Words at the bottom
{'counsellor': 32351, "'frag'": 32352, 'relasing': 32353, "'real'": 32354, 'hrs': 32355, 'enviroment': 32356, 'trifling': 32357, '24hours': 32358, 'ahhhh': 32359, 'lol': 32360}
一个词在语料库中出现得越频繁,它的 ID 就越低。像“the”、“to”和“of”这样的常见词(被称为停用词)实际上是最常见的单词。接下来的步骤,我们将精细调整我们的分词器对象,以便它具有一个有限大小的词汇表。因为我们处理的是一个相对较小的语料库,所以我们必须确保词汇表不要太大,因为过大的词汇表可能由于数据不足而导致单词向量学习不佳:
from tensorflow.keras.preprocessing.text import Tokenizer
tokenizer = Tokenizer(
num_words=15000,
filters='!"#$%&()*+,-./:;<=>?@[\\]^_'{|}~\t\n',
lower=True, split=' ', oov_token='',
)
tokenizer.fit_on_texts(news_stories)
由于我们的词汇表包含超过 30,000 个单词,我们将词汇表大小限制为 15,000。这样,分词器将只保留最常见的 15,000 个单词作为词汇表。当我们以这种方式限制词汇表时,出现了一个新问题。由于分词器的词汇表并不包含真实词汇表中的所有可能单词,可能会出现词汇表外的单词(即 OOV 单词)。一种解决方案是用一个特殊的标记(如 <UNK>)替换 OOV 单词,或者将它们从语料库中移除。通过将要替换 OOV 标记的字符串传递给分词器的 oov_token 参数,可以实现这一点。在这种情况下,我们将删除 OOV 单词。如果我们在设置词汇表大小时小心谨慎,忽略一些稀有词不会影响准确学习单词的上下文。
我们可以查看分词器对文本进行的转换。接下来,让我们转换我们语料库中第一篇故事的前 100 个字符(存储在 news_stories 变量中):
print(f"Original: {news_stories[0][:100]}")
然后,我们可以调用 tokenizer 的 texts_to_sequences() 方法,将一组文档(每个文档是一个字符串)转换为一个包含单词 ID 列表的列表(即每个文档都转换为一个单词 ID 列表)。
print(f"Sequence IDs: {tokenizer.texts_to_sequences([news_stories[0][:100]])[0]}")
这将打印输出:
Original: Ad sales boost Time Warner profit Quarterly profits at US media giant TimeWarner jumped 76% to $1.1
Sequence IDs: [4223, 187, 716, 66, 3596, 1050, 3938, 626, 21, 49, 303, 717, 8263, 2972, 5321, 3, 108, 108]
现在我们的分词器已经配置好了。接下来,我们只需要用一行代码将所有新闻文章转换为单词 ID 的序列:
news_sequences = tokenizer.texts_to_sequences(news_stories)
接下来,我们使用 TensorFlow 提供的 tf.keras.preprocessing.sequence.skipgrams() 函数生成跳字模型。我们在一个示例短语上调用该函数,示例短语代表从数据集中提取的前 5 个单词:
sample_word_ids = news_sequences[0][:5]
sample_phrase = ' '.join([tokenizer.index_word[wid] for wid in sample_word_ids])
print(f"Sample phrase: {sample_phrase}")
print(f"Sample word IDs: {sample_word_ids }\n")
这将输出:
Sample phrase: ad sales boost time warner
Sample word IDs: [4223, 187, 716, 66, 3596]
让我们考虑一个窗口大小为 1。意味着对于给定的目标单词,我们定义上下文为目标单词两侧各一个单词。
window_size = 1 # How many words to consider left and right.
我们已经具备了从我们选择的示例短语中提取跳字模型的所有要素。运行时,此函数将输出我们需要的数据格式,即(目标-上下文)元组作为输入,相应的标签(0 或 1)作为输出:
inputs, labels = tf.keras.preprocessing.sequence.skipgrams(
sequence=sample_word_ids,
vocabulary_size=n_vocab,
window_size=window_size,
negative_samples=1.0,
shuffle=False,
categorical=False,
sampling_table=None,
seed=None
)
让我们花一点时间来回顾一些重要的参数:
-
sequence(list[str]或list[int])– 一个包含单词或单词 ID 的列表。 -
vocabulary_size(int)– 词汇表的大小。 -
window_size(int)– 要考虑的上下文窗口大小。window_size定义了窗口的两侧长度。 -
negative_samples(int)– 生成负向候选词的比例。例如,值为 1 表示正向和负向 skipgram 候选词的数量相等。值为 0 则表示不会生成负向候选词。 -
shuffle(bool)– 是否对生成的输入进行洗牌。 -
categorical (bool)– 是否将标签生成分类形式(即,独热编码)或整数。 -
sampling_table(np.ndarray)– 与词汇表大小相同的数组。数组中给定位置的元素表示根据该位置在分词器的词 ID 到词映射中的索引采样该单词的概率。正如我们很快会看到的,这是一种便捷的方法,可以避免常见的无信息词被过度采样。 -
seed(int)– 如果启用了洗牌,这是用于洗牌的随机种子。
在生成输入和标签后,我们来打印一些数据:
print("Sample skip-grams")
for inp, lbl in zip(inputs, labels):
print(f"\tInput: {inp} ({[tokenizer.index_word[wi] for wi in inp]}) /
Label: {lbl}")
这将产生:
Sample skip-grams
Input: [4223, 187] (['ad', 'sales']) / Label: 1
Input: [187, 4223] (['sales', 'ad']) / Label: 1
Input: [187, 716] (['sales', 'boost']) / Label: 1
Input: [716, 187] (['boost', 'sales']) / Label: 1
Input: [716, 66] (['boost', 'time']) / Label: 1
Input: [66, 716] (['time', 'boost']) / Label: 1
Input: [66, 3596] (['time', 'warner']) / Label: 1
Input: [3596, 66] (['warner', 'time']) / Label: 1
Input: [716, 9685] (['boost', "kenya's"]) / Label: 0
Input: [3596, 12251] (['warner', 'rear']) / Label: 0
Input: [4223, 3325] (['ad', 'racing']) / Label: 0
Input: [66, 7978] (['time', 'certificate']) / Label: 0
Input: [716, 12756] (['boost', 'crushing']) / Label: 0
Input: [66, 14543] (['time', 'touchy']) / Label: 0
Input: [187, 3786] (['sales', '9m']) / Label: 0
Input: [187, 3917] (['sales', 'doherty']) / Label: 0
例如,由于单词“sales”出现在“ad”这个词的上下文中,因此它被视为一个正向候选词。另一方面,由于单词“racing”(从词汇中随机抽取)没有出现在“ad”这个词的上下文中,因此它被视为一个负向候选词。
在选择负向候选词时,skipgrams() 函数会随机选择它们,并对词汇表中的所有单词赋予相同的权重。然而,原文中解释说,这可能导致性能不佳。一种更好的策略是使用 unigram 分布作为选择负向上下文词的先验。
你可能会想知道什么是 unigram 分布。它表示文本中单字(或标记)的频率计数。然后,通过将这些频率除以所有频率的总和,频率计数可以轻松地转换为概率(或归一化频率)。最神奇的是,你不需要手动为每个文本语料库计算这个!事实证明,如果你取一个足够大的文本语料库,计算 unigram 的归一化频率,并将它们从高到低排序,你会发现语料库大致遵循某种恒定的分布。对于一个包含 math 个 unigram 的语料库中排名为 math 的单词,其归一化频率 f[k] 给出如下公式:

这里,math 是一个超参数,可以调节以更接近真实分布。这就是所谓的 Zipf’s law。换句话说,如果你有一个词汇表,其中单词按从最常见到最不常见的顺序排列(ID 排序),你可以使用 Zipf 定律来近似每个单词的归一化频率。我们将根据 Zipf 定律输出的概率来采样单词,而不是对所有单词赋予相等的概率。这意味着单词的采样将根据它们在语料库中的出现频率进行(也就是说,越常见的单词,越有可能被采样)。
为此,我们可以使用 tf.random.log_uniform_candidate_sampler() 函数。该函数接受一个大小为 [b, num_true] 的正上下文候选词批次,其中 b 是批次大小,num_true 是每个示例的真实候选词数量(对于 skip-gram 模型来说为 1),并输出一个大小为 [num_sampled] 的数组,其中 num_sampled 是我们需要的负样本数量。我们稍后将详细讨论这个函数的工作原理,并通过实际操作进行说明。但在此之前,让我们先使用 tf.keras.preprocessing.sequence.skipgrams() 函数生成一些正向候选词:
inputs, labels = tf.keras.preprocessing.sequence.skipgrams(
sample_phrase_word_ids,
vocabulary_size=len(tokenizer.word_index.items())+1,
window_size=window_size,
negative_samples=0,
shuffle=False
)
inputs, labels = np.array(inputs), np.array(labels)
请注意,我们指定了 negative_samples=0,因为我们将使用候选样本生成器来生成负样本。接下来我们讨论如何使用 tf.random.log_uniform_candidate_sampler() 函数来生成负候选词。这里我们将首先使用该函数为单个词生成负候选词:
negative_sampling_candidates, true_expected_count, sampled_expected_count = tf.random.log_uniform_candidate_sampler(
true_classes=inputs[:1, 1:], # [b, 1] sized tensor
num_true=1, # number of true words per example
num_sampled=10,
unique=True,
range_max=n_vocab,
name="negative_sampling"
)
这个函数接受以下参数:
-
true_classes(np.ndarray或tf.Tensor)– 一个包含真实目标词的张量。它需要是一个大小为 [b, num_true] 的数组,其中num_true表示每个示例的真实上下文候选词的数量。由于每个示例只有一个上下文词,这个值为 1。 -
num_true(int)– 每个示例的真实上下文词的数量。 -
num_sampled(int)– 要生成的负样本数量。 -
unique(bool)– 是否生成唯一样本或允许重复采样。 -
range_max(int)– 词汇表的大小。
它返回:
-
sampled_candidates(tf.Tensor)– 一个大小为 [num_sampled] 的张量,包含负候选词。 -
true_expected_count(tf.Tensor)– 一个大小为 [b, num_true] 的张量;表示每个真实候选词被抽样的概率(根据齐普夫定律)。 -
sampled_expected_count(tf.Tensor)– 一个大小为 [num_sampled] 的张量;如果从语料库中抽取,表示每个负样本与真实候选词一同出现的概率。
我们不必过于担心后面两个实体。对我们来说,最重要的是 sampled_candidates。调用该函数时,我们必须确保 true_classes 的形状是 [b, num_true]。在我们的情况下,我们将在单个输入词 ID 上运行该函数,形状为 [1, 1]。它将返回以下内容:
Positive sample: [[187]]
Negative samples: [ 1 10 9744 3062 139 5 14 78 1402 115]
true_expected_count: [[0.00660027]]
sampled_expected_count: [4.0367463e-01 1.0333969e-01 1.2804421e-04 4.0727769e-04 8.8460185e-03
1.7628242e-01 7.7631921e-02 1.5584969e-02 8.8879210e-04 1.0659459e-02]
现在,将所有内容结合起来,我们来编写一个数据生成器函数,为模型生成数据批次。这个函数名为 skip_gram_data_generator(),接受以下参数:
-
sequences(List[List[int]])– 一个包含词 ID 的列表列表。这是由分词器的texts_to_sequences()函数生成的输出。 -
window_size(int)– 上下文窗口大小。 -
batch_size(int)– 批次大小。 -
negative_samples(int)– 每个示例要生成的负样本数量。 -
vocabulary_size(int)– 词汇表大小。 -
seed– 随机种子。
它将返回一个包含以下内容的数据批次:
-
一批目标词 ID
-
一批对应的上下文词 ID(包括正例和负例)
-
一批标签(0 和 1)
函数签名如下:
def skip_gram_data_generator(sequences, window_size, batch_size, negative_samples, vocab_size, seed=None):
首先,我们将打乱新闻文章的顺序,这样每次生成数据时,它们都会以不同的顺序被获取。这有助于模型更好地进行泛化:
rand_sequence_ids = np.arange(len(sequences))
np.random.shuffle(rand_sequence_ids)
接下来,对于语料库中的每个文本序列,我们生成正向 skip-gram。positive_skip_grams包含按顺序排列的(target, context)词对元组:
for si in rand_sequence_ids:
positive_skip_grams, _ =
tf.keras.preprocessing.sequence.skipgrams(
sequences[si],
vocabulary_size=vocab_size,
window_size=window_size,
negative_samples=0.0,
shuffle=False,
sampling_table=sampling_table,
seed=seed
)
请注意,我们传递了一个sampling_table参数。这是提高 Word2vec 模型性能的另一种策略。sampling_table只是一个与词汇表大小相同的数组,并在数组的每个索引中指定一个概率,该索引处的词将会在 skip-gram 生成过程中被采样。这个技术被称为子采样。每个词w[i]的采样概率由以下公式给出:

这里,t是一个可调参数。对于足够大的语料库,它的默认值为 0.00001。在 TensorFlow 中,您可以通过如下方式轻松生成此表:
计算采样表时不需要确切的频率,因为我们可以利用齐普夫定律来近似这些频率:
sampling_table = tf.keras.preprocessing.sequence.make_sampling_table(
n_vocab, sampling_factor=1e-05
)
对于positive_skip_grams中包含的每个元组,我们生成negative_samples数量的负样本。然后,我们用正负样本填充目标、上下文和标签列表:
targets, contexts, labels = [], [], []
for target_word, context_word in positive_skip_grams:
context_class = tf.expand_dims(tf.constant([context_word],
dtype="int64"), 1)
negative_sampling_candidates, _, _ =
tf.random.log_uniform_candidate_sampler(
true_classes=context_class,
num_true=1,
num_sampled=negative_samples,
unique=True,
range_max=vocab_size,
name="negative_sampling")
# Build context and label vectors (for one target word)
context = tf.concat(
[tf.constant([context_word], dtype='int64'),
negative_sampling_candidates],
axis=0
)
label = tf.constant([1] + [0]*negative_samples,
dtype="int64")
# Append each element from the training example to global
# lists.
targets.extend([target_word]*(negative_samples+1))
contexts.append(context)
labels.append(label)
然后,我们将按如下方式将这些转换为数组,并随机打乱数据。在打乱时,您必须确保所有数组都一致地被打乱。否则,您将会破坏与输入相关联的标签:
contexts, targets, labels = np.concatenate(contexts),
np.array(targets), np.concatenate(labels)
# If seed is not provided generate a random one
if not seed:
seed = random.randint(0, 10e6)
np.random.seed(seed)
np.random.shuffle(contexts)
np.random.seed(seed)
np.random.shuffle(targets)
np.random.seed(seed)
np.random.shuffle(labels)
最后,数据批次生成如下:
for eg_id_start in range(0, contexts.shape[0], batch_size):
yield (
targets[eg_id_start: min(eg_id_start+batch_size,
inputs.shape[0])],
contexts[eg_id_start: min(eg_id_start+batch_size,
inputs.shape[0])]
), labels[eg_id_start: min(eg_id_start+batch_size,
inputs.shape[0])]
接下来,我们将查看我们将要使用的模型的具体细节。
使用 TensorFlow 实现 skip-gram 架构
现在,我们将走过一个使用 TensorFlow 库实现的 skip-gram 算法。完整的练习可以在Ch3_word2vec.ipynb中找到,该文件位于Ch03-Word-Vectors练习目录中。
首先,让我们定义模型的超参数。您可以自由更改这些超参数,查看它们如何影响最终的性能(例如,batch_size = 1024 或 batch_size = 2048)。然而,由于这是一个比复杂的现实世界问题更简单的问题,您可能不会看到任何显著差异(除非您将它们更改为极端值,例如,batch_size = 1 或 num_sampled = 1):
batch_size = 4096 # Data points in a single batch
embedding_size = 128 # Dimension of the embedding vector.
window_size=1 # We use a window size of 1 on either side of target word
negative_samples = 4 # Number of negative samples generated per example
epochs = 5 # Number of epochs to train for
# We pick a random validation set to sample nearest neighbors
valid_size = 16 # Random set of words to evaluate similarity on.
# We sample valid datapoints randomly from a large window without always
# being deterministic
valid_window = 250
# When selecting valid examples, we select some of the most frequent words # as well as some moderately rare words as well
np.random.seed(54321)
random.seed(54321)
valid_term_ids = np.array(random.sample(range(valid_window), valid_size))
valid_term_ids = np.append(
valid_term_ids, random.sample(range(1000, 1000+valid_window),
valid_size),
axis=0
)
接下来,我们定义模型。为此,我们将依赖 Keras 的功能性 API。我们需要超越最简单的 API,即顺序 API,因为这个模型需要两个输入流(一个用于上下文,另一个用于目标)。
我们将首先进行导入。然后清除任何当前正在运行的会话,以确保没有其他模型占用硬件:
import tensorflow.keras.backend as K
K.clear_session()
我们将定义两个输入层:
# Inputs - skipgrams() function outputs target, context in that order
input_1 = tf.keras.layers.Input(shape=(), name='target')
input_2 = tf.keras.layers.Input(shape=(), name='context')
注意shape是如何定义为()的。当定义shape参数时,实际的输出形状将会添加一个新的未定义维度(即大小为None)。换句话说,最终的输出形状将是[None]。
接下来,我们定义两个嵌入层:目标嵌入层和上下文嵌入层。这些层将用于查找目标和上下文词 ID 的嵌入,这些词 ID 将由输入生成函数生成。
# Two embeddings layers are used one for the context and one for the
# target
target_embedding_layer = tf.keras.layers.Embedding(
input_dim=n_vocab, output_dim=embedding_size,
name='target_embedding'
)
context_embedding_layer = tf.keras.layers.Embedding(
input_dim=n_vocab, output_dim=embedding_size,
name='context_embedding'
)
定义好嵌入层后,接下来我们来看一下将传入输入层的词 ID 的嵌入:
# Lookup outputs of the embedding layers
target_out = target_embedding_layer(input_1)
context_out = context_embedding_layer(input_2)
我们现在需要计算target_out和context_out的点积。
为此,我们将使用tf.keras.layers.Dot层:
# Computing the dot product between the two
out = tf.keras.layers.Dot(axes=-1)([context_out, target_out])
最后,我们将模型定义为一个tf.keras.models.Model对象,其中我们指定了inputs和outputs参数。inputs需要是一个或多个输入层,而outputs可以是一个或多个由一系列tf.keras.layers对象生成的输出:
# Defining the model
skip_gram_model = tf.keras.models.Model(inputs=[input_1, input_2], outputs=out, name='skip_gram_model')
我们使用损失函数和优化器来编译模型:
# Compiling the model
skip_gram_model.compile(loss=tf.keras.losses.BinaryCrossentropy(from_logits=True), optimizer='adam', metrics=['accuracy'])
让我们通过调用以下内容来查看模型的摘要:
skip_gram_model.summary()
这将输出:
Model: "skip_gram_model"
________________________________________________________________________
Layer (type) Output Shape Param # Connected to
========================================================================
context (InputLayer) [(None,)] 0
_______________________________________________________________________
target (InputLayer) [(None,)] 0
_______________________________________________________________________
context_embedding (Embedding) (None, 128) 1920128 context[0][0]
_______________________________________________________________________
target_embedding (Embedding) (None, 128) 1920128 target[0][0]
_______________________________________________________________________
dot (Dot) (None, 1) 0 context_embedding[0][0]
target_embedding[0][0]
=======================================================================
Total params: 3,840,256
Trainable params: 3,840,256
Non-trainable params: 0
________________________________________________________________________
训练和评估模型将是我们接下来的议程。
训练和评估模型
我们的训练过程将非常简单,因为我们已经定义了一个函数来生成模型所需格式的数据批次。但在我们继续进行模型训练之前,我们需要考虑如何评估词向量模型。词向量的概念是共享语义相似性的词之间的距离较小,而没有相似性的词之间的距离较大。为了计算词与词之间的相似度,我们可以使用余弦距离。在我们的超参数讨论中,我们随机选取了一组词 ID 并将它们存储在valid_term_ids中。我们将在每个周期结束时实现一种方法,计算这些术语的最接近的k个词。
为此,我们使用 Keras 回调函数。Keras 回调函数为你提供了一种在每次训练迭代、每个周期、每个预测步骤等结束时执行重要操作的方式。你可以在www.tensorflow.org/api_docs/python/tf/keras/callbacks查看所有可用回调函数的完整列表。由于我们需要一个专门为词向量设计的评估机制,我们将需要实现自己的回调函数。我们的回调函数将接受一个包含验证词的词 ID 列表、一个包含嵌入矩阵的模型以及一个用于解码词 ID 的 Tokenizer:
class ValidationCallback(tf.keras.callbacks.Callback):
def __init__(self, valid_term_ids, model_with_embeddings, tokenizer):
self.valid_term_ids = valid_term_ids
self.model_with_embeddings = model_with_embeddings
self.tokenizer = tokenizer
super().__init__()
def on_epoch_end(self, epoch, logs=None):
""" Validation logic """
# We will use context embeddings to get the most similar words
# Other strategies include: using target embeddings, mean
# embeddings after avaraging context/target
embedding_weights =
self.model_with_embeddings.get_layer(
"context_embedding"
).get_weights()[0]
normalized_embeddings = embedding_weights /
np.sqrt(np.sum(embedding_weights**2, axis=1, keepdims=True))
# Get the embeddings corresponding to valid_term_ids
valid_embeddings = normalized_embeddings[self.valid_term_ids,
:]
# Compute the similarity between valid_term_ids and all the
# embeddings
# V x d (d x D) => V x D
top_k = 5 # Top k items will be displayed
similarity = np.dot(valid_embeddings, normalized_embeddings.T)
# Invert similarity matrix to negative
# Ignore the first one because that would be the same word as the
# probe word
similarity_top_k = np.argsort(-similarity, axis=1)[:, 1:
top_k+1]
# Print the output
for i, term_id in enumerate(valid_term_ids):
similar_word_str = ', '.join([self.tokenizer.index_word[j]
for j in similarity_top_k[i, :] if j > 1])
print(f"{self.tokenizer.index_word[term_id]}:
{similar_word_str }")
print('\n')
评估将在每个训练周期结束时进行,因此我们将重写on_epoch_end()函数。该函数从上下文嵌入层中提取嵌入。
然后,嵌入向量被归一化为单位长度。之后,提取与验证词对应的嵌入向量到一个单独的矩阵中,称为valid_embeddings。接着计算验证嵌入与所有词嵌入之间的余弦距离,得到一个[valid_size, vocabulary size]大小的矩阵。我们从中提取出最相似的k个词,并通过print语句显示它们。
最终,模型可以按如下方式进行训练:
skipgram_validation_callback = ValidationCallback(valid_term_ids, skip_gram_model, tokenizer)
for ei in range(epochs):
print(f"Epoch: {ei+1}/{epochs} started")
news_skip_gram_gen = skip_gram_data_generator(
news_sequences, window_size, batch_size, negative_samples,
n_vocab
)
skip_gram_model.fit(
news_skip_gram_gen, epochs=1,
callbacks=skipgram_validation_callback,
)
我们首先简单地定义了一个回调实例。接下来,我们训练模型若干个周期。在每个周期中,我们生成跳字模型数据(同时打乱文章的顺序),并对数据调用skip_gram_model.fit()。以下是五个周期训练后的结果:
Epoch: 5/5 ended
2233/2233 [==============================] - 146s 65ms/step - loss: 0.4842 - accuracy: 0.8056
months: days, weeks, years, detained, meaning
were: are, was, now, davidson, widened
mr: resignation, scott, tony, stead, article
champions: premier, pottage, kampala, danielli, dominique
businesses: medium, port, 2002's, tackling, doug
positive: electorate, proposal, bolz, visitors', strengthen
pop: 'me', style, lacks, tourism, tuesdays
在这里,我们展示了一些最具代表性的学习到的词向量。例如,我们可以看到,与“months”最相似的两个词是“days”和“weeks”。“mr”这一称呼常与男性名字如“scott”和“tony”一起出现。词语“premier”与“champion”具有相似性。你还可以进一步实验:
-
可在
www.tensorflow.org/api_docs/python/tf/random找到不同的负样本候选采样方法 -
不同的超参数选择(例如嵌入向量大小和负样本数量)
在本节中,我们从头到尾讨论了跳字算法。我们展示了如何使用 TensorFlow 中的函数来转换数据。然后我们使用 Keras 中的层和功能性 API 实现了跳字架构。最后,我们训练了模型,并在一些测试数据上直观地检查了其性能。接下来,我们将讨论另一个流行的 Word2vec 算法——连续词袋(CBOW)模型。
连续词袋模型(Continuous Bag-of-Words)
CBOW 模型与跳字模型算法的工作原理类似,但在问题的表述上有一个显著的变化。在跳字模型中,我们从目标词预测上下文词。然而,在 CBOW 模型中,我们从上下文词预测目标词。我们通过取前面例子中的句子来比较跳字算法和 CBOW 模型的数据表现:
The dog barked at the mailman.
对于跳字算法,数据元组—(输入词, 输出词)—可能看起来是这样的:
(dog, the),(dog, barked),(barked, dog),等等
对于 CBOW,数据元组则会是如下形式:
([the, barked], dog), ([dog, at], barked),等等
因此,CBOW 的输入维度为 2 × m × D,其中m是上下文窗口的大小,D是嵌入向量的维度。CBOW 的概念模型如图 3.13所示:

图 3.8:CBOW 模型
我们不会过多讨论 CBOW 的细节,因为它与 skip-gram 非常相似。例如,一旦嵌入被聚合(即拼接或求和),它们将通过 softmax 层,最终计算出与 skip-gram 算法相同的损失。然而,我们将讨论该算法的实现(尽管不深入),以便清楚地理解如何正确实现 CBOW。CBOW 的完整实现可在Ch03-Word-Vectors练习文件夹中的ch3_word2vec.ipynb中找到。
为 CBOW 算法生成数据
不幸的是,与 skip-gram 算法不同,我们没有现成的函数来为 CBOW 算法生成数据。因此,我们需要自己实现这个函数。
你可以在Ch03-Word-Vectors文件夹中的ch3_word2vec.ipynb文件中找到该函数(名为cbow_grams())的实现。这个过程与我们在 skip-grams 中使用的非常相似。然而,数据格式会略有不同。因此,我们将讨论该函数返回的数据格式。
该函数接受与我们之前讨论的skip_gram_data_generator()函数相同的参数:
-
sequences(List[List[int]])– 词 ID 的列表列表。这是 Tokenizer 的texts_to_sequences()函数生成的输出。 -
window_size(int)– 上下文的窗口大小。 -
batch_size(int)– 批次大小。 -
negative_samples(int)– 每个样本生成的负样本数量。 -
vocabulary_size(int)– 词汇表大小。 -
seed– 随机种子。
返回的数据格式也略有不同。它将返回一个包含以下内容的数据批次:
-
一个批次的目标词 ID,这些目标词包括正样本和负样本。
-
一个批次对应的上下文词 ID。与 skip-gram 不同,对于 CBOW,我们需要上下文中的所有词,而不仅仅是一个。例如,如果我们定义批次大小为
b,窗口大小为w,则这是一个[b, 2w]大小的张量。 -
一个批次或标签(0 和 1)。
现在我们来学习该算法的具体细节。
在 TensorFlow 中实现 CBOW
我们将使用与之前相同的超参数:
batch_size = 4096 # Data points in a single batch
embedding_size = 128 # Dimension of the embedding vector.
window_size=1 # We use a window size of 1 on either side of target word
epochs = 5 # Number of epochs to train for
negative_samples = 4 # Number of negative samples generated per example
# We pick a random validation set to sample nearest neighbors
valid_size = 16 # Random set of words to evaluate similarity on.
# We sample valid datapoints randomly from a large window without always
# being deterministic
valid_window = 250
# When selecting valid examples, we select some of the most frequent words
# as well as some moderately rare words as well
np.random.seed(54321)
random.seed(54321)
valid_term_ids = np.array(random.sample(range(valid_window), valid_size))
valid_term_ids = np.append(
valid_term_ids, random.sample(range(1000, 1000+valid_window),
valid_size),
axis=0
)
和之前一样,让我们先清除掉任何剩余的会话(如果有的话):
import tensorflow.keras.backend as K
K.clear_session()
我们定义了两个输入层。注意第二个输入层被定义为具有2 x window_size的维度。这意味着该层的最终形状将是[None, 2 x window_size]:
# Inputs
input_1 = tf.keras.layers.Input(shape=())
input_2 = tf.keras.layers.Input(shape=(window_size*2,))
现在我们来定义两个嵌入层:一个用于上下文词,另一个用于目标词。我们将从输入层输入数据,并生成context_out和target_out:
context_embedding_layer = tf.keras.layers.Embedding(
input_dim=n_vocab+1, output_dim=embedding_size,
name='context_embedding'
)
target_embedding_layer = tf.keras.layers.Embedding(
input_dim=n_vocab+1, output_dim=embedding_size,
name='target_embedding'
)
context_out = context_embedding_layer(input_2)
target_out = target_embedding_layer(input_1)
如果你查看context_out的形状,你会看到它的形状是[None, 2, 128],其中2是2 x window_size,这是因为它考虑了一个词周围的整个上下文。这需要通过对所有上下文词的平均值进行降维,变为[None, 128]。这一操作是通过使用 Lambda 层完成的:
mean_context_out = tf.keras.layers.Lambda(lambda x: tf.reduce_mean(x, axis=1))(context_out)
我们将一个Lambda函数传递给tf.keras.layers.Lambda层,以在第二维度上减少context_out张量,从而生成一个大小为[None, 128]的张量。由于target_out和mean_context_out张量的形状都是[None, 128],我们可以计算这两者的点积,生成一个输出张量[None, 1]:
out = tf.keras.layers.Dot(axes=-1)([context_out, target_out])
有了这些,我们可以将最终模型定义如下:
cbow_model = tf.keras.models.Model(inputs=[input_1, input_2], outputs=out, name='cbow_model')
类似于skip_gram_model,我们将按如下方式编译cbow_model:
cbow_model.compile(
loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),
optimizer='adam',
metrics=['accuracy']
)
如果你想查看模型的摘要,可以运行cbow_model.summary()。
训练和评估模型
模型训练与我们训练 skip-gram 模型的方式相同。首先,让我们定义一个回调函数,用于找到与valid_term_ids集合中定义的词最相似的前 k 个词:
cbow_validation_callback = ValidationCallback(valid_term_ids, cbow_model, tokenizer)
接下来,我们训练cbow_model若干轮:
for ei in range(epochs):
print(f"Epoch: {ei+1}/{epochs} started")
news_cbow_gen = cbow_data_generator(
news_sequences,
window_size,
batch_size,
negative_samples
)
cbow_model.fit(
news_cbow_gen,
epochs=1,
callbacks=cbow_validation_callback,
)
输出应该如下所示。我们挑选了一些最合理的词向量进行展示:
months: years, days, weeks, minutes, seasons
you: we, they, i, don't, we'll
were: are, aren't, have, because, need
music: terrestrial, cameras, casual, divide, camera
also: already, previously, recently, rarely, reportedly
best: supporting, actress, category, fiction, contenders
him: them, me, themselves, won't, censors
mr: tony, gordon, resignation, cherie, jack
5bn: 5m, 7bn, 4bn, 8bn, 8m
champions: premier, rugby, appearances, irish, midfielder
deutsche: austria, austria's, butcher, violence, 1989
files: movies, collections, vast, habit, ballad
pop: fiction, veteran, scrubs, wars, commonwealth
从视觉检查来看,CBOW 似乎已经学到了有效的词向量。类似于 skip-gram 模型,它已经将“years”和“days”这样的词与“months”进行了类比。像“5bn”这样的数字值周围有“5m”和“7bn”。但重要的是要记住,视觉检查只是评估词向量的一种快速而粗略的方式。
通常,词向量会在一些下游任务中进行评估。一个流行的任务是词类比推理任务。它主要聚焦于回答类似以下的问题:
雅典之于希腊,如巴格达之于 ____
答案是Iraq。答案是如何计算的?如果词向量合理,那么:
Word2vec(Athens) – Word2vec(Greece) = Word2vec(Baghdad) – Word2vec(Iraq)
或者
Word2vec(Iraq) = Word2vec(Baghdad) - Word2vec(Athens) + Word2vec(Greece)
答案通过计算Word2vec(Baghdad) - Word2vec(Athens) + Word2vec(Greece)得到。这个类比任务的下一步是查看与结果向量最相似的词是否是 Iraq。通过这种方式,可以计算类比推理任务的准确度。然而,由于我们的数据集不够大,不能很好地执行此任务,所以我们在本章中不会使用这个任务。
在这里,我们结束了对 CBOW 算法的讨论。尽管 CBOW 与 skip-gram 算法有相似之处,但它在架构和数据上也存在差异。
总结
词嵌入已成为许多 NLP 任务的核心部分,广泛应用于机器翻译、聊天机器人、图像描述生成和语言建模等任务中。词嵌入不仅作为一种降维技术(与独热编码相比),还提供了比其他技术更丰富的特征表示。在本章中,我们讨论了两种基于神经网络的学习词表示的流行方法,即 skip-gram 模型和 CBOW 模型。
首先,我们讨论了该问题的经典方法,以便了解过去是如何学习词表示的。我们讨论了多种方法,例如使用 WordNet、构建词的共现矩阵,以及计算 TF-IDF。
接下来,我们探讨了基于神经网络的词表示学习方法。首先,我们手工计算了一个例子,以理解词嵌入或词向量是如何计算的,帮助我们理解涉及的计算过程。
接下来,我们讨论了第一个词嵌入学习算法——skip-gram 模型。然后我们学习了如何准备数据以供学习使用。随后,我们研究了如何设计一个损失函数,使我们能够利用给定词的上下文词来使用词嵌入。最后,我们讨论了如何使用 TensorFlow 实现 skip-gram 算法。
然后我们回顾了下一种学习词嵌入的方法——CBOW 模型。我们还讨论了 CBOW 与 skip-gram 模型的区别。最后,我们还讨论了 CBOW 的 TensorFlow 实现。
在下一章,我们将学习几种其他的词嵌入学习技术,分别是全球向量(Global Vectors,简称 GloVe)和语言模型的嵌入(Embeddings from Language Models,简称 ELMo)。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,结识志同道合的人,与超过 1000 名成员一起学习: packt.link/nlp

第四章:高级词向量算法
在第三章,Word2vec – 学习词向量中,我们介绍了 Word2vec、学习词向量的基础知识,以及两个常见的 Word2vec 算法:skip-gram 和 CBOW。在本章中,我们将讨论其他几种词向量算法:
-
GloVe – 全局向量
-
ELMo – 来自语言模型的嵌入
-
使用 ELMo 进行文档分类
首先,你将学习一种词嵌入学习技术,称为全局向量(GloVe),以及 GloVe 相对于 skip-gram 和 CBOW 的具体优势。
你还将学习一种最近的语言表示方法,称为来自语言模型的嵌入(ELMo)。与其他算法相比,ELMo 具有优势,因为它能够消除词义歧义并捕捉语义。具体来说,ELMo 生成的是“上下文化”的单词表示,它通过使用给定单词及其周围的单词,而不是像 skip-gram 或 CBOW 那样独立地处理单词表示。
最后,我们将解决一个使用我们新创建的 ELMo 向量进行文档分类的令人兴奋的应用案例。
GloVe – 全局向量表示
skip-gram 和 CBOW 算法的主要限制之一是它们只能捕捉局部上下文信息,因为它们只看一个固定长度的窗口围绕单词。因此,缺少了解决这个问题的重要部分,因为这些算法并不查看全局统计信息(全局统计信息是指我们查看一个单词在文本语料库中与另一个单词的上下文中的所有出现情况的一种方法)。
然而,我们已经在第三章,Word2vec – 学习词向量中学习过一种可以包含这些信息的结构:共现矩阵。让我们回顾一下共现矩阵,因为 GloVe 使用共现矩阵中捕捉到的统计信息来计算向量。
共现矩阵编码了单词的上下文信息,但它们需要维护一个 V × V 的矩阵,其中 V 是词汇表的大小。为了理解共现矩阵,假设我们有两个例句:
-
Jerry and Mary are friends。
-
Jerry buys flowers for Mary。
如果我们假设上下文窗口的大小为 1,即在所选单词的每一侧,那么共现矩阵将如下所示(我们只显示矩阵的上三角部分,因为矩阵是对称的):
| Jerry | and | Mary | are | friends | buys | flowers | for | |
|---|---|---|---|---|---|---|---|---|
| Jerry | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 |
| and | 0 | 1 | 0 | 0 | 0 | 0 | 0 | |
| Mary | 0 | 1 | 0 | 0 | 0 | 1 | ||
| are | 0 | 1 | 0 | 0 | 0 | |||
| friends | 0 | 0 | 0 | 0 | ||||
| buys | 0 | 1 | 0 | |||||
| flowers | 0 | 1 | ||||||
| for | 0 |
我们可以看到,这个矩阵展示了语料库中一个词与其他任何词的关系,因此它包含了关于语料库的全局统计信息。也就是说,拥有共现矩阵相较于仅仅看到局部上下文有什么优势呢?
-
它为你提供了关于词语特性的额外信息。例如,如果你考虑句子“the cat sat on the mat”,就很难判断“the”是否是一个特殊的词,出现在像“cat”或“mat”这样的词的上下文中。然而,如果你有足够大的语料库和共现矩阵,就很容易看出“the”是一个频繁出现的停用词。
-
共现矩阵识别了上下文或短语的重复使用,而在局部上下文中这些信息则被忽略。例如,在足够大的语料库中,“New York”将明显成为赢家,表明这两个词在同一上下文中出现了很多次。
需要牢记的是,Word2vec 算法使用各种技术来大致注入一些词汇共现模式,同时学习词向量。例如,我们在上一章使用的子采样技术(即更频繁地采样低频词)有助于识别和避免停用词。但它们引入了额外的超参数,并且不如共现矩阵那样富有信息。
使用全局统计信息来生成词表示并不是一个新概念。一个叫做潜在语义分析(LSA)的算法已经在其方法中使用了全局统计信息。
LSA 作为一种文档分析技术,将文档中的词映射到所谓的概念,即在文档中出现的常见词模式。基于全局矩阵分解的方法有效地利用语料库的全局统计信息(例如,词语在全局范围内的共现),但在词汇类比任务中表现较差。另一方面,基于上下文窗口的方法在词汇类比任务中表现较好,但没有利用语料库的全局统计信息,因此有改进的空间。GloVe 试图兼顾这两者的优点——一种既高效利用全局语料库统计信息,又像 skip-gram 或 CBOW 那样通过上下文窗口优化学习模型的方法。
GloVe,一种用于学习词嵌入的新技术,已在 Pennington 等人的论文《GloVe: Global Vectors for Word Representation》中提出(nlp.stanford.edu/pubs/glove.pdf)。GloVe 旨在弥补 Word2vec 算法中缺失的全局共现信息。GloVe 的主要贡献是提出了一种新的成本函数(或目标函数),该函数利用了共现矩阵中可用的宝贵统计信息。让我们首先理解 GloVe 方法背后的动机。
理解 GloVe
在查看 GloVe 的实现细节之前,让我们先花些时间理解 GloVe 中计算的基本概念。为此,我们来看一个例子:
-
考虑单词 i=Ice 和 j=Steam
-
定义一个任意的探针词 k
-
定义
为单词 i 和 k 在一起出现的概率,
为单词 j 和 k 一起出现的概率
现在让我们看看
实体在不同 k 值下的表现。
对于 k = “Solid”,它很可能与 i 一起出现,因此
会较高。然而,k 不太会与 j 一起出现,导致
较低。因此,我们得到以下表达式:

接下来,对于 k = “gas”,它不太可能与 i 紧密相邻出现,因此会有一个较低的
;然而,由于 k 与 j 高度相关,
的值将会较高。这导致了以下情况:

现在,对于像 k = “water” 这样的单词,它与 i 和 j 都有很强的关系,或者对于 k = “Fashion” 这样的单词,它与 i 和 j 都没有太多相关性,我们得到如下结果:

如果假设我们已经为这些单词学习了合理的词向量,这些关系可以在向量空间中可视化,从而理解为何比率
会有这样的行为(见 图 4.1)。在下图中,实心箭头表示单词 (i, j) 之间的距离,而虚线则表示单词 (i, k) 和 (j, k) 之间的距离。这些距离可以与我们讨论的概率值关联起来。例如,当 i = “ice”和 k = “solid” 时,我们期望它们的向量之间的距离较短(即更频繁地共同出现)。因此,由于
的定义,我们可以将 (i, k) 之间的距离与
的倒数关联起来(即
)。该图展示了随着探针词 k 的变化,这些距离是如何变化的:

图 4.1:当探针词变化时,P_ik 和 P_jk 实体如何随着与单词 i 和 j 的接近度而变化
可以看到,
实体是通过测量两个单词紧密出现的频率来计算的,当三个单词之间的关系发生变化时,它的表现也会有所不同。因此,它成为了学习词向量的一个不错的候选对象。因此,定义损失函数的一个好的起点将如下所示:

在这里,F 是某个函数,w 和
是我们将使用的两个不同的嵌入空间。换句话说,词汇
和
是从一个嵌入空间中查找的,而探测词
则是从另一个嵌入空间中查找的。从这一点开始,原始论文仔细地进行了推导,以得到以下损失函数:

我们这里不会深入推导,因为这超出了本书的范围。我们将直接使用已推导出的损失函数,并通过 TensorFlow 实现该算法。如果你需要一个较少数学密集的解释,了解我们是如何推导该成本函数的,请参考作者撰写的文章:towardsdatascience.com/light-on-math-ml-intuitive-guide-to-understanding-glove-embeddings-b13b4f19c010。
在这里,
被定义为
,如果
,否则为 1,其中
是词 j 在词 i 的上下文中出现的频率。
是我们设置的一个超参数。记住,我们在损失函数中定义了两个嵌入空间
和
。
和
分别表示从嵌入空间
中获得的词 i 的词嵌入和偏置嵌入。而
和
则分别表示从嵌入空间
中获得的词 j 的词嵌入和偏置嵌入。这两种嵌入的行为类似,除了初始化时的随机化。在评估阶段,这两个嵌入将被加在一起,从而提高性能。
实现 GloVe
在本小节中,我们将讨论实现 GloVe 的步骤。完整代码可以在 ch4_glove.ipynb 练习文件中找到,该文件位于 ch4 文件夹内。
首先,我们将定义超参数,就像在上一章中做的那样:
batch_size = 4096 # Data points in a single batch
embedding_size = 128 # Dimension of the embedding vector.
window_size=1 # We use a window size of 1 on either side of target word
epochs = 5 # Number of epochs to train for
# We pick a random validation set to sample nearest neighbors
valid_size = 16 # Random set of words to evaluate similarity on.
# We sample valid datapoints randomly from a large window without always
# being deterministic
valid_window = 250
# When selecting valid examples, we select some of the most frequent words # as well as some moderately rare words as well
np.random.seed(54321)
random.seed(54321)
valid_term_ids = np.array(random.sample(range(valid_window), valid_size))
valid_term_ids = np.append(
valid_term_ids, random.sample(range(1000, 1000+valid_window), valid_
size),
axis=0
)
你在这里定义的超参数与我们在上一章中定义的超参数相同。我们有一个批量大小、嵌入维度、窗口大小、训练轮数,最后,还有一组保留的验证词 ID,用来打印最相似的词。
然后我们将定义模型。首先,我们将导入一些在后续代码中需要用到的库:
import tensorflow.keras.backend as K
from tensorflow.keras.layers import Input, Embedding, Dot, Add
from tensorflow.keras.models import Model
K.clear_session()
模型将有两个输入层:word_i 和 word_j。它们分别表示一批上下文词和一批目标词(或一批正样本跳字):
# Define two input layers for context and target words
word_i = Input(shape=())
word_j = Input(shape=())
注意形状是如何定义的。形状被定义为空元组。这意味着 word_i 和 word_j 的最终形状将是 [None],意味着它将接受一个任意元素数量的向量作为输入。
接下来,我们将定义嵌入层。将会有四个嵌入层:
-
embeddings_i– 上下文嵌入层 -
embeddings_j– 目标嵌入层 -
b_i– 上下文嵌入偏置 -
b_j– 目标嵌入偏置
以下代码定义了这些内容:
# Each context and target has their own embeddings (weights and biases)
# Embedding weights
embeddings_i = Embedding(n_vocab, embedding_size, name='target_embedding')(word_i)
embeddings_j = Embedding(n_vocab, embedding_size, name='context_embedding')(word_j)
# Embedding biases
b_i = Embedding(n_vocab, 1, name='target_embedding_bias')(word_i)
b_j = Embedding(n_vocab, 1, name='context_embedding_bias')(word_j)
接下来,我们将计算输出。这个模型的输出将是:

如你所见,这就是我们最终损失函数的一部分。我们拥有所有正确的元素来计算这个结果:
# Compute the dot product between embedding vectors (i.e. w_i.w_j)
ij_dot = Dot(axes=-1)([embeddings_i,embeddings_j])
# Add the biases (i.e. w_i.w_j + b_i + b_j )
pred = Add()([ij_dot, b_i, b_j])
首先,我们将使用 tensorflow.keras.layers.Dot 层来计算上下文嵌入查找(embeddings_i)和目标嵌入查找(embeddings_j)之间的点积。举例来说,Dot 层的两个输入将是 [batch size, embedding size] 的大小。经过点积后,输出 ij_dot 的形状将是 [batch size, 1],其中 ij_dot[k] 将是 embeddings_i[k, :] 和 embeddings_j[k, :] 之间的点积。然后,我们只需将 b_i 和 b_j(其形状为 [None, 1])逐元素加到 ij_dot 上。
最后,模型被定义为以 word_i 和 word_j 作为输入,并输出 pred:
# The final model
glove_model = Model(
inputs=[word_i, word_j],outputs=pred,
name='glove_model'
)
接下来,我们将进行一些相当重要的操作。
我们必须设计一种方法,使用模型中可用的各种组件/功能来计算上面定义的复杂损失函数。首先,让我们重新审视损失函数。

其中,
,如果
,否则为 1。
尽管看起来很复杂,我们可以利用现有的损失函数和其他功能来实现 GloVe 损失。你可以将这个损失函数抽象为下图所示的三个组件:

图 4.2:GloVe 损失函数的分解,展示了预测值、目标值和权重是如何相互作用以计算最终损失的
因此,如果样本权重用
表示,预测值用
表示,真实目标用
表示,那么我们可以将损失函数写为:

这仅仅是一个加权均方损失。因此,我们将使用"mse"作为我们模型的损失函数:
# Glove has a specific loss function with a sound mathematical
# underpinning
# It is a form of mean squared error
glove_model.compile(loss="mse", optimizer = 'adam')
我们稍后会看到如何将样本权重输入到模型中,以完成损失函数。到目前为止,我们已经定义了 GloVe 算法的不同组件,并编译了模型。接下来,我们将看看如何生成数据来训练 GloVe 模型。
为 GloVe 生成数据
我们将使用的数据集与上一章使用的数据集相同。为了回顾一下,我们将使用 BBC 新闻文章数据集,网址为mlg.ucd.ie/datasets/bbc.html。该数据集包含 2225 篇新闻文章,属于 5 个主题:商业、娱乐、政治、体育和科技,均发表于 2004 至 2005 年间的 BBC 网站。
现在让我们生成数据。我们将数据生成封装在一个名为glove_data_generator()的函数中。第一步,让我们编写一个函数签名:
def glove_data_generator(
sequences, window_size, batch_size, vocab_size, cooccurrence_matrix,
x_max=100.0, alpha=0.75, seed=None
):
该函数接受多个参数:
-
sequences(List[List[int]])– 一个包含单词 ID 列表的列表。这是由分词器的texts_to_sequences()函数生成的输出。 -
window_size(int)– 上下文窗口大小。 -
batch_size(int)– 批量大小。 -
vocab_size(int)– 词汇表大小。 -
cooccurrence_matrix(scipy.sparse.lil_matrix)– 一个稀疏矩阵,包含单词的共现。 -
x_max(int)– GloVe 用于计算样本权重的超参数。 -
alpha(float)– GloVe 用于计算样本权重的超参数。 -
seed– 随机种子。
它还包含若干输出:
-
一批(目标,上下文)单词 ID 元组
-
对应的
值,适用于(目标,上下文)元组 -
样本权重(即
)值,适用于(目标,上下文)元组
首先,我们将打乱新闻文章的顺序:
# Shuffle the data so that, every epoch, the order of data is
# different
rand_sequence_ids = np.arange(len(sequences))
np.random.shuffle(rand_sequence_ids)
接下来,我们将创建采样表,以便可以使用子采样避免过度采样常见词汇(例如停用词):
sampling_table =
tf.keras.preprocessing.sequence.make_sampling_table(vocab_size)
在此基础上,对于每个序列(即表示文章的单词 ID 列表),我们生成正向 skip-gram。请注意,我们将negative_samples=0.0,因为与 skip-gram 或 CBOW 算法不同,GloVe 不依赖于负样本:
# For each story/article
for si in rand_sequence_ids:
# Generate positive skip-grams while using sub-sampling
positive_skip_grams, _ = tf.keras.preprocessing.sequence.
skipgrams(
sequences[si],
vocabulary_size=vocab_size,
window_size=window_size,
negative_samples=0.0,
shuffle=False,
sampling_table=sampling_table,
seed=seed
)
在此基础上,我们首先将 skip-gram 元组拆分成两个列表,一个包含目标,另一个包含上下文单词,并随后将其转换为 NumPy 数组:
# Take targets and context words separately
targets, context = zip(*positive_skip_grams)
targets, context = np.array(targets).ravel(),
np.array(context).ravel()
然后,我们从共现矩阵中索引(目标,上下文)单词对所给出的位置信息,以检索相应的
值,其中(i,j)表示(目标,上下文)对:
x_ij = np.array(cooccurrence_matrix[targets,
context].toarray()).ravel()
然后,我们计算相应的
(记作log_x_ij)和
(记作sample_weights):
# Compute log - Introducing an additive shift to make sure we
# don't compute log(0)
log_x_ij = np.log(x_ij + 1)
# Sample weights
# if x < x_max => (x/x_max)**alpha / else => 1
sample_weights = np.where(x_ij < x_max, (x_ij/x_max)**alpha, 1)
如果未选择代码,则设置一个随机种子。之后,context、targets、log_x_ij 和 sample_weights 将被打乱,同时保持数组元素之间的对应关系:
# If seed is not provided generate a random one
if not seed:
seed = random.randint(0, 10e6)
# Shuffle data
np.random.seed(seed)
np.random.shuffle(context)
np.random.seed(seed)
np.random.shuffle(targets)
np.random.seed(seed)
np.random.shuffle(log_x_ij)
np.random.seed(seed)
np.random.shuffle(sample_weights)
最后,我们迭代通过我们上面创建的数据批次。每个批次将包含
-
一批(目标,上下文)单词 ID 元组
-
对应的
值,适用于(目标,上下文)元组 -
样本权重(即
)值,适用于(目标,上下文)元组
按此顺序。
# Generate a batch or data in the format
# ((target words, context words), log(X_ij) <- true targets,
# f(X_ij) <- sample weights)
for eg_id_start in range(0, context.shape[0], batch_size):
yield (
targets[eg_id_start: min(eg_id_start+batch_size,
targets.shape[0])],
context[eg_id_start: min(eg_id_start+batch_size,
context.shape[0])]
), log_x_ij[eg_id_start: min(eg_id_start+batch_size,
log_x_ij.shape[0])], \
sample_weights[eg_id_start: min(eg_id_start+batch_size,
sample_weights.shape[0])]
现在数据已经准备好输入,我们来讨论最后一个步骤:训练模型。
训练与评估 GloVe
训练模型是轻而易举的,因为我们拥有所有训练模型所需的组件。第一步,我们将重用在第三章中创建的ValidationCallback,即Word2vec – 学习词嵌入。回顾一下,ValidationCallback是一个 Keras 回调。Keras 回调让你能够在每次训练迭代、周期、预测步骤等结束时执行一些重要操作。在这里,我们使用回调在每个周期结束时执行验证步骤。我们的回调将接受一个词 ID 的列表(作为验证词,存放在valid_term_ids中),包含嵌入矩阵的模型,以及一个解码词 ID 的 tokenizer。然后,它将计算验证词集中的每个词的最相似的 top-k 词,并将其作为输出:
glove_validation_callback = ValidationCallback(valid_term_ids, glove_model, tokenizer)
# Train the model for several epochs
for ei in range(epochs):
print("Epoch: {}/{} started".format(ei+1, epochs))
news_glove_data_gen = glove_data_generator(
news_sequences, window_size, batch_size, n_vocab
)
glove_model.fit(
news_glove_data_gen, epochs=1,
callbacks=glove_validation_callback,
)
一旦模型训练完成,你应该能得到一个合乎预期的输出。以下是一些精心挑选的结果:
election: attorney, posters, forthcoming, november's, month's
months: weeks, years, nations, rbs, thirds
you: afford, we, they, goodness, asked
music: cameras, mp3, hp's, refuseniks, divide
best: supporting, category, asante, counterparts, actor
mr: ron, tony, bernie, jack, 63
leave: pay, need, unsubstantiated, suited, return
5bn: 8bn, 2bn, 1bn, 3bn, 7bn
debut: solo, speakerboxxx, youngster, nasty, toshack
images: 117, pattern, recorder, lennon, unexpectedly
champions: premier, celtic, football, representatives, neighbour
individual: extra, attempt, average, improvement, survived
businesses: medium, sell, redder, abusive, handedly
deutsche: central, austria's, donald, ecb, austria
machine: unforced, wireless, rapid, vehicle, workplace
你可以看到,“months”,“weeks”和“years”等词被分到了一组。像“5bn”,“8bn”和“2bn”这样的数字也被分到了一组。“Deutsche”被“Austria’s”和“Austria”围绕。最后,我们将词嵌入保存到磁盘。我们将每个上下文和目标向量空间的权重与偏置合并为一个数组,其中数组的最后一列表示偏置,并将其保存到磁盘:
def save_embeddings(model, tokenizer, vocab_size, save_dir):
os.makedirs(save_dir, exist_ok=True)
_, words_sorted = zip(*sorted(list(tokenizer.index_word.items()),
key=lambda x: x[0])[:vocab_size-1])
words_sorted = [None] + list(words_sorted)
context_embedding_weights = model.get_layer("context_embedding").get_
weights()[0]
context_embedding_bias = model.get_layer("context_embedding_bias").
get_weights()[0]
context_embedding = np.concatenate([context_embedding_weights,
context_embedding_bias], axis=1)
target_embedding_weights = model.get_layer("target_embedding").get_
weights()[0]
target_embedding_bias = model.get_layer("target_embedding_bias").get_
weights()[0]
target_embedding = np.concatenate([target_embedding_weights, target_
embedding_bias], axis=1)
pd.DataFrame(
context_embedding,
index = words_sorted
).to_pickle(os.path.join(save_dir, "context_embedding_and_bias.pkl"))
pd.DataFrame(
target_embedding,
index = words_sorted
).to_pickle(os.path.join(save_dir, "target_embedding_and_bias.pkl"))
save_embeddings(glove_model, tokenizer, n_vocab, save_dir='glove_embeddings')
我们将词嵌入保存为 pandas DataFrame。首先,我们按 ID 对所有词进行排序。我们减去 1,以去除保留的词 ID 0,因为我们将在下一行手动添加它。请注意,词 ID 0 不会出现在tokenizer.index_word中。接下来,我们按名称获取所需的层(即context_embedding、target_embedding、context_embedding_bias和target_embedding_bias)。一旦获取到这些层,我们可以使用get_weights()函数来获取权重。
在本节中,我们讨论了 GloVe,这是一种词嵌入学习技术。
GloVe 相对于第三章中讨论的 Word2vec 技术的主要优点在于,它关注语料库的全局和局部统计信息来学习嵌入。由于 GloVe 能够捕捉到词的全局信息,它通常能提供更好的性能,尤其是在语料库规模增大时。另一个优点是,与 Word2vec 技术不同,GloVe 并不近似代价函数(例如,Word2vec 使用负采样),而是计算真正的代价。这使得损失的优化更加高效和容易。
在下一节中,我们将介绍另一个词向量算法,称为来自语言模型的嵌入(ELMo)。
ELMo – 消除词向量中的歧义
到目前为止,我们已经研究了只能为词汇中的每个单词提供唯一表示的词嵌入算法。然而,它们会为给定的单词提供恒定的表示,无论你查询多少次。这为什么是个问题呢?请考虑以下两个短语:
我去银行存了一些钱
和
我沿着河岸走
显然,单词“bank”在两个完全不同的语境中使用。如果你使用普通的词向量算法(例如 skip-gram),你只能为单词“bank”提供一个表示,并且这个表示可能会在金融机构的概念和可以走的河岸边缘的概念之间混淆,具体取决于它在语料库中的引用。因此,更合理的做法是为一个词提供嵌入,同时保留并利用它周围的上下文。这正是 ELMo 所努力实现的目标。
具体来说,ELMo 处理的是一系列输入,而不是单一的词汇,并为序列中每个词提供上下文化的表示。图 4.3 展示了涵盖该模型的不同组件。首先需要理解的是,ELMo 是一个复杂的系统!在 ELMo 中,许多神经网络模型相互协调以产生输出。特别地,模型使用:
-
一个字符嵌入层(每个字符的嵌入向量)。
-
一个 卷积神经网络 (CNN)——CNN 由许多卷积层和可选的全连接分类层组成。
卷积层接收一系列输入(例如单词中的字符序列),并在输入上移动一个加权窗口来生成潜在表示。我们将在后续章节中详细讨论 CNN。
- 两个双向 LSTM 层——LSTM 是一种用于处理时间序列数据的模型。给定一系列输入(例如词向量序列),LSTM 会沿着时间维度从一个输入处理到另一个输入,并在每个位置产生一个输出。与全连接网络不同,LSTM 具有记忆功能,这意味着当前位点的输出会受到 LSTM 过去见过的数据的影响。我们将在后续章节中详细讨论 LSTM。
这些不同组件的具体细节超出了本章的讨论范围。它们将在后续章节中详细讨论。因此,如果你不理解这里展示的子组件的具体机制,也不必担心(图 4.3)。

图 4.3:ELMo 模型的不同组件。词嵌入是通过一种名为 CNN 的神经网络生成的。这些词嵌入被输入到 LSTM 模型中(该模型可以处理时间序列数据)。第一个 LSTM 模型的输出被输入到第二个 LSTM 模型,以生成每个词的潜在上下文化表示。
我们可以从 TensorFlow Hub (tfhub.dev) 下载预训练的 ELMo 模型。TF Hub 是各种预训练模型的存储库。
它托管了用于图像分类、文本分类、文本生成等任务的模型。你可以访问该网站并浏览各种可用的模型。
从 TensorFlow Hub 下载 ELMo
我们将使用的 ELMo 模型位于 tfhub.dev/google/elmo/3。它已经在一个非常大的文本语料库上进行了训练,以解决称为语言建模的任务。在语言建模中,我们试图根据先前的标记序列预测下一个单词。在接下来的章节中,我们将更多地了解语言建模。
在下载模型之前,让我们设置以下环境变量:
# Not allocating full GPU memory upfront
%env TF_FORCE_GPU_ALLOW_GROWTH=true
# Making sure we cache the models and are not downloaded all the time
%env TFHUB_CACHE_DIR=./tfhub_modules
TF_FORCE_GPU_ALLOW_GROWTH 允许 TensorFlow 根据需要分配 GPU 内存,而不是一次性分配所有 GPU 内存。 TFHUB_CACHE_DIR 设置模型下载的目录。我们首先导入 TensorFlow Hub:
import tensorflow_hub as hub
接下来,像往常一样,我们将通过运行以下代码清除任何正在运行的 TensorFlow 会话:
import tensorflow as tf
import tensorflow.keras.backend as K
K.clear_session()
最后,我们将下载 ELMo 模型。你可以使用两种方式从 TF Hub 下载预训练模型并在我们的代码中使用它们:
-
hub.load(<url>, **kwargs)– 推荐的下载和使用 TensorFlow 2 兼容模型的方式 -
hub.KerasLayer(<url>, **kwargs)– 这是在 TensorFlow 2 中使用基于 TensorFlow 1 的模型的一种解决方案
不幸的是,ELMo 还没有移植到 TensorFlow 2。因此,我们将使用 hub.KerasLayer() 作为在 TensorFlow 2 中加载 ELMo 的解决方法:
elmo_layer = hub.KerasLayer(
"https://tfhub.dev/google/elmo/3",
signature="tokens",signature_outputs_as_dict=True
)
请注意,我们正在提供两个参数,signature 和 signature_outputs_as_dict:
-
signature(str) – 可以是default或tokens。默认签名接受字符串列表,其中每个字符串将在内部转换为标记列表。标记签名接受输入为具有两个键的字典。即tokens(标记列表的列表。每个标记列表是一个短语/句子,包括填充标记以将其调整为固定长度)和 "sequence_len"(每个标记列表的长度,以确定填充长度)。 -
signature_outputs_as_dict(bool) – 当设置为true时,将返回提供的签名中定义的所有输出。
现在我们已经理解了 ELMo 的组成部分,并从 TensorFlow Hub 下载了它,让我们看看如何处理 ELMo 的输入数据。
准备 ELMo 的输入
在这里,我们将定义一个函数,将给定的字符串列表转换为 ELMo 期望输入的格式。请记住,我们将 ELMo 的签名设置为 tokens。签名 "tokens" 的示例输入如下。
{
'tokens': [
['the', 'cat', 'sat', 'on', 'the', 'mat'],
['the', 'mat', 'sat', '', '', '']
],
'sequence_len': [6, 3]
}
让我们花一点时间处理输入的组成部分。首先,它有一个关键字tokens,其中包含一系列令牌。每个令牌列表可以看作是一个句子。注意短句子的末尾如何添加填充以匹配长度。这很重要,否则模型会抛出错误,因为它无法将任意长度的序列转换为张量。接下来我们有sequence_len,它是一个整数列表。每个整数指定每个序列的真实长度。注意第二个元素为 3,以匹配第二个序列中实际存在的令牌。
给定一个字符串列表,我们可以编写一个函数来为我们执行这个转换。这就是format_text_for_elmo()函数的作用。让我们深入了解具体细节:
def format_text_for_elmo(texts, lower=True, split=" ", max_len=None):
""" Formats a given text for the ELMo model (takes in a list of
strings) """
token_inputs = [] # Maintains individual tokens
token_lengths = [] # Maintains the length of each sequence
max_len_inferred = 0
# We keep a variable to maintain the max length of the input
# Go through each text (string)
for text in texts:
# Process the text and get a list of tokens
tokens = tf.keras.preprocessing.text.text_to_word_sequence(text,
lower=lower, split=split)
# Add the tokens
token_inputs.append(tokens)
# Compute the max length for the collection of sequences
if len(tokens)>max_len_inferred:
max_len_inferred = len(tokens)
# It's important to make sure the maximum token length is only as
# large as the longest input in the sequence
# Here we make sure max_len is only as large as the longest input
if max_len and max_len_inferred < max_len:
max_len = max_len_inferred
if not max_len:
max_len = max_len_inferred
# Go through each token sequence and modify sequences to have same
# length
for i, token_seq in enumerate(token_inputs):
token_lengths.append(min(len(token_seq), max_len))
# If the maximum length is less than input length, truncate
if max_len < len(token_seq):
token_seq = token_seq[:max_len]
# If the maximum length is greater than or equal to input length,
# add padding as needed
else:
token_seq = token_seq+[""]*(max_len-len(token_seq))
assert len(token_seq)==max_len
token_inputs[i] = token_seq
# Return the final output
return {
"tokens": tf.constant(token_inputs),
"sequence_len": tf.constant(token_lengths)
}
我们首先创建两个列表,token_inputs和token_lengths,用于包含单个令牌及其各自的长度。接下来,我们遍历texts中的每个字符串,使用tf.keras.preprocessing.text.text_to_word_sequence()函数获取单个令牌。在此过程中,我们将计算迄今为止观察到的最大令牌长度。遍历完所有序列后,我们检查从输入推断出的最大长度是否与max_len(如果指定)不同。如果不同,我们将使用max_len_inferred作为最大长度。这一点很重要,因为如果你不这样做,可能会通过为max_len定义一个大值来不必要地延长输入长度。不仅如此,如果你这么做,模型将抛出像下面这样的错误。
#InvalidArgumentError: Incompatible shapes: [2,6,1] vs. [2,10,1024]
# [[node mul (defined at .../python3.6/site-packages/tensorflow_
hub/module_v2.py:106) ]] [Op:__inference_pruned_3391]
一旦找到适当的最大长度,我们将遍历序列并
-
如果它比
max_len长,则截断序列。 -
如果它比
max_len短,则添加令牌直到达到max_len。
最后,我们将使用tf.constant构造将它们转换为tf.Tensor对象。例如,你可以使用以下方式调用该函数:
print(format_text_for_elmo(["the cat sat on the mat", "the mat sat"], max_len=10))
这将输出:
{'tokens': <tf.Tensor: shape=(2, 6), dtype=string, numpy=
array([[b'the', b'cat', b'sat', b'on', b'the', b'mat'],
[b'the', b'mat', b'sat', b'', b'', b'']], dtype=object)>, 'sequence_len': <tf.Tensor: shape=(2,), dtype=int32, numpy=array([6, 3], dtype=int32)>}
接下来我们将看到如何使用 ELMo 为准备好的输入生成嵌入。
使用 ELMo 生成嵌入
一旦输入准备好,生成嵌入就非常简单。首先,我们将把输入转换为 ELMo 层规定的格式。这里我们使用 BBC 数据集中的一些示例标题:
# Titles of 001.txt - 005.txt in bbc/business
elmo_inputs = format_text_for_elmo([
"Ad sales boost Time Warner profit",
"Dollar gains on Greenspan speech",
"Yukos unit buyer faces loan claim",
"High fuel prices hit BA's profits",
"Pernod takeover talk lifts Domecq"
])
接下来,只需将elmo_inputs传递给elmo_layer作为输入,并获取结果:
# Get the result from ELMo
elmo_result = elmo_layer(elmo_inputs)
现在让我们使用以下代码打印结果及其形状:
# Print the result
for k,v in elmo_result.items():
print("Tensor under key={} is a {} shaped Tensor".format(k, v.shape))
这将打印出:
Tensor under key=sequence_len is a (5,) shaped Tensor
Tensor under key=elmo is a (5, 6, 1024) shaped Tensor
Tensor under key=default is a (5, 1024) shaped Tensor
Tensor under key=lstm_outputs1 is a (5, 6, 1024) shaped Tensor
Tensor under key=lstm_outputs2 is a (5, 6, 1024) shaped Tensor
Tensor under key=word_emb is a (5, 6, 512) shaped Tensor
正如你所看到的,模型返回了 6 个不同的输出。让我们逐一查看:
-
sequence_len– 我们提供的相同输入,包含输入中各个序列的长度 -
word_emb– 通过 ELMo 模型中的 CNN 层获得的令牌嵌入。我们为所有序列位置(即 6)和批次中的所有行(即 5)得到了一个大小为 512 的向量。 -
lstm_output1– 通过第一个 LSTM 层获得的令牌的上下文化表示 -
lstm_output2– 通过第二个 LSTM 层获得的令牌的上下文化表示 -
default– 通过对所有的lstm_output1和lstm_output2嵌入进行平均得到的平均嵌入向量 -
elmo– 所有word_emb、lstm_output1和lstm_output2的加权和,其中权重是一组任务特定的可训练参数,将在任务特定训练期间一起训练
我们在这里关注的是default输出。它将为我们提供文档内容的非常好的表示。
其他词嵌入技术
除了我们在这里讨论的词嵌入技术外,还有一些著名的广泛使用的词嵌入技术。我们将在此讨论其中一些。
FastText
FastText (fasttext.cc/),由 Bojanowski 等人于论文《Enriching Word Vectors with Subword Information》提出 (arxiv.org/pdf/1607.04606.pdf),介绍了一种通过考虑词语的子组件来计算词嵌入的技术。具体来说,他们将词嵌入计算为词的n-gram 嵌入的总和,n取多个值。在论文中,他们使用了3 <= n <= 6。例如,对于单词“banana”,三元组(n=3)为['ban', 'ana', 'nan', 'ana']。这使得嵌入变得更加健壮,能够抵抗文本中的常见问题,比如拼写错误。
Swivel 嵌入
Swivel 嵌入,由 Shazeer 等人于论文《Swivel: Improving Embeddings by Noticing What’s Missing》提出 (arxiv.org/pdf/1602.02215.pdf),尝试将 GloVe 和跳字模型与负采样结合。GloVe 的一个关键限制是它仅使用有关正向上下文的信息。因此,该方法不会因尝试创建未曾一起出现的单词的相似向量而受到惩罚。而跳字模型中使用的负采样则直接解决了这个问题。Swivel 的最大创新是包含未观察到的词对的损失函数。作为额外的好处,它还可以在分布式环境中进行训练。
Transformer 模型
Transformer 模型是一类重新定义我们思考 NLP 问题方式的模型。Transformer 模型最初由 Vaswani 在论文《Attention is all you need》中提出 (arxiv.org/pdf/1706.03762.pdf)。该模型内部有许多不同的嵌入,像 ELMo 一样,它可以通过处理文本序列为每个标记生成嵌入。我们将在后续章节中详细讨论 Transformer 模型。
我们已经讨论了使用 ELMo 模型所需的所有细节。接下来,我们将使用 ELMo 进行文档分类,在此过程中,ELMo 将生成文档嵌入作为分类模型的输入。
使用 ELMo 进行文档分类
尽管 Word2vec 提供了一种非常优雅的学习词语数值表示的方法,但仅仅学习词表示并不足以令人信服地展示词向量在实际应用中的强大功能。
词嵌入被用作许多任务中词语的特征表示,比如图像标题生成和机器翻译。然而,这些任务涉及结合不同的学习模型,如卷积神经网络(CNNs)和长短期记忆(LSTM)模型,或者两个 LSTM 模型(CNN 和 LSTM 模型将在后续章节中详细讨论)。为了理解词嵌入在实际应用中的使用,我们可以从一个更简单的任务——文档分类开始。
文档分类是自然语言处理(NLP)中最流行的任务之一。文档分类对处理海量数据的人员非常有用,例如新闻网站、出版商和大学。因此,看看如何通过嵌入整个文档而不是词语,将学习到的词向量应用于像文档分类这样的实际任务是非常有趣的。
本练习可在Ch04-Advance-Word-Vectors文件夹下找到(ch4_document_classification.ipynb)。
数据集
对于这个任务,我们将使用一组已组织好的文本文件。这些是来自 BBC 的新闻文章。该数据集中每篇文档都属于以下类别之一:商业、娱乐、政治、体育或技术。
以下是来自实际数据的几个简短片段:
商业
日本勉强避免衰退
日本经济在截至九月的三个月里,勉强避免了技术性衰退,数据显示。
修正后的数据显示增长仅为 0.1%——而前一季度也出现了类似规模的收缩。按年计算,数据表明年增长仅为 0.2%,...
首先,我们将下载数据并将其加载到内存中。我们将使用相同的download_data()函数来下载数据。然后,我们会稍微修改read_data()函数,使其不仅返回文章列表(每篇文章是一个字符串),还返回文件名列表,其中每个文件名对应存储该文章的文件。文件名随后将帮助我们为分类模型创建标签。
def read_data(data_dir):
# This will contain the full list of stories
news_stories = []
filenames = []
print("Reading files")
i = 0 # Just used for printing progress
for root, dirs, files in os.walk(data_dir):
for fi, f in enumerate(files):
# We don't read the readme file
if 'README' in f:
continue
# Printing progress
i += 1
print("."*i, f, end='\r')
# Open the file
with open(os.path.join(root, f), encoding='latin-1') as text_
file:
story = []
# Read all the lines
for row in text_file:
story.append(row.strip())
# Create a single string with all the rows in the doc
story = ' '.join(story)
# Add that to the list
news_stories.append(story)
filenames.append(os.path.join(root, f))
print('', end='\r')
print("\nDetected {} stories".format(len(news_stories)))
return news_stories, filenames
news_stories, filenames = read_data(os.path.join('data', 'bbc'))
然后,我们将像之前一样在数据上创建并拟合一个分词器。
from tensorflow.keras.preprocessing.text import Tokenizer
n_vocab = 15000 + 1
tokenizer = Tokenizer(
num_words=n_vocab - 1,
filters='!"#$%&()*+,-./:;<=>?@[\\]^_'{|}~\t\n',
lower=True, split=' ', oov_token=''
)
tokenizer.fit_on_texts(news_stories)
接下来,我们将创建标签。由于我们正在训练一个分类模型,因此我们需要输入和标签。我们的输入将是文档嵌入(我们很快会看到如何计算它们),而目标将是一个介于 0 和 4 之间的标签 ID。我们上面提到的每个类别(例如,商业、技术等)将被分配到一个单独的类别中。由于文件名包括作为文件夹的类别,因此我们可以利用文件名生成标签 ID。
我们将使用 pandas 库来创建标签。首先,我们将文件名列表转换为 pandas 的 Series 对象,方法如下:
labels_ser = pd.Series(filenames, index=filenames)
该系列中的一个示例条目可能类似于data/bbc/tech/127.txt。接下来,我们将按“/”字符分割每个条目,这将返回一个列表['data', 'bbc', 'tech', '127.txt']。我们还会设置expand=True。expand=True将通过将列表中的每个项目转换为DataFrame的单独列,来把我们的 Series 对象转换成 DataFrame。换句话说,我们的pd.Series对象将变成一个形状为[N, 4]的pd.DataFrame,每一列包含一个 token,其中N是文件的数量:
labels_ser = labels_ser.str.split(os.path.sep, expand=True)
在生成的数据中,我们只关心第三列,它包含了给定文章的类别(例如tech)。因此,我们将丢弃其他数据,仅保留这一列:
labels_ser = labels_ser.iloc[:, -2]
最后,我们将使用 pandas 的map()函数将字符串标签映射到整数 ID,方法如下:
labels_ser = labels_ser.map({'business': 0, 'entertainment': 1, 'politics': 2, 'sport': 3, 'tech': 4})
这将导致如下结果:
data/bbc/tech/272.txt 4
data/bbc/tech/127.txt 4
data/bbc/tech/370.txt 4
data/bbc/tech/329.txt 4
data/bbc/tech/240.txt 4
Name: 2, dtype: int64
我们在这里所做的,可以通过将一系列命令链式写成一行来实现:
labels_ser = pd.Series(filenames, index=filenames).str.split(os.path.sep, expand=True).iloc[:, -2].map(
{'business': 0, 'entertainment': 1, 'politics': 2, 'sport': 3,
'tech': 4}
)
接下来,我们进入另一个重要步骤,即将数据拆分为训练集和测试集。在训练一个监督学习模型时,我们通常需要三个数据集:
-
训练集 —— 这是模型将要训练的 数据集。
-
验证集 —— 这个数据集将在训练过程中用于监控模型性能(例如,防止过拟合的迹象)。
-
测试集 —— 该数据集在模型训练过程中不会暴露给模型。它只会在模型训练完成后,用于评估模型在未见数据上的表现。
在这个练习中,我们只使用训练集和测试集。这将帮助我们将讨论聚焦于嵌入部分,并简化关于下游分类模型的讨论。这里我们将 67%的数据作为训练数据,33%的数据作为测试数据。数据将随机拆分:
from sklearn.model_selection import train_test_split
train_labels, test_labels = train_test_split(labels_ser, test_size=0.33)
现在我们有了一个训练数据集用于训练模型,以及一个测试数据集用于在未见数据上进行测试。接下来我们将展示如何从标记或单词嵌入生成文档嵌入。
生成文档嵌入
让我们首先回顾一下我们如何存储 skip-gram、CBOW 和 GloVe 算法的嵌入。图 4.4展示了这些嵌入在pd.DataFrame对象中的样子。

图 4.4:保存到磁盘的 skip-gram 算法上下文嵌入的快照。你可以看到下方显示它有 128 列(即嵌入维度)。
ELMo 嵌入是一个例外。由于 ELMo 为序列中的所有 token 生成上下文表示,因此我们存储了通过对所有生成的向量取平均得到的均值嵌入向量:

图 4.5:ELMo 向量的快照。ELMo 向量有 1024 个元素。
为了从 skip-gram、CBOW 和 GloVe 嵌入计算文档嵌入,让我们编写以下函数:
def generate_document_embeddings(texts, filenames, tokenizer, embeddings):
""" This function takes a sequence of tokens and compute the mean
embedding vector from the word vectors of all the tokens in the
document """
doc_embedding_df = []
# Contains document embeddings for all the articles
assert isinstance(embeddings, pd.DataFrame), 'embeddings must be a
pd.DataFrame'
# This is a trick we use to quickly get the text preprocessed by the
# tokenizer
# We first convert text to a sequences, and then back to text, which
# will give the preprocessed tokens
sequences = tokenizer.texts_to_sequences(texts)
preprocessed_texts = tokenizer.sequences_to_texts(sequences)
# For each text,
for text in preprocessed_texts:
# Make sure we had matches for tokens in the embedding matrx
assert embeddings.loc[text.split(' '), :].shape[0]>0
# Compute mean of all the embeddings associated with words
mean_embedding = embeddings.loc[text.split(' '), :].mean(axis=0)
# Add that to list
doc_embedding_df.append(mean_embedding)
# Save the doc embeddings in a dataframe
doc_embedding_df = pd.DataFrame(doc_embedding_df, index=filenames)
return doc_embedding_df
generate_document_embeddings()函数接受以下参数:
-
texts– 一个字符串列表,其中每个字符串代表一篇文章 -
filenames– 一个文件名列表,对应于texts中的文章 -
tokenizer– 一个可以处理texts的分词器 -
embeddings– 以pd.DataFrame格式表示的嵌入,其中每一行代表一个词向量,按对应的标记索引
该函数首先通过将字符串转换为序列,然后再转换回字符串列表来预处理文本。这帮助我们利用分词器的内置预处理功能来清理文本。接下来,将每个预处理的字符串按空格字符拆分,以返回一个词元列表。然后,我们索引嵌入矩阵中所有与文本中所有词元对应的位置。最后,通过计算所有选择的嵌入向量的均值来计算文档的均值向量。
有了这个,我们可以加载来自不同算法(skip-gram、CBOW 和 GloVe)的嵌入,并计算文档嵌入。这里我们仅展示 skip-gram 算法的过程。但你可以轻松扩展到其他算法,因为它们有类似的输入和输出:
# Load the skip-gram embeddings context and target
skipgram_context_embeddings = pd.read_pickle(
os.path.join('../Ch03-Word-Vectors/skipgram_embeddings',
'context_embedding.pkl')
)
skipgram_target_embeddings = pd.read_pickle(
os.path.join('../Ch03-Word-Vectors/skipgram_embeddings',
'target_embedding.pkl')
)
# Compute the mean of context & target embeddings for better embeddings
skipgram_embeddings = (skipgram_context_embeddings + skipgram_target_embeddings)/2
# Generate the document embeddings with the average context target
# embeddings
skipgram_doc_embeddings = generate_document_embeddings(news_stories, filenames, tokenizer, skipgram_embeddings)
现在我们将看到如何利用生成的文档嵌入来训练分类器。
使用文档嵌入进行文档分类
我们将在此数据上训练一个简单的多类(或多项式)逻辑回归分类器。逻辑回归模型将如下所示:

图 4.6:该图描述了多项式逻辑回归模型。模型接受一个嵌入向量,并输出不同类别的概率分布
这是一个非常简单的模型,只有一层,其中输入是嵌入向量(例如,一个包含 128 个元素的向量),输出是一个 5 节点的 softmax 层,该层会输出输入属于每个类别的可能性,作为一个概率分布。
我们将训练多个模型,而不是仅仅一次运行。这将为我们提供一个更一致的模型性能结果。为了实现这个模型,我们将使用一个流行的通用机器学习库,名为 scikit-learn(scikit-learn.org/stable/)。在每次运行中,都会创建一个多类逻辑回归分类器,使用sklearn.linear_model.LogisticRegression对象。此外,在每次运行中:
-
模型在训练输入和目标上进行训练
-
模型为每个测试输入预测类别(一个从 0 到 4 的值),其中输入的类别是所有类别中具有最大概率的类别
-
模型使用测试集的预测类别和真实类别来计算测试准确度
代码如下所示:
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
def get_classification_accuracy(doc_embeddings, train_labels, test_labels, n_trials):
""" Train a simple MLP model for several trials and measure test
accuracy"""
accuracies = [] # Store accuracies across trials
# For each trial
for trial in range(n_trials):
# Create a MLP classifier
lr_classifier = LogisticRegression(multi_class='multinomial',
max_iter=500)
# Fit the model on training data
lr_classifier.fit(doc_embeddings.loc[train_labels.index],
train_labels)
# Get the predictions for test data
predictions = lr_classifier.predict(doc_embeddings.loc[test_
labels.index])
# Compute accuracy
accuracies.append(accuracy_score(predictions, test_labels))
return accuracies
# Get classification accuracy for skip-gram models
skipgram_accuracies = get_classification_accuracy(
skipgram_doc_embeddings, train_labels, test_labels, n_trials=5
)
print("Skip-gram accuracies: {}".format(skipgram_accuracies))
通过设置multi_class='multinomial',我们确保这是一个多类逻辑回归模型(或 softmax 分类器)。这将输出:
Skip-gram accuracies: [0.882…, 0.882…, 0.881…, 0.882…, 0.884…]
当你按步骤操作所有 skip-gram、CBOW、GloVe 和 ELMo 算法时,你会看到类似以下的结果。这是一个箱线图。然而,由于各次实验的表现相似,因此图表中不会出现太多变化。

图 4.7:不同模型在文档分类中的性能箱线图。我们可以看到,ELMo 是明显的赢家,而 GloVe 的表现最差。
我们可以看到,skip-gram 达到了大约 86% 的准确率,紧随其后的是 CBOW,二者的表现相当。令人惊讶的是,GloVe 的表现远低于 skip-gram 和 CBOW,准确率约为 66%。
这可能指向 GloVe 损失函数的一个限制。与 skip-gram 和 CBOW 不同,后者同时考虑正样本(已观察到)和负样本(未观察到)的目标和上下文对,而 GloVe 只关注已观察到的对。
这可能会影响 GloVe 生成有效词表示的能力。最终,ELMo 达到了最佳性能,准确率大约为 98%。但需要注意的是,ELMo 是在比 BBC 数据集更大规模的数据集上训练的,因此仅根据这个数字将 ELMo 与其他模型进行比较是不公平的。
在这一部分,你学习了如何将词嵌入扩展为文档嵌入,并且如何将这些嵌入用于下游分类模型进行文档分类。首先,你了解了使用选定算法(例如 skip-gram、CBOW 和 GloVe)进行词嵌入。然后我们通过对文档中所有单词的词嵌入进行平均来创建文档嵌入。这适用于 skip-gram、CBOW 和 GloVe 算法。在 ELMo 算法的情况下,我们能够直接从模型中推断出文档嵌入。随后,我们使用这些文档嵌入对一些 BBC 新闻文章进行分类,这些文章属于以下类别:娱乐、科技、政治、商业和体育。
总结
在本章中,我们讨论了 GloVe——另一种词嵌入学习技术。GloVe 通过将全局统计信息纳入优化,进一步提升了当前 Word2Vec 算法的性能。
接下来,我们学习了一个更为先进的算法,叫做 ELMo(即来自语言模型的嵌入)。ELMo 通过查看单词在句子或短语中的上下文,而不是孤立地看待单词,提供了上下文化的词表示。
最后,我们讨论了词嵌入的一个实际应用——文档分类。我们展示了词嵌入非常强大,并且允许我们用一个简单的多类逻辑回归模型相当好地分类相关文档。由于 ELMo 在大量数据上进行了训练,因此其表现优于 skip-gram、CBOW 和 GloVe。
在下一章,我们将讨论另一类深度网络,它们在利用数据中存在的空间信息方面更强大,称为卷积神经网络(CNNs)。
具体来说,我们将看到如何利用 CNNs 来挖掘句子的空间结构,将其分类到不同的类别中。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,与志同道合的人交流,和超过 1000 名成员一起学习,网址为:packt.link/nlp

第五章:使用卷积神经网络进行句子分类
在本章中,我们将讨论一种叫做 卷积神经网络(CNN)的神经网络。CNN 与全连接神经网络有很大的不同,并且在许多任务中取得了最先进的性能。这些任务包括图像分类、物体检测、语音识别,当然还有句子分类。CNN 的主要优势之一是,与全连接层相比,CNN 中的卷积层参数数量要小得多。这使得我们能够构建更深的模型,而不必担心内存溢出。此外,深层模型通常会带来更好的性能。
我们将通过讨论 CNN 中的不同组件以及使 CNN 与全连接神经网络不同的特性,详细介绍 CNN。接着,我们将讨论 CNN 中使用的各种操作,如卷积操作和池化操作,以及与这些操作相关的一些超参数,如滤波器大小、填充和步幅。我们还将看看实际操作背后的一些数学原理。在对 CNN 有了充分的理解后,我们将探讨使用 TensorFlow 实现 CNN 的实际操作。首先,我们将实现一个 CNN 用于图像分类,然后使用 CNN 进行句子分类。具体来说,我们将通过以下几个主题:
-
学习 CNN 的基础知识
-
使用 CNN 进行图像分类
-
使用 CNN 进行句子分类
介绍 CNN
在本节中,你将学习 CNN。具体来说,你将首先理解 CNN 中存在的各种操作,如卷积层、池化层和全连接层。接下来,我们将简要了解这些操作是如何连接在一起形成一个端到端模型的。
需要注意的是,我们将用 CNN 解决的第一个用例是图像分类任务。CNN 最初是用来解决计算机视觉任务的,后来才被应用于自然语言处理(NLP)。此外,CNN 在计算机视觉领域的应用要比在 NLP 领域更为广泛,这使得在视觉上下文中解释其基本概念更加容易。因此,我们将首先学习 CNN 在计算机视觉中的应用,然后再转向 NLP。
CNN 基础知识
现在,让我们在不深入技术细节的情况下探讨 CNN 背后的基本思想。CNN 是一堆层的堆叠,包括卷积层、池化层和全连接层。我们将讨论每一层,以了解它们在 CNN 中的作用。
最初,输入连接到一组卷积层。这些卷积层通过卷积操作,滑动一个权重块(有时称为卷积窗口或滤波器)在输入上并产生输出。卷积层使用少量的权重,这些权重组织成每层仅覆盖输入的小块,这与全连接神经网络不同,这些权重在某些维度(例如图像的宽度和高度)上是共享的。此外,CNN 使用卷积操作通过滑动这小部分权重沿着目标维度来共享输出的权重。通过这个卷积操作,我们最终得到的结果如图 5.1所示。如果卷积滤波器中存在的模式在图像的小块中出现,卷积将为该位置输出一个较高的值;如果没有,它将输出一个较低的值。此外,通过对整个图像进行卷积,我们得到一个矩阵,表示在某个位置是否存在该模式。最终,我们会得到一个作为卷积输出的矩阵:

图 5.1:卷积操作对图像的作用
此外,这些卷积层可以与池化/子采样层交替使用,池化层减少了输入的维度。在减少维度的同时,我们使得卷积神经网络(CNN)的平移不变性得以保持,并且强迫 CNN 在较少的信息下进行学习,从而提高模型的泛化能力和正则化效果。通过将输入划分为多个小块并将每个小块转换为单个元素,我们可以减少维度。例如,这种转换包括选择一个小块中的最大元素或对一个小块中的所有值进行平均。我们将在图 5.2中展示池化如何使 CNN 的平移不变性得以保持:

图 5.2:池化操作如何帮助使数据的平移不变
在这里,我们有原始图像和稍微在y轴上平移的图像。我们为这两幅图像计算了卷积输出,可以看到值10在卷积输出中的位置略有不同。然而,使用最大池化(它取每个厚方块的最大值),我们最终可以得到相同的输出。我们将在后续详细讨论这些操作。
最后,输出被传递给一组全连接层,这些层将输出传递给最终的分类/回归层(例如,句子/图像分类)。全连接层包含了 CNN 总权重的大部分,因为卷积层的权重较少。然而,研究发现,CNN 在有全连接层的情况下表现优于没有全连接层的情况。这可能是因为卷积层由于其较小的尺寸而学习到更多局部特征,而全连接层则提供了这些局部特征应该如何连接以产生理想输出的全局视图。
图 5.3展示了一个典型的 CNN 用于图像分类:

图 5.3:典型的 CNN 架构
从图中可以明显看出,CNN 在设计上能够在学习过程中保持输入的空间结构。换句话说,对于二维输入,CNN 通常会有二维的层,而接近输出层时则只有全连接层。保持空间结构使得 CNN 能够利用输入的宝贵空间信息,并以较少的参数学习输入。空间信息的价值在图 5.4中得到了说明:

图 5.4:将图像展开为一维向量会丧失一些重要的空间信息
如你所见,当一张猫的二维图像被展开为一维向量时,耳朵不再靠近眼睛,鼻子也远离眼睛。这意味着我们在展开过程中破坏了一些有用的空间信息。这就是保持输入的二维特性如此重要的原因。
CNN 的强大之处
卷积神经网络(CNNs)是一个非常多功能的模型家族,并在许多类型的任务中表现出了卓越的性能。这种多功能性归因于 CNN 能够同时执行特征提取和学习,从而提高了效率和泛化能力。让我们讨论几个 CNN 的应用实例。
在ImageNet 大规模视觉识别挑战赛(ILSVRC)2020 中,CNN 被用于图像分类、物体检测和物体定位任务,并取得了惊人的测试准确率。例如,在图像分类任务中,CNN 的 Top-1 测试准确率约为 90%,涵盖 1,000 个不同的物体类别,这意味着 CNN 能够正确识别大约 900 个不同的物体。
CNN 也被用于图像分割。图像分割是指将图像划分为不同的区域。例如,在包含建筑物、道路、车辆和乘客的城市景观图像中,将道路从建筑物中隔离出来就是一个分割任务。此外,CNN 在自然语言处理(NLP)任务中也取得了显著进展,展现了其在句子分类、文本生成和机器翻译等任务中的表现。
理解卷积神经网络(CNN)
现在我们已经了解了卷积神经网络的高层次概念,让我们来深入了解 CNN 的技术细节。首先,我们将讨论卷积操作并介绍一些术语,比如滤波器大小、步长和填充。简而言之,滤波器大小指的是卷积操作的窗口大小,步长指的是卷积窗口每次移动的距离,填充则指的是处理输入边界的方式。我们还将讨论一种叫做反卷积或转置卷积的操作。然后,我们将讨论池化操作的细节。最后,我们将讨论如何添加全连接层,以生成分类或回归输出。
卷积操作
在本节中,我们将详细讨论卷积操作。首先,我们将讨论没有步长和填充的卷积操作,然后描述有步长的卷积操作,接着讨论有填充的卷积操作。最后,我们将讨论一种叫做转置卷积的操作。对于本章中的所有操作,我们假设索引从 1 开始,而不是从 0 开始。
标准卷积操作
卷积操作是卷积神经网络(CNN)的核心部分。对于一个大小为
的输入和一个权重块(也称为滤波器或卷积核)
,其中
,卷积操作将权重块滑动到输入上。我们用 X 表示输入,W 表示权重块,H 表示输出。此外,在每个位置 i, j,输出按如下公式计算:

这里,x [i,j]、w [i,j] 和 h[i,j] 分别表示 X、W 和 H 在 (i,j) 位置的值。如方程所示,尽管输入大小为
,但在这种情况下输出的大小将是
。此外,m 被称为滤波器大小。这意味着输出的宽度和高度将略小于原始输入。让我们通过可视化来看这个问题(见 图 5.5):

图 5.5:卷积操作,滤波器大小 (m) = 3,步长 = 1,并且没有填充
注意
卷积操作产生的输出(图 5.5 上方的矩形)有时被称为特征图。
接下来让我们讨论卷积中的步长参数。
带步长的卷积
在前面的例子中,我们通过一步进行滤波器的移动。然而,这并不是强制性的;我们可以在卷积输入时采用较大的步长或步幅。因此,步长的大小被称为步幅。
让我们修改之前的公式,加入s [i]和s [j]步幅:


在这种情况下,随着s[i]和s[j]增大,输出会变小。对比图 5.5(步幅 = 1)和图 5.6(步幅 = 2)可以说明不同步幅的效果:

图 5.6:滤波器大小(m)= 2,步幅 = 2,并且没有填充的卷积操作
如你所见,使用步幅进行卷积有助于像池化层一样减少输入的维度。因此,有时在卷积神经网络(CNN)中,卷积操作与步幅结合使用,代替池化操作,因为它能减少计算复杂度。还需注意,步幅所实现的维度减小可以进行调整或控制,而标准卷积操作的维度减小是固有的。接下来,我们将讨论卷积中另一个重要的概念——填充。
卷积与填充
每次卷积操作(没有步幅)不可避免地会导致输出尺寸的减小,这是一个不希望出现的属性。这大大限制了网络中可以使用的层数。另外,已知较深的网络比浅层网络表现更好。需要注意的是,这不应与通过步幅实现的维度减小混淆,因为步幅是一个设计选择,如果需要,我们可以决定使用步幅为 1。因此,填充被用来绕过这个问题。实现方法是将零填充到输入的边界,使得输出尺寸与输入尺寸相等。假设步幅为 1:

这里:

图 5.7 展示了填充的结果:

图 5.7:滤波器大小(m=3),步幅(s=1),以及零填充的卷积操作
接下来,我们将讨论转置卷积操作。
转置卷积
尽管卷积操作在数学上看起来很复杂,但它可以简化为矩阵乘法。因此,我们可以定义卷积操作的转置,或者有时称为反卷积。然而,我们将使用转置卷积这一术语,因为它听起来更自然。此外,反卷积指的是一个不同的数学概念。转置卷积操作在卷积神经网络(CNN)中起着重要作用,用于反向传播过程中梯度的反向累积。我们将通过一个例子来解释。
对于大小为
的输入和大小为
的权重块或滤波器,其中
,卷积操作将权重块滑动在输入上。我们将输入表示为 X,权重块表示为 W,输出表示为 H。输出 H 可以通过以下矩阵乘法计算:
假设
和
为了清晰起见,我们从左到右、从上到下展开输入 X,得到如下结果:

让我们从 W 定义一个新矩阵 A:

然后,如果我们执行以下矩阵乘法,我们得到 H:

现在,通过将输出
重塑为
,我们得到卷积输出。现在让我们将这个结果投影回 n 和 m。
通过展开输入
到
,并通过创建矩阵
从 w,如我们之前所示,我们得到
,然后将其重塑为
。
接下来,为了获得转置卷积,我们只需转置 A 并得到如下结果:

这里,
是转置卷积的结果输出。
我们在这里结束卷积操作的讨论。我们讨论了卷积操作、带步幅的卷积操作、带填充的卷积操作以及如何计算转置卷积。接下来,我们将更详细地讨论池化操作。
池化操作
池化操作,有时也称为子采样操作,主要是为了减少卷积神经网络(CNN)中间输出的大小,并使得 CNN 具有平移不变性。与没有填充的卷积引起的自然维度缩减相比,池化操作更为可取,因为我们可以通过池化层来决定输出的大小缩减位置,而不是每次都强制发生。没有填充的情况下强制维度减小会严格限制我们在 CNN 模型中能使用的层数。
我们将在接下来的章节中数学定义池化操作。更准确地说,我们将讨论两种类型的池化:最大池化和平均池化。然而,首先我们将定义符号。对于大小为
的输入和大小为
的卷积核(类似于卷积层的滤波器),其中
,卷积操作将权重块滑动在输入上。我们将输入表示为 X,权重块表示为 W,输出表示为 H。然后我们使用 x [i,j]、w[i,j] 和 h[i,j] 来表示 X、W 和 H 中(i,j)位置的值。接下来,我们将讨论常用的池化实现。
最大池化
最大池化操作从输入的定义卷积核内选择最大元素生成输出。最大池化操作通过窗口滑动(图 5.8 中的中间方块),每次取最大值。数学上,我们将池化公式定义如下:


图 5.8 显示了该操作:

图 5.8:滤波器大小为 3,步长为 1,且无填充的最大池化操作
接下来,我们将讨论如何进行带步长的最大池化。
带步长的最大池化
带步长的最大池化与带步长的卷积相似。其公式如下:


图 5.9 显示了结果:

图 5.9:对大小为 (n=4) 的输入进行最大池化操作,滤波器大小为 (m=2),步长 (s=2),且无填充
接下来我们将讨论另一种池化变体——平均池化。
平均池化
平均池化与最大池化类似,不同之处在于它不仅取最大值,而是取所有落入卷积核内输入的平均值。考虑以下方程:

平均池化操作如 图 5.10 所示:

图 5.10:对大小为 (n=4) 的输入进行平均池化操作,滤波器大小为 (m=2),步长 (s=1),且无填充
到目前为止,我们讨论了直接对二维输入(如图像)执行的操作。接下来我们将讨论它们如何与一维的全连接层连接。
全连接层
全连接层是从输入到输出的完全连接的权重集合。这些全连接的权重能够学习全局信息,因为它们从每个输入连接到每个输出。而且,拥有这样的完全连接层使我们能够将前面卷积层学到的特征全局结合起来,生成有意义的输出。
我们定义最后一个卷积或池化层的输出大小为
,其中 p 是输入的高度,o 是输入的宽度,d 是输入的深度。举个例子,考虑一个 RGB 图像,其高度和宽度是固定的,深度为 3(每个 RGB 组件都有一个深度通道)。
然后,对于紧接在最后一个卷积或池化层后的初始全连接层,权重矩阵将是
,其中层输出的高度 x 宽度 x 深度 是该最后一层产生的输出单元数量,m 是全连接层中隐藏单元的数量。然后,在推理(或预测)过程中,我们将最后一个卷积/池化层的输出重新调整为大小为
,并执行以下矩阵乘法以获得 h:

结果全连接层的行为就像一个全连接神经网络,其中有多个全连接层和一个输出层。输出层可以是一个用于分类问题的 softmax 分类层,或者一个用于回归问题的线性层。
将一切结合起来
现在我们将讨论卷积层、池化层和全连接层如何结合在一起形成一个完整的 CNN。
如图 5.11所示,卷积层、池化层和全连接层结合在一起,形成一个端到端的学习模型,该模型接受原始数据(可以是高维的,例如 RGB 图像),并产生有意义的输出(例如物体的类别)。首先,卷积层学习图像的空间特征。
较低的卷积层学习低级特征,如图像中不同方向的边缘,而较高的层学习更高级的特征,如图像中出现的形状(例如,圆形和三角形)或物体的更大部分(例如,狗的脸、狗的尾巴和汽车的前部)。中间的池化层使这些学习到的特征稍微具有平移不变性。这意味着,在新图像中,即使该特征相对于在学习图像中出现的位置稍微偏移,CNN 仍然能够识别该特征。最后,全连接层将 CNN 学到的高级特征结合起来,生成全局表示,这些表示将由最终输出层用于确定物体属于哪个类别:

图 5.11:结合卷积层、池化层和全连接层形成 CNN
在对 CNN 有了强烈的概念理解后,我们现在将开始我们的第一个用例:使用 CNN 模型进行图像分类。
练习 – 使用 CNN 对 Fashion-MNIST 进行图像分类
这将是我们第一次使用 CNN 进行实际机器学习任务的示例。我们将使用 CNN 对图像进行分类。不从 NLP 任务开始的原因是,应用 CNN 于 NLP 任务(例如,句子分类)并不是非常直接。使用 CNN 处理此类任务需要一些技巧。然而,CNN 最初是为应对图像数据而设计的。因此,我们从这里开始,然后逐步探索 CNN 如何应用于 NLP 任务的 使用 CNN 进行句子分类 部分。
关于数据
在这个练习中,我们将使用一个在计算机视觉社区中广为人知的数据集:Fashion-MNIST 数据集。Fashion-MNIST 受到著名的 MNIST 数据集的启发(yann.lecun.com/exdb/mnist/)。MNIST 是一个包含手写数字(0 到 9,即 10 个数字)标签图像的数据库。然而,由于 MNIST 图像分类任务的简单性,MNIST 的测试准确率几乎接近 100%。截至本文撰写时,流行的研究基准网站 paperswithcode.com 发布了 99.87% 的测试准确率(paperswithcode.com/sota/image-classification-on-mnist)。正因如此,Fashion-MNIST 应运而生。
Fashion-MNIST 包含衣物图像。我们的任务是将每件衣物分类到一个类别中(例如:连衣裙、T 恤)。该数据集包含两个数据集:训练集和测试集。我们将在训练集上进行训练,并在未见过的测试数据集上评估模型的性能。我们还将把训练集分成两个部分:训练集和验证集。我们将使用验证数据集作为模型的持续性能监测机制。我们稍后会详细讨论,但我们会看到,通过简单的训练,模型可以达到大约 88% 的测试准确率,而无需任何特殊的正则化或技巧。
下载和探索数据
第一个任务是下载并探索数据。为了下载数据,我们将直接使用 tf.keras.datasets 模块,因为它提供了多个数据集,能够通过 TensorFlow 方便地进行下载。要查看其他可用的数据集,请访问 www.tensorflow.org/api_docs/python/tf/keras/datasets。本章的完整代码位于 Ch05-Sentence-Classification 文件夹中的 ch5_image_classification_fashion_mnist.ipynb 文件里。只需调用以下函数即可下载数据:
(train_images, train_labels), (test_images, test_labels) = tf.keras.datasets.fashion_mnist.load_data()
数据将被下载到 TensorFlow 指定的默认缓存目录(例如:~/.keras/dataset/fasion_minst)。
接着,我们将通过打印数据的形状来看数据的大小:
print("train_images is of shape: {}".format(train_images.shape))
print("train_labels is of shape: {}".format(train_labels.shape))
print("test_images is of shape: {}".format(test_images.shape))
print("test_labels is of shape: {}".format(test_labels.shape))
这将产生:
train_images is of shape: (60000, 28, 28)
train_labels is of shape: (60000,)
test_images is of shape: (10000, 28, 28)
test_labels is of shape: (10000,)
我们可以看到,我们有 60,000 张训练图像,每张大小为 28x28,还有 10,000 张相同尺寸的测试图像。标签是简单的类别 ID,范围从 0 到 9。我们还将创建一个变量来包含类别 ID 到类别名称的映射,这将在探索和训练后分析中帮助我们:
# Available at: https://www.tensorflow.org/api_docs/python/tf/keras/
# datasets/fashion_mnist/load_data
label_map = {
0: "T-shirt/top", 1: "Trouser", 2: "Pullover", 3: "Dress", 4: "Coat",
5: "Sandal", 6: "Shirt", 7: "Sneaker", 8: "Bag", 9: "Ankle boot"
}
我们还可以绘制图像,这将生成如下的图像图表(图 5.12):

图 5.12:Fashion-MNIST 数据集中图像的概览
最后,我们将通过在每个张量的末尾添加一个新的维度(大小为 1)来扩展 train_images 和 test_images。TensorFlow 中卷积操作的标准实现是针对四维输入设计的(即批次、高度、宽度和通道维度)。
在这里,图像中省略了通道维度,因为它们是黑白图像。因此,为了符合 TensorFlow 卷积操作的维度要求,我们需要在图像中添加这一额外的维度。这是使用 CNN 中卷积操作的必要条件。你可以按如下方式进行:
train_images = train_images[:, : , :, None]
test_images = test_images[:, : ,: , None]
使用 NumPy 提供的索引和切片功能,你可以像上面那样简单地通过在索引时添加 None 维度来给张量添加新的维度。现在我们来检查张量的形状:
print("train_images is of shape: {}".format(train_images.shape))
print("test_images is of shape: {}".format(test_images.shape))
这会得到:
train_images is of shape: (60000, 28, 28, 1)
test_images is of shape: (10000, 28, 28, 1)
让我们尝试实现一个可以从这些数据中学习的 CNN 模型。
实现 CNN
在这一小节中,我们将查看 TensorFlow 实现 CNN 时的一些重要代码片段。完整的代码可在 Ch05-Sentence-Classification 文件夹中的 ch5_image_classification_mnist.ipynb 文件中找到。首先,我们将定义一些重要的超参数。代码注释已经自解释,这些超参数的作用如下:
batch_size = 100 # This is the typical batch size we've been using
image_size = 28 # This is the width/height of a single image
# Number of color channels in an image. These are black and white images
n_channels = 1
# Number of different digits we have images for (i.e. classes)
n_classes = 10
这样,我们就可以开始实现模型了。我们将从最早期的 CNN 模型之一 LeNet 获取灵感,LeNet 在 LeCun 等人的论文《基于梯度的学习应用于文档识别》中提出(yann.lecun.com/exdb/publis/pdf/lecun-01a.pdf)。这个模型是一个很好的起点,因为它虽然简单,但在数据集上能够取得相当不错的表现。我们将对原始模型做一些微小的修改,因为原始模型处理的是 32x32 尺寸的图像,而在我们的案例中,图像是 28x28 尺寸的。
让我们快速浏览一下模型的细节。它具有以下层序列:
-
一个具有 5x5 卷积核、1x1 步幅和有效填充的卷积层
-
一个具有 2x2 卷积核、2x2 步幅和有效池化的最大池化层
-
一个具有 5x5 卷积核、1x1 步幅和有效池化的卷积层
-
一个具有 2x2 卷积核、2x2 步幅和有效池化的最大池化层
-
一个具有 4x4 卷积核、1x1 步幅和有效池化的卷积层
-
一个将 2D 输出展平为 1D 向量的层
-
一个具有 84 个节点的 Dense 层
-
一个最终的 softmax 预测层,包含 10 个节点
在这里,除了最后一层外,所有层都使用 ReLU(修正线性单元)激活函数。CNN 模型中的卷积层将我们之前讨论的卷积操作推广到多通道输入,并产生多通道输出。让我们来理解一下这是什么意思。我们看到的原始卷积操作作用于一个简单的二维平面,具有高度 h 和宽度 w。接下来,卷积核在平面上移动,每个位置生成一个单一值。这个过程会生成另一个二维平面。但是在实际应用中,CNN 模型处理的是四维输入,即形状为 [batch size, height, width, in channels] 的输入,并生成一个四维输出,即形状为 [batch size, height, width, out channels] 的输出。为了生成这个输出,卷积核需要是一个四维张量,具有 [kernel height, kernel width, in channels, out channels] 的维度。
可能一开始不太清楚为什么输入、输出和卷积核需要采用这种格式。图 5.13 解释了这一点。

图 5.13:二维卷积层的输入和输出形状
接下来,我们将概述整个模型。如果你一开始没有理解,不用担心。我们会逐行讲解,帮助你理解模型的构建过程:
from tensorflow.keras.layers import Conv2D, MaxPool2D, Flatten, Dense
from tensorflow.keras.models import Sequential
import tensorflow.keras.backend as K
K.clear_session()
lenet_like_model = Sequential([
# 1st convolutional layer
Conv2D(
filters=16, kernel_size=(5,5), strides=(1,1), padding='valid',
activation='relu',
input_shape=(image_size,image_size,n_channels)
), # in 28x28 / out 24x24
# 1st max pooling layer
MaxPool2D(pool_size=(2,2), strides=(2,2), padding='valid'),
# in 24x24 / out 12x12
# 2nd convolutional layer
Conv2D(filters=16, kernel_size=(5,5), strides=(1,1),
padding='valid', activation='relu'), # in 12x12 / out 8x8
# 2nd max pooling layer
MaxPool2D(pool_size=(2,2), strides=(2,2), padding='valid'),
# in 8x8 / out 4x4
# 3rd convolutional layer
Conv2D(filters=120, kernel_size=(4,4), strides=(1,1),
padding='valid', activation='relu'), # in 4x4 / out 1x1
# flatten the output of the last layer to suit a fully connected layer
Flatten(),
# First dense (fully-connected) layer
Dense(84, activation='relu'),
# Final prediction layer
Dense(n_classes, activation='softmax')
])
首先需要注意的是,我们使用的是 Keras 的 Sequential API。我们在这里实现的 CNN 模型由一系列层按顺序连接。因此,我们将使用最简单的 API。接下来是我们第一个卷积层。我们已经讨论过卷积操作。让我们来看第一行:
Conv2D(
filters=16, kernel_size=(5,5), strides=(1,1), padding='valid',
activation='relu',
input_shape=(image_size,image_size,n_channels)
)
tensorflow.keras.layers.Conv2D 层接受如下参数值,顺序如下:
-
filters(int): 这是输出滤波器的数量(即输出通道的数量)。 -
kernel_size(Tuple[int]): 这是卷积核的(高度,宽度)。 -
strides(Tuple[int]): 这个参数表示输入的高度和宽度维度上的步幅。 -
padding(str): 这个参数表示填充类型(可以是'SAME'或'VALID')。 -
activation(str): 使用的非线性激活函数。 -
input_shape(Tuple[int]): 输入的形状。在定义input_shape时,我们不需要指定批次维度,因为它会自动添加。
接下来,我们有第一个最大池化层,其形式如下:
MaxPool2D(pool_size=(2,2), strides=(2,2), padding='valid')
这些参数与tf.keras.layers.Conv2D中的参数非常相似。pool_size参数对应于kernel_size参数,用于指定池窗口的(高度,宽度)。按照类似的模式,以下卷积和池化层被定义。最终的卷积层输出大小为[batch size, 1, 1, 120]。高度和宽度维度为 1,因为 LeNet 的设计使得最后一个卷积核的高度和宽度与输出相同。在将这个输入送入全连接层之前,我们需要将其展平,使其形状为[batch size, 120]。这是因为标准的 Dense 层接受的是二维输入。为此,我们使用tf.keras.layers.Flatten()层:
Flatten(),
最后,我们定义两个 Dense 层如下。
Dense(84, activation='relu'),
Dense(n_classes, activation='softmax')
最后一步,我们将使用稀疏类别交叉熵损失函数和 Adam 优化器来编译模型。我们还将跟踪数据上的准确率:
lenet_like_model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
数据准备好且模型完全定义后,我们可以开始训练模型。模型训练非常简单,只需调用一个函数:
lenet_like_model.fit(train_images, train_labels, validation_split=0.2, batch_size=batch_size, epochs=5)
tf.keras.layers.Model.fit()接受许多参数。但我们这里只讨论我们在这里使用的那些:
-
x(np.ndarray/tf.Tensor/ 其他):接受一个张量,作为模型的输入(实现为 NumPy 数组或 TensorFlow 张量)。但是,接受的值不仅限于张量。要查看完整的列表,请参阅www.tensorflow.org/api_docs/python/tf/keras/Model#fit。 -
y(np.ndarray/tf.Tensor):接受一个张量,该张量将作为模型的标签(目标)。 -
validation_split(float):设置此参数意味着训练数据的一部分(例如,0.2 表示 20%)将作为验证数据。 -
epochs(int):训练模型的轮数。
你可以通过调用以下命令在测试数据上评估训练好的模型:
lenet_like_model.evaluate(test_images, test_labels)
运行后,你将看到如下输出:
313/313 [==============================] - 1s 2ms/step - loss: 0.3368 - accuracy: 0.8806
训练后的模型应达到约 88%的准确率。
你刚刚学会了我们用来创建第一个 CNN 的函数。你学会了如何使用这些函数来实现 CNN 结构、定义损失、最小化损失并获得未见数据的预测。我们使用了一个简单的 CNN 来看看它是否能够学习分类服装物品。此外,我们成功地用一个相对简单的 CNN 达到了超过 88%的准确率。接下来,我们将分析 CNN 生成的一些结果。我们将了解为什么 CNN 没有正确识别一些图像。
分析 CNN 生成的预测结果
在这里,我们可以从测试集中随机挑选一些正确和错误分类的样本,以评估 CNN 的学习能力(见图 5.14)。
我们可以看到,对于正确分类的实例,卷积神经网络(CNN)对输出的信心通常非常高。这是一个好兆头,表明模型正在做出非常自信且准确的决策。然而,当我们评估错误分类的实例时,我们可以发现其中一些实例确实很难,甚至人类也可能会犯错。例如,对于一个被分类为凉鞋的 ankle boot,其上有一个大的黑色补丁,这可能表明有带子,导致它更可能被认为是凉鞋(第三行从右数第三张图)。此外,在第三行从右数第五张图中,很难判断它是衬衫还是有领 T 恤:

图 5.14:Fashion-MNIST 正确分类和错误分类的实例
使用 CNN 进行句子分类
尽管 CNN 主要用于计算机视觉任务,但没有什么能阻止它们用于 NLP 应用。如前所述,CNN 最初是为视觉内容设计的。因此,使用 CNN 进行 NLP 任务需要更多的努力。这也是我们从简单的计算机视觉问题开始学习 CNN 的原因。CNN 是机器学习问题的一个有吸引力的选择,因为卷积层的参数数量较少。CNN 在 NLP 中的一个有效应用是句子分类。
在句子分类中,给定的句子应该被分类到一个类别中。我们将使用一个问题数据库,其中每个问题都按其主题进行标记。例如,问题 “Who was Abraham Lincoln?” 将被标记为问题,其标签为 Person。为此,我们将使用一个句子分类数据集,数据集可通过 cogcomp.org/Data/QA/QC/ 获取;你将在这里找到多个数据集。我们使用的是包含约 5,500 个训练问题及其相应标签和 500 个测试句子的集合。
我们将使用 Yoon Kim 在论文《卷积神经网络用于句子分类》中介绍的 CNN 网络,来理解 CNN 在自然语言处理(NLP)任务中的价值。然而,使用 CNN 进行句子分类与我们之前讨论的 Fashion-MNIST 示例有所不同,因为现在的操作(例如卷积和池化)发生在一个维度(长度)中,而不是两个维度(高度和宽度)。此外,池化操作也会与正常的池化操作有所不同,正如我们很快会看到的那样。你可以在 Ch5-Sentence-Classification 文件夹中的 ch5_cnn_sentence_classification.ipynb 文件找到这个练习的代码。作为第一步,我们将理解数据。
句子分类的数据转换方式
假设一句话有 p 个单词。首先,如果句子的长度小于 n,我们将为句子填充一些特殊的单词(将句子长度设置为 n 个单词),如
所示。接下来,我们将句子中的每个单词表示为一个大小为 k 的向量,该向量可以是一个独热编码表示,或者是使用 skip-gram、CBOW 或 GloVe 学习的 Word2vec 词向量。然后,一批大小为 b 的句子可以表示为一个
矩阵。
让我们通过一个例子来演示。让我们考虑以下三句话:
-
鲍勃和玛丽是朋友。
-
鲍勃踢足球。
-
玛丽喜欢在合唱团里唱歌。
在这个例子中,第三个句子有最多的单词,因此我们设置 n = 7,即第三个句子中的单词数。接下来,让我们来看一下每个单词的独热编码表示。在这种情况下,有 13 个不同的单词。因此,我们得到如下表示:
鲍勃: 1,0,0,0,0,0,0,0,0,0,0,0,0
和: 0,1,0,0,0,0,0,0,0,0,0,0,0
玛丽: 0,0,1,0,0,0,0,0,0,0,0,0,0
同样,k = 13,原因相同。使用这种表示,我们可以将三句话表示为一个大小为 3 x 7 x 13 的三维矩阵,如 图 5.15 所示:

图 5.15:一批句子表示为句子矩阵
你也可以在这里使用词嵌入代替独热编码。将每个单词表示为独热编码特征会引入稀疏性并浪费计算内存。通过使用词嵌入,我们使得模型能够学习到比独热编码更紧凑、更强大的单词表示。这也意味着
成为一个超参数(即嵌入大小),而不是由词汇表的大小驱动。这意味着,在图 5.15中,每一列将是一个分布式的连续向量,而不是由 0 和 1 组成的组合。
我们知道,独热向量会导致高维和高度稀疏的表示,且不理想。另一方面,词向量提供了更丰富的单词表示。然而,学习词向量的计算成本较高。还有一种替代方法叫做哈希技巧。哈希技巧的优点在于它非常简单,但提供了一个强大且经济的替代方案,介于独热向量和词向量之间。哈希技巧背后的想法是使用哈希函数将给定的标记转换为整数。
f(
这里的f是一个选定的哈希函数。一些常见的哈希函数包括 SHA(brilliant.org/wiki/secure-hashing-algorithms/)和 MD5(searchsecurity.techtarget.com/definition/MD5)。还有更高级的哈希方法,比如局部敏感哈希(www.pinecone.io/learn/locality-sensitive-hashing/),可以为形态上相似的词语生成相似的 ID。你可以通过 TensorFlow(www.tensorflow.org/api_docs/python/tf/keras/preprocessing/text/hashing_trick)轻松使用哈希技巧。
实现 – 下载并准备数据
首先,我们将从网上下载数据。数据下载功能在笔记本中提供,简单地下载了两个文件:训练数据和测试数据(文件路径保存在train_filename和test_filename中)。
如果你打开这些文件,你会看到它们包含一系列文本行。每一行的格式是:
<Category>: <sub-category> <question>
每个问题都有两个元数据:一个类别和一个子类别。类别是宏观分类,子类别则是对问题类型的更细致的划分。共有六个类别:DESC(描述相关)、ENTY(实体相关)、HUM(人类相关)、ABBR(缩写相关)、NUM(数字相关)和LOC(地点相关)。每个类别下有若干子类别。例如,ENTY类别进一步细分为动物、货币、事件、食物等。在我们的任务中,我们将专注于高级分类(即六个类别),但你也可以通过最小的修改,利用相同的模型进行子类别层次的分类。
一旦文件下载完成,我们将把数据读入内存。为此,我们将实现read_data()函数:
def read_data(filename):
'''
Read data from a file with given filename
Returns a list of strings where each string is a lower case word
'''
# Holds question strings, categories and sub categories
# category/sub_cateory definitions: https://cogcomp.seas.upenn.edu/
# Data/QA/QC/definition.html
questions, categories, sub_categories = [], [], []
with open(filename,'r',encoding='latin-1') as f:
# Read each line
for row in f:
# Each string has format <cat>:<sub cat> <question>
# Split by : to separate cat and (sub_cat + question)
row_str = row.split(":")
cat, sub_cat_and_question = row_str[0], row_str[1]
tokens = sub_cat_and_question.split(' ')
# The first word in sub_cat_and_question is the sub
# category rest is the question
sub_cat, question = tokens[0], ' '.join(tokens[1:])
questions.append(question.lower().strip())
categories.append(cat)
sub_categories.append(sub_cat)
return questions, categories, sub_categories
train_questions, train_categories, train_sub_categories = read_data(train_filename)
test_questions, test_categories, test_sub_categories = read_data(test_filename)
这个函数简单地遍历文件中的每一行,并按照上述格式分离问题、类别和子类别。然后,将每个问题、类别和子类别分别写入questions、categories和sub_categories列表。最后,函数返回这些列表。通过为训练和测试数据提供questions、categories和sub_categories,我们将为训练和测试数据创建pandas数据框。
pandas数据框是一种用于存储多维数据的表达型数据结构。一个数据框可以有索引、列和数值。每个值都有特定的索引和列。创建一个数据框是相当简单的:
# Define training and testing
train_df = pd.DataFrame(
{'question': train_questions, 'category': train_categories,
'sub_category': train_sub_categories}
)
test_df = pd.DataFrame(
{'question': test_questions, 'category': test_categories,
'sub_category': test_sub_categories}
)
我们使用字典调用 pd.DataFrame 构造函数。字典的键表示 DataFrame 的列,值表示每列中的元素。这里我们创建了三个列:question、category 和 sub_category。
图 5.16 展示了 train_df 的样子。

图 5.16:在 pandas DataFrame 中捕获的数据示例
我们将对训练集中的行进行简单的洗牌,以确保不会在数据中引入任何无意的顺序:
# Shuffle the data for better randomization
train_df = train_df.sample(frac=1.0, random_state=seed)
该过程将从 DataFrame 中随机抽样 100% 的数据。换句话说,它将打乱行的顺序。从此时起,我们将不再考虑 sub_category 列。我们将首先将每个类别标签映射到一个类别 ID:
# Generate the label to ID mapping
unique_cats = train_df["category"].unique()
labels_map = dict(zip(unique_cats, np.arange(unique_cats.shape[0])))
print("Label->ID mapping: {}".format(labels_map))
n_classes = len(labels_map)
# Convert all string labels to IDs
train_df["category"] = train_df["category"].map(labels_map)
test_df["category"] = test_df["category"].map(labels_map)
我们首先识别 train_df["category"] 中存在的唯一值。然后,我们将通过将唯一值映射到数字 ID(0 到 5)的列表来创建一个字典。np.arange() 函数可以用来生成一个指定范围内的整数序列(这里,范围是从 0 到 unique_cats 的长度)。这个过程将生成以下 labels_map。
标签->ID 映射:{0: 0, 1: 1, 2: 2, 4: 3, 3: 4, 5: 5}
然后,我们简单地将这个映射应用于训练和测试 DataFrame 的类别列,将字符串标签转换为数字标签。转换后的数据如下所示(图 5.17)。

图 5.17:在将类别映射为整数后,DataFrame 中的数据示例
我们创建一个验证集,源自原始训练集,用于在训练过程中监控模型表现。我们将使用 scikit-learn 库中的train_test_split()函数。10%的数据将作为验证数据,其余 90% 保留作为训练数据。
from sklearn.model_selection import train_test_split
train_df, valid_df = train_test_split(train_df, test_size=0.1)
print("Train size: {}".format(train_df.shape))
print("Valid size: {}".format(valid_df.shape))
输出如下:
Train size: (4906, 3)
Valid size: (546, 3)
我们可以看到,大约 4,900 个示例用于训练,剩余的作为验证。在接下来的部分,我们将构建一个分词器来对问题进行分词,并为每个词汇分配数字 ID。
实现 – 构建分词器
接下来,到了构建分词器的时刻,它可以将单词映射为数字 ID:
from tensorflow.keras.preprocessing.text import Tokenizer
# Define a tokenizer and fit on train data
tokenizer = Tokenizer()
tokenizer.fit_on_texts(train_df["question"].tolist())
这里,我们简单地创建一个 Tokenizer 对象,并使用 fit_on_texts() 函数在训练语料库上训练它。在这个过程中,分词器会将词汇表中的单词映射为 ID。我们将把训练集、验证集和测试集中的所有输入转换为单词 ID 的序列。只需调用 tokenizer.texts_to_sequences() 函数,并传入一个字符串列表,每个字符串代表一个问题:
# Convert each list of tokens to a list of IDs, using tokenizer's mapping
train_sequences = tokenizer.texts_to_sequences(train_df["question"].tolist())
valid_sequences = tokenizer.texts_to_sequences(valid_df["question"].tolist())
test_sequences = tokenizer.texts_to_sequences(test_df["question"].tolist())
重要的是要理解,我们每次给模型输入一批问题。所有问题的词数不太可能相同。如果所有问题的词数不相同,我们无法形成一个张量,因为问题的长度不一致。为了解决这个问题,我们必须通过特殊符号填充较短的序列,并截断超过指定长度的序列。为了实现这一点,我们可以轻松使用tf.keras.preprocessing.sequence.pad_sequences()函数。值得一提的是,我们可以仔细查看该函数所接受的参数:
-
sequences (List[List[int]])– 整数列表的列表;每个整数列表是一个序列 -
maxlen (int)– 最大填充长度 -
padding (string)– 是否在开头(pre)或结尾(post)进行填充 -
truncating (string)– 是否在开头(pre)或结尾(post)进行截断 -
value (int)– 用于填充的值(默认为 0)
在下面的代码中,我们使用这个函数为训练、验证和测试数据创建序列矩阵:
max_seq_length = 22
# Pad shorter sentences and truncate longer ones (maximum length: max_seq_
# length)
preprocessed_train_sequences = tf.keras.preprocessing.sequence.pad_sequences(
train_sequences, maxlen=max_seq_length, padding='post',
truncating='post'
)
preprocessed_valid_sequences = tf.keras.preprocessing.sequence.pad_sequences(
valid_sequences, maxlen=max_seq_length, padding='post',
truncating='post'
)
preprocessed_test_sequences = tf.keras.preprocessing.sequence.pad_sequences(
test_sequences, maxlen=max_seq_length, padding='post',
truncating='post'
)
我们选择 22 作为序列长度的原因是通过简单的分析得出的。训练语料库中序列长度的 99% 百分位数为 22。因此,我们选择了这个值。另一个重要统计信息是词汇表大小大约为 7,880 个词。接下来我们将讨论模型。
句子分类 CNN 模型
现在我们将讨论用于句子分类的 CNN 的技术细节。首先,我们将讨论如何将数据或句子转换为可以方便地由 CNN 处理的首选格式。接下来,我们将讨论如何将卷积和池化操作适应于句子分类,最后,我们将讨论如何将所有这些组件连接起来。
卷积操作
如果忽略批量大小,即假设我们每次只处理一个句子,我们的数据是一个
矩阵,其中 n 是填充后每个句子的单词数,k 是单个词向量的维度。在我们的例子中,这将是 7 x 13。
现在我们将定义卷积权重矩阵,其大小为
,其中 m 是一维卷积操作的过滤器大小。通过将输入 x 的大小为
与大小为
的权重矩阵 W 卷积,我们将得到大小为
的输出 h,其计算过程如下:

在这里,w[i,j] 是 W 的 (i,j)^(th) 元素,我们将使用零填充 x,使得 h 的大小为
。此外,我们将更简洁地定义这个操作,如下所示:

在这里,定义了卷积操作(带填充),并且我们将添加一个额外的标量偏置b。图 5.18*展示了这一操作:

图 5.18:句子分类的卷积操作。使用不同的卷积核宽度的卷积层对句子(即标记序列)进行卷积
然后,为了学习丰富的特征,我们有并行层,使用不同的卷积过滤器大小。每个卷积层输出大小为
的隐藏向量,我们将这些输出连接起来,作为下一层的输入,大小为
,其中q是我们将使用的并行层的数量。q越大,模型的性能越好。
卷积的值可以通过以下方式理解。想象一下电影评分学习问题(有两个类别,正面或负面),我们有以下句子:
-
我喜欢这部电影,还不错
-
我不喜欢这部电影,差劲
现在想象一个大小为 5 的卷积窗口。我们将根据卷积窗口的移动来对单词进行分箱。
句子I like the movie, not too bad给出了:
[I, like, the, movie, ‘,’]
[like, the, movie, ‘,’, not]
[the, movie, ‘,’, not, too]
[movie, ‘,’, not, too, bad]
句子I did not like the movie, bad给出了以下结果:
[I, did, not, like, the]
[did, not ,like, the, movie]
[not, like, the, movie, ‘,’]
[like, the, movie, ‘,’, bad]
对于第一个句子,像以下的窗口会传达评分为正面:
[I, like, the, movie, ‘,’]
[movie, ‘,’, not, too, bad]
然而,对于第二个句子,像以下的窗口会传达出负面的评分信息:
[did, not, like, the, movie]
我们能够看到这样的模式,它们帮助分类评分,这得益于保留的空间性。例如,如果你使用像词袋模型这样的技术来计算句子的表示,这会丢失空间信息,那么上述两个句子的表示将会非常相似。卷积操作在保留句子空间信息方面起着重要作用。
通过具有不同过滤器大小的q个层,网络学习如何提取不同大小短语的评分,从而提高性能。
时间池化
池化操作旨在对之前讨论的并行卷积层产生的输出进行下采样。具体实现如下:
假设最后一层的输出h的大小是
。时间池化层将生成一个输出h’,大小为
。精确的计算如下:

这里,
和h^((i))是由第i层卷积产生的输出,
是属于该层的权重集。简单来说,时间池化操作通过连接每个卷积层的最大元素来创建一个向量。
我们将在图 5.19中说明这个操作:

图 5.19:用于句子分类的时间池化操作
通过结合这些操作,我们最终得到了如图 5.20所示的架构:

图 5.20:句子分类 CNN 架构。具有不同核宽度的卷积层池生成一组输出序列。这些序列被送入“时间池化”层,生成该输入的紧凑表示。最后,这些被连接到具有 softmax 激活的分类层:
实现 – 使用 CNN 进行句子分类
我们开始在 TensorFlow 2 中实现模型。在此之前,让我们从 TensorFlow 中导入几个必要的模块:
import tensorflow.keras.backend as K
import tensorflow.keras.layers as layers
import tensorflow.keras.regularizers as regularizers
from tensorflow.keras.models import Model
清除当前运行的会话,以确保之前的运行不会干扰当前的运行:
K.clear_session()
在我们开始之前,我们将使用 Keras 的功能性 API。这样做的原因是我们将在这里构建的模型不能使用顺序 API 构建,因为该模型中有复杂的路径。我们先从创建一个输入层开始:
Input layer takes word IDs as inputs
word_id_inputs = layers.Input(shape=(max_seq_length,), dtype='int32')
输入层简单地接收一个max_seq_length的单词 ID 批次。也就是说,接收一批序列,其中每个序列都填充或截断到最大长度。我们将dtype指定为int32,因为它们是单词 ID。接下来,我们定义一个嵌入层,在该层中我们将查找与通过word_id_inputs层传入的单词 ID 对应的嵌入:
# Get the embeddings of the inputs / out [batch_size, sent_length,
# output_dim]
embedding_out = layers.Embedding(input_dim=n_vocab, output_dim=64)(word_id_inputs)
这是一个随机初始化的嵌入层。它包含一个大小为[n_vocab, 64]的大矩阵,其中每一行表示由该行编号索引的单词的词向量。嵌入将与模型共同学习,同时在监督任务上训练模型。在下一部分中,我们将定义三个不同的一维卷积层,分别使用三个不同的核(过滤器)大小:3、4和5,每个卷积层有 100 个特征图:
# For all layers: in [batch_size, sent_length, emb_size] / out [batch_
# size, sent_length, 100]
conv1_1 = layers.Conv1D(
100, kernel_size=3, strides=1, padding='same',
activation='relu'
)(embedding_out)
conv1_2 = layers.Conv1D(
100, kernel_size=4, strides=1, padding='same',
activation='relu'
)(embedding_out)
conv1_3 = layers.Conv1D(
100, kernel_size=5, strides=1, padding='same',
activation='relu'
)(embedding_out)
这里需要做出一个重要区分,我们使用的是一维卷积,而不是之前练习中使用的二维卷积。然而,大多数概念仍然相同。主要的区别在于,tf.keras.layers.Conv2D作用于四维输入,而tf.keras.layers.Conv1D作用于三维输入(即形状为[batch size, width, in channels]的输入)。换句话说,卷积核仅沿一个方向在输入上滑动。这些层的每个输出都会产生一个形状为[batch size, sentence length, 100]的张量。然后,这些输出会在最后一个轴上连接,形成一个单一的张量:
# in previous conv outputs / out [batch_size, sent_length, 300]
conv_out = layers.Concatenate(axis=-1)([conv1_1, conv1_2, conv1_3])
随后,新的张量大小为[batch size, sentence length, 300],将用于执行时间池化操作。我们可以通过定义一个一维最大池化层(即tf.keras.layers.MaxPool1D)来实现时间池化操作,其窗口宽度与序列长度相同。这样会为conv_out中的每个特征图生成一个单一值作为输出:
# Pooling over time operation.
# This is doing the max pooling over sequence length
# in other words, each feature map results in a single output
# in [batch_size, sent_length, 300] / out [batch_size, 1, 300]
pool_over_time_out = layers.MaxPool1D(pool_size=max_seq_length, padding='valid')(conv_out)
这里我们在执行操作后获得了一个[batch_size, 1, 300]大小的输出。接下来,我们将使用tf.keras.layers.Flatten层将此输出转换为[batch_size, 300]大小的输出。Flatten 层将所有维度(除了批次维度)压缩为一个维度:
# Flatten the unit length dimension
flatten_out = layers.Flatten()(pool_over_time_out)
最后,flatten_out将传递到一个全连接层,该层具有n_classes(即六个)节点作为输出,并且使用 softmax 激活函数:
# Compute the final output
out = layers.Dense(
n_classes, activation='softmax',
kernel_regularizer=regularizers.l2(0.001)
)(flatten_out)
注意使用了kernel_regularizer参数。我们可以使用该参数为给定层添加任何特殊的正则化(例如 L1 或 L2 正则化)。最后,我们定义一个模型如下,
# Define the model
cnn_model = Model(inputs=word_id_inputs, outputs=out)
使用所需的损失函数、优化器和评估指标来编译模型:
# Compile the model with loss/optimzier/metrics
cnn_model.compile(
loss='sparse_categorical_crossentropy',
optimizer='adam',
metrics=['accuracy']
)
你可以通过运行以下代码查看模型:
cnn_model.summary()
结果为,
Model: "model"
______________________________________________________________________
Layer (type) Output Shape Param # Connected to
======================================================================
input_1 (InputLayer) [(None, 22)] 0
______________________________________________________________________
embedding (Embedding) (None, 22, 64) 504320 input_1[0][0]
______________________________________________________________________
conv1d (Conv1D) (None, 22, 100) 19300 embedding[0][0]
______________________________________________________________________
conv1d_1 (Conv1D) (None, 22, 100) 25700 embedding[0][0]
______________________________________________________________________
conv1d_2 (Conv1D) (None, 22, 100) 32100 embedding[0][0]
______________________________________________________________________
concatenate (Concatenate) (None, 22, 300) 0 conv1d[0][0]
conv1d_1[0][0]
conv1d_2[0][0]
______________________________________________________________________
max_pooling1d (MaxPooling1D) (None, 1, 300) 0 concatenate[0][0]
______________________________________________________________________
flatten (Flatten) (None, 300) 0 max_pooling1d[0][0]
______________________________________________________________________
dense (Dense) (None, 6) 1806 flatten[0][0]
======================================================================
Total params: 583,226
Trainable params: 583,226
Non-trainable params: 0
______________________________________________________________________
接下来,我们将在已经准备好的数据上训练模型。
训练模型
由于我们在开始时已经做好了基础工作,确保数据已经转换,因此训练模型非常简单。我们需要做的就是调用tf.keras.layers.Model.fit()函数。不过,我们可以通过利用一些技术来提升模型性能。我们将使用 TensorFlow 内置的回调函数来实现这一点。我们要使用的技术叫做“学习率衰减”。其思想是,当模型停止提高性能时,按某个比例减少学习率。以下回调函数可以帮助我们实现这一点:
# Call backs
lr_reduce_callback = tf.keras.callbacks.ReduceLROnPlateau(
monitor='val_loss', factor=0.1, patience=3, verbose=1,
mode='auto', min_delta=0.0001, min_lr=0.000001
)
可以根据需要设置这些参数来控制学习率的减少。让我们理解上面提到的参数:
-
monitor (str)– 用于监控的指标,以便衰减学习率。我们将监控验证损失 -
factor (float)– 降低学习率的倍数。例如,0.1 的因子意味着学习率将减少 10 倍(例如,0.01 将降到 0.001) -
patience (int)– 在没有改进的情况下,等待多少个 epoch 后才会降低学习率 -
mode (string)– 指定是否寻找指标的增加或减少;auto表示方向将根据指标名称确定 -
min_delta (float)– 视为改进的最小增减量 -
min_lr (float)– 最小学习率(下限)
让我们训练模型:
# Train the model
cnn_model.fit(
preprocessed_train_sequences, train_labels,
validation_data=(preprocessed_valid_sequences, valid_labels),
batch_size=128,
epochs=25,
callbacks=[lr_reduce_callback]
)
我们将看到准确率迅速上升,而验证准确率在 88%左右停滞。以下是生成的输出片段:
Epoch 1/50
39/39 [==============================] - 1s 9ms/step - loss: 1.7147 - accuracy: 0.3063 - val_loss: 1.3912 - val_accuracy: 0.5696
Epoch 2/50
39/39 [==============================] - 0s 6ms/step - loss: 1.2268 - accuracy: 0.6052 - val_loss: 0.7832 - val_accuracy: 0.7509
...
Epoch 00015: ReduceLROnPlateau reducing learning rate to 1.0000000656873453e-06.
Epoch 16/50
39/39 [==============================] - 0s 6ms/step - loss: 0.0487 - accuracy: 0.9999 - val_loss: 0.3639 - val_accuracy: 0.8846
Restoring model weights from the end of the best epoch.
Epoch 00016: early stopping
接下来,让我们在测试数据集上测试模型:
cnn_model.evaluate(preprocessed_test_sequences, test_labels, return_dict=True)
按照练习中给出的测试数据进行评估,我们在这个句子分类任务中获得了接近 88%的测试准确率(对于 500 个测试句子)。
在这里,我们结束了关于使用 CNN 进行句子分类的讨论。我们首先讨论了如何将一维卷积操作与一种称为时间池化的特殊池化操作结合,来实现基于 CNN 架构的句子分类器。最后,我们讨论了如何使用 TensorFlow 来实现这样的 CNN,并且看到它在句子分类中的确表现良好。
了解我们刚刚解决的问题如何在实际中应用是很有用的。假设你手上有一本关于罗马历史的厚重文档,而你只想了解关于尤利乌斯·凯撒的内容,而不想读完整本书。在这种情况下,我们刚刚实现的句子分类器可以作为一个有用的工具,帮助你总结出与某个人相关的句子,这样你就不必阅读整篇文档了。
句子分类还可以应用于许多其他任务;其中一个常见的应用是对电影评论进行正负面分类,这对于自动化计算电影评分非常有用。句子分类在医学领域也有重要应用,它可以用来从包含大量文本的大型文档中提取临床有用的句子。
总结
在本章中,我们讨论了卷积神经网络(CNN)及其各种应用。首先,我们详细解释了 CNN 是什么,以及它在机器学习任务中表现优异的能力。接下来,我们将 CNN 分解成几个组件,如卷积层和池化层,并详细讨论了这些操作符的工作原理。此外,我们还讨论了与这些操作符相关的几个超参数,如滤波器大小、步幅和填充。
接着,为了说明 CNN 的功能,我们通过一个简单的例子展示了如何对衣物图像进行分类。我们还进行了一些分析,看看为什么 CNN 在某些图像识别上出现错误。
最后,我们开始讨论了卷积神经网络(CNN)如何应用于自然语言处理(NLP)任务。具体来说,我们讨论了一种修改过的 CNN 架构,可以用于对句子进行分类。然后,我们实现了这一特定的 CNN 架构,并在实际的句子分类任务中进行了测试。
在下一章,我们将进入一种在许多 NLP 任务中广泛应用的神经网络类型——递归神经网络(RNNs)。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,与志同道合的人一起学习,和超过 1000 名成员共同进步,网址:packt.link/nlp

第六章:循环神经网络
循环神经网络 (RNNs) 是一类特殊的神经网络,旨在处理序列数据(即时间序列数据),如股票市场价格或文本序列(例如,可变长度的句子)。RNN 维持一个状态变量,用于捕捉序列数据中存在的各种模式;因此,它们能够建模序列数据。相比之下,传统的前馈神经网络没有这种能力,除非数据被表示为捕捉序列中重要模式的特征表示。然而,提出这样的特征表示是非常困难的。前馈模型用于建模序列数据的另一个替代方案是为每个时间/序列位置设置一组独立的参数,以便为特定位置分配的参数可以学习该位置发生的模式。这会大大增加模型的内存需求。
然而,与前馈网络为每个位置拥有一组独立参数不同,RNN 在时间上共享相同的参数。时间上的参数共享是 RNN 的一个重要部分,实际上是学习时间序列模式的主要推动力之一。然后,状态变量会随着我们在序列中观察到的每个输入而随时间更新。随着时间共享的这些参数,结合状态向量,能够根据序列中先前观察到的值预测序列的下一个值。此外,由于我们每次只处理序列中的一个元素(例如,每次处理文档中的一个单词),RNN 可以处理任意长度的数据,而无需使用特殊标记对数据进行填充。
本章将深入探讨 RNN 的细节。首先,我们将讨论如何通过从一个简单的前馈模型开始来形成一个 RNN。
在此之后,我们将讨论 RNN 的基本功能。我们还将深入探讨 RNN 的基础方程式,例如输出计算和参数更新规则,并讨论几种 RNN 的应用变体:一对一、一对多和多对多的 RNN。我们将通过一个例子,展示如何使用 RNN 来识别命名实体(例如人名、组织名等),这对于构建知识库等下游应用具有重要价值。我们还将讨论一个更复杂的 RNN 模型,该模型能够同时正向和反向读取文本,并使用卷积层提高模型的准确性。本章将通过以下几个主要主题进行讲解:
-
理解 RNN
-
通过时间的反向传播
-
RNN 的应用
-
使用 RNN 进行命名实体识别(NER)
-
使用字符和标记嵌入进行命名实体识别(NER)
理解 RNN
在本节中,我们将通过温和的介绍来讨论 RNN 的定义,然后深入探讨更具体的技术细节。我们之前提到过,RNN 通过维护一个随着时间推移而变化的状态变量来处理更多的数据,从而使其具备建模顺序数据的能力。特别是,这个状态变量通过一组循环连接在时间上不断更新。循环连接的存在是 RNN 和前馈网络之间的主要结构性差异。循环连接可以理解为 RNN 在过去学到的一系列记忆之间的联系,这些记忆与 RNN 当前的状态变量相连接。换句话说,循环连接根据 RNN 所拥有的过去记忆来更新当前的状态变量,使得 RNN 能够基于当前输入以及之前的输入进行预测。
术语 RNN 有时用来指代循环模型家族,它包含许多不同的模型。换句话说,有时它被用作某个特定 RNN 变体的泛化。在这里,我们使用 RNN 这个术语来指代一种最早实现的 RNN 模型,称为 Elman 网络。
在接下来的部分,我们将讨论以下主题。首先,我们将讨论如何通过将前馈网络表示为计算图来开始。
然后我们将通过一个例子来说明前馈网络为什么可能在顺序任务中失败。接着,我们将调整该前馈图来建模顺序数据,这将给我们一个 RNN 的基本计算图。我们还将讨论 RNN 的技术细节(例如,更新规则)。最后,我们将讨论如何训练 RNN 模型的具体细节。
前馈神经网络的问题
为了理解前馈神经网络的局限性以及 RNN 如何解决这些问题,让我们想象一个数据序列:

接下来,假设在现实世界中,x 和 y 之间存在以下关系:


在这里,g[1] 和 g[2] 是转换(例如,乘以权重矩阵后进行非线性转换)。这意味着当前输出 y[t] 依赖于当前状态 h[t],其中 h[t] 是通过当前输入 x[t] 和前一个状态 h[t-1] 计算得出的。这个状态编码了模型历史上观察到的关于先前输入的信息。
现在,让我们想象一个简单的前馈神经网络,我们将通过以下方式表示它:

在这里,y[t] 是某个输入 x[t] 的预测输出。
如果我们使用前馈神经网络来解决这个任务,网络将不得不一次处理一个
,每次将
作为输入。现在,让我们考虑一下这种解决方案在时间序列问题中可能面临的问题。
一个前馈神经网络在时间 t 预测的输出 y[t] 仅依赖于当前输入 x[t]。换句话说,它并不知道导致 x[t] 的输入(即
)。因此,前馈神经网络无法完成这样一个任务:当前的输出不仅依赖于当前输入,还依赖于先前的输入。让我们通过一个例子来理解这一点。
假设我们需要训练一个神经网络来填补缺失的单词。我们有如下短语,并希望预测下一个单词:
詹姆斯有一只猫,它喜欢喝 ____。
如果我们每次处理一个单词并使用前馈神经网络,那么我们只会得到输入 drink,这远远不足以理解这个短语,甚至无法理解上下文(单词 drink 可以出现在许多不同的语境中)。有些人可能会认为,通过一次性处理完整句子,我们可以得到较好的结果。尽管这是对的,但这种方法也有局限性,比如在处理非常长的句子时。然而,现在有一种新的模型家族,称为 Transformer,它们使用完全连接的层来处理完整的数据序列,并且在性能上超越了顺序模型。我们稍后会单独讲解这些模型。
使用 RNN 建模
另一方面,我们可以使用 RNN 来解决这个问题。我们将从已有的数据开始:

假设我们有以下关系:


现在,让我们将 g[1] 替换为一个函数逼近器
,该函数由
参数化,它接受当前输入 x[t] 和系统的先前状态 h[t-1] 作为输入,并生成当前状态 h[t]。然后,我们将 g[2] 替换为
,它接受系统的当前状态 h[t] 并生成 y[t]。这就给出了如下结果:


我们可以将
看作是生成 x 和 y 的真实模型的近似。为了更清楚地理解这一点,让我们将方程展开如下:

例如,我们可以将 y[4] 表示为如下形式:

同样,通过展开,我们得到以下结果(为了清晰起见,省略了
和
):

这可以通过图形来表示,如 图 6.1 所示:

图 6.1:x[t] 和 y[t] 的关系展开
我们可以将该图进行概括,对于任何给定的时间步 t,如 图 6.2 所示:

图 6.2:RNN 结构的单步计算
然而,需要理解的是,h[t-1]实际上是在接收到x[t]之前的h[t]。换句话说,h[t-1]是h[t]在一个时间步之前的值。
因此,我们可以使用循环连接表示h[t]的计算,如图 6.3所示:

图 6.3:带有循环连接的 RNN 单步计算
将一系列方程映射从
到
,如图 6.3所示,这使我们能够将任何y[t]表示为x[t]、h[t-1]和h[t]的函数。这是 RNN 的核心思想。
循环神经网络的技术描述
现在,我们更深入地了解 RNN 的构成,并定义在 RNN 内部发生的计算的数学方程式。我们从我们推导出的两个函数开始,作为学习从x[t]到y[t]的函数逼近器:


如我们所见,神经网络由一组权重、偏置和一些非线性激活函数组成。因此,我们可以将之前的关系写成如下形式:

在这里,tanh 是 tanh 激活函数,U是大小为
的权重矩阵,其中m是隐藏单元的数量,d是输入的维度。此外,W是大小为
的权重矩阵,用于从h[t-1]到h[t]创建循环连接。y[t]的关系由以下方程给出:

在这里,V是大小为
的权重矩阵,c是输出的维度(这可以是输出类别的数量)。在图 6.4中,我们展示了这些权重如何形成一个 RNN。箭头表示数据在网络中的流动方向:

图 6.4:RNN 的结构
到目前为止,我们已经看到如何用计算节点的图表示 RNN,其中边表示计算过程。此外,我们还探讨了 RNN 背后的实际数学原理。现在让我们来看一下如何优化(或训练)RNN 的权重,以便从序列数据中学习。
时间反向传播
在训练 RNN 时,使用一种特殊形式的反向传播,称为时间反向传播(BPTT)。然而,要理解 BPTT,首先我们需要了解BP是如何工作的。然后我们将讨论为什么 BP 不能直接应用于 RNN,但如何将 BP 适应 RNN,从而得出 BPTT。最后,我们将讨论 BPTT 中存在的两个主要问题。
反向传播是如何工作的
反向传播是用来训练前馈神经网络的技术。在反向传播过程中,你会执行以下操作:
-
计算给定输入的预测
-
通过将预测与输入的实际标签进行比较,计算预测的误差 E(例如,均方误差和交叉熵损失)
-
通过在梯度的相反方向上迈出小步,更新前馈网络的权重,以最小化在步骤 2中计算的损失,针对所有 w[ij],其中 w[ij] 是 i^(th) 层的 j^(th) 权重
为了更清楚地理解上述计算,考虑 图 6.5 中描绘的前馈网络。该网络有两个单一的权重 w[1] 和 w[2],并计算两个输出 h 和 y,如下面的图所示。为简化模型,我们假设没有非线性:

图 6.5:前馈网络的计算
我们可以使用链式法则如下计算
:

这简化为以下内容:

在这里,l 是数据点 x 的正确标签。此外,我们假设均方误差作为损失函数。这里的一切都是定义明确的,计算
也相当直接。
为什么我们不能直接对 RNN 使用 BP
现在,让我们对 图 6.6 中的 RNN 做同样的尝试。现在我们有了一个额外的递归权重 w[3]。为了清晰地突出我们要强调的问题,我们省略了输入和输出的时间成分:

图 6.6:RNN 的计算
让我们看看如果我们应用链式法则来计算
会发生什么:

这变为以下内容:

这里的项
会产生问题,因为它是一个递归项。你最终会得到无限多个导数项,因为 h 是递归的(也就是说,计算 h 包含了 h 本身),并且 h 不是常量,而是依赖于 w[3]。这通过随着时间展开输入序列 x 来解决,为每个输入 x[t] 创建一个 RNN 的副本,分别计算每个副本的导数,然后通过将这些更新求和来合并这些更新,进而计算权重更新。我们将在接下来讨论这个过程的细节。
反向传播通过时间——训练 RNN
计算 RNN 的反向传播技巧是考虑完整的输入序列,而不仅仅是单个输入。然后,如果我们在时间步 4 计算
,我们将得到以下结果:

这意味着我们需要计算所有时间步长(直到第四个时间步)的梯度和。换句话说,我们将首先展开序列,以便我们可以为每个时间步 j 计算
和
。这是通过创建四个 RNN 的副本来完成的。因此,要计算
,我们需要 t-j+1 个 RNN 副本。然后,我们将通过将所有前一个时间步的梯度相加,滚动这些副本为一个单一的 RNN,得到梯度,并使用梯度
来更新 RNN。
然而,随着时间步数的增加,这变得非常昂贵。为了更高效的计算,我们可以使用 截断反向传播通过时间(TBPTT)来优化递归模型,这是 BPTT 的一种近似方法。
截断 BPTT——高效训练 RNN
在 TBPTT 中,我们只计算固定数量 T 时间步的梯度(与 BPTT 中计算到序列开始不同)。更具体地说,在计算
时,对于时间步 t,我们只计算到 t-T 的导数(也就是说,我们不计算到序列的开始):

这比标准的 BPTT 在计算上更为高效。在标准 BPTT 中,对于每个时间步 t,我们需要计算从序列开始到当前时间步的导数。但随着序列长度越来越大,这变得计算上不可行(例如,当逐字处理一篇长文本时)。然而,在截断 BPTT 中,我们只计算固定步数的反向导数,正如你可以想象的那样,随着序列变长,计算成本不会发生变化。
BPTT 的局限性——消失梯度和爆炸梯度
尽管有了计算递归权重梯度的方法,并且拥有像 TBPTT 这样的计算高效近似方法,我们仍然无法毫无问题地训练 RNN。计算中可能还会出现其他问题。
为了理解为什么如此,让我们展开
中的一个单项,公式如下:

由于我们知道反向传播的问题来自于递归连接,因此让我们忽略 w[1]x 项,考虑以下内容:

通过简单地展开 h[3] 并进行简单的算术运算,我们可以证明这一点:

我们看到,对于仅四个时间步,我们有一个项
。因此,在 n^(th) 时间步,它将变成
。假设我们在 n=100 时间步将 w[3] 初始化为非常小的值(例如 0.00001);那么梯度将变得极其微小(约为 10^(-500) 的量级)。此外,由于计算机在表示数字时精度有限,这个更新将被忽略(即,算术下溢)。这被称为 消失梯度。
解决梯度消失问题并不是非常直接。没有简单的方法可以重新缩放梯度,以便它们能够正确地在时间上传播。实践中解决梯度消失问题的一些技术包括:仔细初始化权重(例如,Xavier 初始化),或使用基于动量的优化方法(即,除了当前的梯度更新外,我们还会添加一个额外的项,这是所有过去梯度的累积,称为速度项)。然而,解决梯度消失问题的更有原则的方法,例如对标准 RNN 的不同结构修改,已经被提出,正如我们将在第七章,理解长短期记忆网络中看到的那样。
另一方面,假设我们将 w[3] 初始化为非常大(比如 1000.00)。那么在 n=100 的时间步长下,梯度将变得巨大(规模为 10³⁰⁰)。
这会导致数值不稳定,您将在 Python 中得到类似 Inf 或 NaN(即非数字)的值。这被称为梯度爆炸。
梯度爆炸也可能由于问题损失面(loss surface)的复杂性而发生。由于输入的维度和模型中存在大量参数(权重),复杂的非凸损失面在深度神经网络中非常常见。
图 6.7 展示了 RNN 的损失面,并突出了具有非常高曲率的墙壁。如果优化方法接触到这样的墙壁,梯度将会爆炸或超调,如图中实线所示。这可能导致非常差的损失最小化、数值不稳定,或者两者都有。避免在这种情况下梯度爆炸的一个简单方法是将梯度裁剪到一个合理的小值,当其大于某个阈值时。图中的虚线显示了当我们在某个小值处裁剪梯度时会发生什么。(梯度裁剪在论文《训练循环神经网络的难题》中有介绍,Pascanu, Mikolov, and Bengio, 国际机器学习大会 (2013): 1310-1318。)

图 6.7:梯度爆炸现象。来源:此图来自 Pascanu、Mikolov 和 Bengio 的论文《训练循环神经网络的难题》。
在这里,我们结束了关于 BPTT 的讨论,BPTT 是为 RNN 适配的反向传播算法。接下来,我们将讨论 RNN 如何用于解决各种应用。这些应用包括句子分类、图像描述和机器翻译。我们将把 RNN 分类为不同的类别,如一对一、一对多、多对一和多对多。
RNN 的应用
到目前为止,我们只讨论了一对一映射的 RNN,其中当前输出依赖于当前输入以及先前观察到的输入历史。这意味着,对于先前观察到的输入序列和当前输入,存在一个输出。然而,在实际应用中,可能会出现只有一个输出对应于输入序列、一个输出对应于单一输入、以及一个输出序列对应于输入序列但序列大小不同的情况。在本节中,我们将讨论几种不同的 RNN 模型设置及其应用。
一对一 RNN
在一对一 RNN 中,当前输入依赖于先前观察到的输入(见 图 6.8)。这种 RNN 适用于每个输入都有输出的问题,但输出既依赖于当前输入,也依赖于导致当前输入的输入历史。一个这样的任务示例是股市预测,其中我们为当前输入输出一个值,而这个输出还依赖于之前输入的表现。另一个例子是场景分类,其中图像中的每个像素都有标签(例如,标签如车、道路和人)。有时,x[t+1] 可能与 y[t] 相同,这对于某些问题是成立的。例如,在文本生成问题中,之前预测的单词成为预测下一个单词的输入。下图展示了一对一 RNN:

图 6.8:具有时间依赖关系的一对一 RNN
一对多 RNN
一对多 RNN 将接受单一输入并输出一个序列(见 图 6.9)。在这里,我们假设输入之间是相互独立的。
也就是说,我们不需要关于先前输入的信息就能对当前输入进行预测。然而,循环连接是必要的,因为尽管我们处理的是单一输入,但输出是一个依赖于先前输出值的值序列。一个可以使用这种 RNN 的示例任务是图像字幕生成任务。例如,对于给定的输入图像,文本字幕可能由五个或十个单词组成。换句话说,RNN 会不断预测单词,直到输出一个描述图像的有意义短语。下图展示了一对多 RNN:

图 6.9:一对多 RNN
多对一 RNN
多对一 RNN 接受任意长度的输入,并为输入序列产生一个单一的输出(见 图 6.10)。句子分类就是一个可以从多对一 RNN 中受益的任务。句子被模型表示为任意长度的单词序列。模型将其作为输入,并产生一个输出,将句子分类为预定义类中的一种。以下是句子分类的一些具体示例:
-
将电影评论分类为正面或负面(即情感分析)
-
根据句子的描述对其进行分类(例如,人物、物体或位置)
多对一 RNN 的另一个应用是通过一次处理图像的一个补丁并将窗口在整个图像上移动,来对大规模图像进行分类。
以下图示展示了一个多对一 RNN:

图 6.10:一个多对一 RNN
多对多 RNN
多对多 RNN(或称序列到序列,简写为 seq2seq)通常会从任意长度的输入中生成任意长度的输出(见 图 6.11)。换句话说,输入和输出不需要是相同的长度。这在机器翻译中尤其有用,因为我们将一个语言的句子翻译成另一种语言。如你所想,一个语言中的一个句子不一定与另一个语言中的句子对齐。另一个例子是聊天机器人,其中聊天机器人读取一串单词(即用户请求),并输出一串单词(即回答)。以下图示展示了一个多对多 RNN:

图 6.11:一个多对多 RNN
我们可以总结前馈网络和 RNN 的不同应用类型如下:
| 算法 | 描述 | 应用 |
|---|---|---|
| 一对一 RNN | 这些网络接受单一输入并生成单一输出。当前输入依赖于之前观察到的输入。 | 股票市场预测,场景分类和文本生成 |
| 一对多 RNN | 这些网络接受单一输入,并生成一个包含任意数量元素的输出 | 图像描述 |
| 多对一 RNN | 这些网络接受一个输入序列,并生成单一输出。 | 句子分类(将单一单词视为单一输入) |
| 多对多 RNN | 这些网络接受任意长度的序列作为输入,并输出任意长度的序列。 | 机器翻译,聊天机器人 |
接下来,我们将学习如何使用 RNN 来识别文本语料库中提到的各种实体。
使用 RNN 进行命名实体识别
现在让我们看一下我们的第一个任务:使用 RNN 识别文本语料库中的命名实体。这个任务被称为 命名实体识别(NER)。我们将使用经过修改的著名 CoNLL 2003(即 计算自然语言学习会议 - 2003)数据集来进行命名实体识别。
CoNLL 2003 数据集支持多种语言,英文数据来源于路透社语料库,该语料库包含了 1996 年 8 月到 1997 年 8 月之间发布的新闻报道。我们将使用的数据库位于 github.com/ZihanWangKi/CrossWeigh,名为 CoNLLPP。与原始的 CoNLL 数据集相比,它是一个经过更加精细筛选的版本,避免了由于错误理解单词上下文而引起的数据集错误。例如,在短语 “Chicago won …” 中,Chicago 被识别为一个地点,而实际上它是一个组织。这个练习可以在 Ch06-Recurrent-Neural-Networks 文件夹下的 ch06_rnns_for_named_entity_recognition.ipynb 中找到。
理解数据
我们定义了一个名为 download_data() 的函数,可以用来下载数据。我们不会深入探讨它的细节,因为它只是下载几个文件并将它们放入一个数据文件夹。一旦下载完成,您将拥有三个文件:
-
data\conllpp_train.txt– 训练集,包含 14041 个句子 -
data\conllpp_dev.txt– 验证集,包含 3250 个句子 -
data\conllpp_test.txt– 测试集,包含 3452 个句子
接下来,我们将读取数据并将其转换为适合我们模型的特定格式。但在此之前,我们需要看看原始数据的样子:
-DOCSTART- -X- -X- O
EU NNP B-NP B-ORG
rejects VBZ B-VP O
German JJ B-NP B-MISC
call NN I-NP O
to TO B-VP O
boycott VB I-VP O
British JJ B-NP B-MISC
lamb NN I-NP O
. . O O
The DT B-NP O
European NNP I-NP B-ORG
Commission NNP I-NP I-ORG
said VBD B-VP O
...
to TO B-PP O
sheep NN B-NP O
. . O O
如您所见,文档中每行包含一个单词,并带有该单词的相关标签。这些标签的顺序如下:
-
词性标签(POS 标签)(例如,名词 -
NN,动词 -VB,限定词 -DT等) -
短语块标签 – 短语块是由一个或多个标记组成的文本段落(例如,
NP代表名词短语,如 “The European Commission”) -
命名实体标签(例如,位置、组织、人物等)
无论是短语块标签还是命名实体标签,都有 B- 和 I- 前缀(例如,B-ORG 或 I-ORG)。这些前缀用于区分实体/短语块的起始标记与后续标记。
数据集中还有五种类型的实体:
-
基于位置的实体(
LOC) -
基于人物的实体(
PER) -
基于组织的实体(
ORG) -
杂项实体(
MISC) -
非实体(
O)
最后,每个句子之间有一个空行。
现在让我们来看一下加载我们下载的数据到内存中的代码,这样我们就可以开始使用它了:
def read_data(filename):
'''
Read data from a file with given filename
Returns a list of sentences (each sentence a string),
and list of ner labels for each string
'''
print("Reading data ...")
# master lists - Holds sentences (list of tokens),
# ner_labels (for each token an NER label)
sentences, ner_labels = [], []
# Open the file
with open(filename,'r',encoding='latin-1') as f:
# Read each line
is_sos = True
# We record at each line if we are seeing the beginning of a
# sentence
# Tokens and labels of a single sentence, flushed when encountered
# a new one
sentence_tokens = []
sentence_labels = []
i = 0
for row in f:
# If we are seeing an empty line or -DOCSTART- that's a new line
if len(row.strip()) == 0 or row.split(' ')[0] == '-
DOCSTART-':
is_sos = False
# Otherwise keep capturing tokens and labels
else:
is_sos = True
token, _, _, ner_label = row.split(' ')
sentence_tokens.append(token)
sentence_labels.append(ner_label.strip())
# When we reach the end / or reach the beginning of next
# add the data to the master lists, flush the temporary one
if not is_sos and len(sentence_tokens)>0:
sentences.append(' '.join(sentence_tokens))
ner_labels.append(sentence_labels)
sentence_tokens, sentence_labels = [], []
print('\tDone')
return sentences, ner_labels
在这里,我们将存储所有句子(作为sentences中的字符串列表)和与每个标记相关的所有标签(作为ner_labels中的列表列表)。我们将逐行读取文件。我们会维护一个布尔值is_sos,用来表示我们是否在句子的开头。我们还会有两个临时列表(sentence_tokens和sentence_labels),用来累积当前句子的标记和 NER 标签。当我们处于句子的开始时,我们会重置这些临时列表。否则,我们会将每个在文件中看到的标记和 NER 标签写入这些临时列表。现在,我们可以在训练集、验证集和测试集上运行这个函数:
# Train data
train_sentences, train_labels = read_data(train_filepath)
# Validation data
valid_sentences, valid_labels = read_data(dev_filepath)
# Test data
test_sentences, test_labels = read_data(test_filepath)
我们将打印几个样本,看看我们得到了什么:
# Print some data
print('\nSample data\n')
for v_sent, v_labels in zip(valid_sentences[:5], valid_labels[:5]):
print("Sentence: {}".format(v_sent))
print("Labels: {}".format(v_labels))
print('\n')
这产生了:
Sentence: West Indian all-rounder Phil Simmons took four for 38 on Friday as Leicestershire beat Somerset by an innings and 39 runs in two days to take over at the head of the county championship .
Labels: ['B-MISC', 'I-MISC', 'O', 'B-PER', 'I-PER', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-ORG', 'O', 'B-ORG', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O']
Sentence: Their stay on top , though , may be short-lived as title rivals Essex , Derbyshire and Surrey all closed in on victory while Kent made up for lost time in their rain-affected match against Nottinghamshire .
Labels: ['O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-ORG', 'O', 'B-ORG', 'O', 'B-ORG', 'O', 'O', 'O', 'O', 'O', 'O', 'B-ORG', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-ORG', 'O']
Sentence: After bowling Somerset out for 83 on the opening morning at Grace Road , Leicestershire extended their first innings by 94 runs before being bowled out for 296 with England discard Andy Caddick taking three for 83 .
Labels: ['O', 'O', 'B-ORG', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-LOC', 'I-LOC', 'O', 'B-ORG', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-LOC', 'O', 'B-PER', 'I-PER', 'O', 'O', 'O', 'O', 'O']
NER 任务的一个独特特点是类别不平衡。也就是说,并非所有类别的样本数量大致相等。正如你可能猜到的,在语料库中,非命名实体的数量要多于命名实体。这导致标签之间出现显著的类别不平衡。因此,让我们来看看不同类别之间样本的分布:
from itertools import chain
# Print the value count for each label
print("Training data label counts")
print(pd.Series(chain(*train_labels)).value_counts())
为了分析数据,我们将首先把 NER 标签转换为 pandas 的Series对象。可以通过简单地在train_labels、valid_labels和test_labels上调用pd.Series()构造函数来完成。但请记住,这些是列表的列表,其中每个内部列表代表句子中所有标记的 NER 标签。为了创建一个扁平化的列表,我们可以使用内置的 Python 库itertools中的chain()函数。它会将多个列表连接在一起,形成一个单一的列表。之后,我们在这个 pandas Series上调用value_counts()函数。这将返回一个新列表,其中索引是原始Series中找到的唯一标签,而值是每个标签出现的次数。这样我们就得到了:
Training data label counts
O 169578
B-LOC 7140
B-PER 6600
B-ORG 6321
I-PER 4528
I-ORG 3704
B-MISC 3438
I-LOC 1157
I-MISC 1155
dtype: int64
正如你所看到的,O 标签的数量远远超过其他标签的数量。在训练模型时,我们需要记住这一点。接下来,我们将分析每个句子的序列长度(即标记的数量)。我们稍后需要这些信息来将句子填充到固定长度。
pd.Series(train_sentences).str.split().str.len().describe(percentiles=[0.05, 0.95])
在这里,我们创建一个 pandas Series,其中每个项目都是在将每个句子拆分为标记列表后,句子的长度。
然后,我们将查看这些长度的 5% 和 95% 分位数。这将产生:
count 14041.000000
mean 14.501887
std 11.602756
min 1.000000
5% 2.000000
50% 10.000000
95% 37.000000
max 113.000000
dtype: float64
我们可以看到,95%的句子长度为 37 个标记或更少。
处理数据
现在是时候处理数据了。我们将保持句子的原始格式,即一个字符串列表,每个字符串代表一个句子。因为我们将把文本处理直接集成到模型中(而不是在外部进行处理)。对于标签,我们需要做一些改变。记住,标签是一个列表的列表,其中每个内部列表表示每个句子中所有标记的标签。具体来说,我们将执行以下操作:
-
将类别标签转换为类别 ID
-
将标签序列填充至指定的最大长度
-
生成一个掩码,指示填充标签,以便我们可以在模型训练过程中忽略填充的标签
首先让我们编写一个函数来获取类标签到类 ID 的映射。这个函数利用 pandas 的unique()函数获取训练集中的唯一标签,并生成一个整数到唯一标签的映射。
def get_label_id_map(train_labels):
# Get the unique list of labels
unique_train_labels = pd.Series(chain(*train_labels)).unique()
# Create a class label -> class ID mapping
labels_map = dict(
zip(unique_train_labels,
np.arange(unique_train_labels.shape[0])))
print("labels_map: {}".format(labels_map))
return labels_map
如果你运行以下代码:
labels_map = get_label_id_map(train_labels)
然后你将得到:
labels_map: {'B-ORG': 0, 'O': 1, 'B-MISC': 2, 'B-PER': 3, 'I-PER': 4, 'B-LOC': 5, 'I-ORG': 6, 'I-MISC': 7, 'I-LOC': 8}
我们编写了一个名为get_padded_int_labels()的函数,该函数接受类标签的序列并返回填充后的类 ID 序列,并可选择返回一个表示填充标签的掩码。该函数接受以下参数:
-
labels(List[List[str]]) – 一个字符串列表的列表,其中每个字符串是类标签 -
labels_map(Dict[str, int]) – 一个字典,将字符串标签映射到整数类型的类 ID -
max_seq_length(int) – 要填充的最大长度(较长的序列将在此长度处被截断) -
return_mask(bool) – 是否返回显示填充标签的掩码
现在让我们来看一下执行上述操作的代码:
def get_padded_int_labels(labels, labels_map, max_seq_length,
return_mask=True):
# Convert string labels to integers
int_labels = [[labels_map[x] for x in one_seq] for one_seq in
labels]
# Pad sequences
if return_mask:
# If we return mask, we first pad with a special value (-1) and
# use that to create the mask and later replace -1 with 'O'
padded_labels = np.array(
tf.keras.preprocessing.sequence.pad_sequences(
int_labels, maxlen=max_seq_length, padding='post',
truncating='post', value=-1
)
)
# mask filter
mask_filter = (padded_labels != -1)
# replace -1 with 'O' s ID
padded_labels[~mask_filter] = labels_map['O']
return padded_labels, mask_filter.astype('int')
else:
padded_labels = np.array(ner_pad_sequence_func(int_labels,
value=labels_map['O']))
return padded_labels
你可以看到函数的第一步将labels中的所有字符串标签通过labels_map转换为整数标签。接下来,我们使用tf.keras.preprocessing.sequence.pad_sequences()函数获得填充后的序列。我们在上一章中详细讨论了这个函数。本质上,它将对任意长度的序列进行填充(使用指定的值)和截断,返回固定长度的序列。我们指示该函数在序列的末尾进行填充和截断,填充值为特殊值-1。然后我们可以简单地生成一个布尔值掩码,其中padded_labels不等于-1。因此,原始标签所在的位置将标记为1,其余位置为0。但是,我们必须将-1的值转换为labels_map中找到的类 ID。我们将其分配给标签O(即其他)。
根据我们在上一章中的发现,我们将最大序列长度设置为40。记住,95%的分位数落在 37 个词的长度上:
max_seq_length = 40
现在我们将为所有训练、验证和测试数据生成处理后的标签和掩码:
# Convert string labels to integers for all train/validation/test data
# Pad train/validation/test data
padded_train_labels, train_mask = get_padded_int_labels(
train_labels, labels_map, max_seq_length, return_mask=True
)
padded_valid_labels, valid_mask = get_padded_int_labels(
valid_labels, labels_map, max_seq_length, return_mask=True
)
padded_test_labels, test_mask = get_padded_int_labels(
test_labels, labels_map, max_seq_length, return_mask=True
)
最后,我们将打印前两个序列的处理后的标签和掩码:
# Print some labels IDs
print(padded_train_labels[:2])
print(train_mask[:2])
它返回:
[[0 1 2 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1]
[3 4 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1]]
[[1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0]
[1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0]]
你可以看到掩码清楚地指示了真实标签和填充标签。接下来,我们将定义模型的一些超参数。
定义超参数
现在,让我们定义我们 RNN 所需的几个超参数,如下所示:
-
max_seq_length– 表示序列的最大长度。我们在数据探索过程中从训练数据中推断出这一点。为序列设置合理的长度非常重要,否则,由于 RNN 的展开,内存可能会爆炸。 -
emedding_size– 词向量的维度。由于我们拥有一个小型语料库,值小于 100 即可。 -
rnn_hidden_size– RNN 中隐藏层的维度。增加隐藏层的维度通常能提高性能。然而,请注意,增加隐藏层的大小会导致所有三组内部权重(即U、W和V)的增加,从而导致较高的计算负担。 -
n_classes– 唯一输出类的数量。 -
batch_size– 训练数据、验证数据和测试数据的批量大小。较高的批量大小通常会带来更好的结果,因为在每次优化步骤中,我们会看到更多的数据,但就像展开一样,这也会导致更高的内存需求。 -
epochs– 训练模型的轮数。
以下是定义的内容:
# The maximum length of sequences
max_seq_length = 40
# Size of token embeddings
embedding_size = 64
# Number of hidden units in the RNN layer
rnn_hidden_size = 64
# Number of output nodes in the last layer
n_classes = 9
# Number of samples in a batch
batch_size = 64
# Number of epochs to train
epochs = 3
现在我们将定义模型。
定义模型
我们将在这里定义模型。我们的模型将包含一个嵌入层,接着是一个简单的 RNN 层,最后是一个密集预测层。需要注意的是,在我们迄今为止的工作中,与前几章不同,我们尚未定义Tokenizer对象。虽然Tokenizer是我们自然语言处理(NLP)管道中的重要部分,用来将每个 token(或单词)转换为 ID,但使用外部分词器有一个大缺点。训练模型后,如果你忘记将分词器与模型一起保存,那么你的机器学习模型就会变得毫无用处:为了应对这一点,在推理时,你需要将每个单词映射到它在训练期间所对应的 ID。
这是分词器所带来的重大风险。在本章中,我们将寻求一种替代方法,在模型中集成分词机制,这样我们以后就不需要再担心这个问题了。图 6.12展示了模型的整体架构:

图 6.12:模型的整体架构。文本向量化层将文本分词并转换为词 ID。接下来,每个 token 作为 RNN 的每个时间步的输入。最后,RNN 在每个时间步预测每个 token 的标签。
文本向量化层介绍
TextVectorization层可以看作是一个现代化的分词器,可以插入到模型中。在这里,我们将仅操作TextVectorization层,而不涉及模型其他部分的复杂性。首先,我们将导入TextVectorization层:
from tensorflow.keras.layers.experimental.preprocessing import TextVectorization
现在我们将定义一个简单的文本语料库:
toy_corpus = ["I went to the market on Sunday", "The Market was empty."]
我们可以按如下方式实例化文本向量化层:
toy_vectorization_layer = TextVectorization()
实例化后,您需要在一些数据上拟合该层。这样,像我们之前使用的分词器一样,它可以学习单词到数字 ID 的映射。为此,我们通过传递文本语料库作为输入,调用该层的adapt()方法:
# Fit it on a corpus of data
toy_vectorization_layer.adapt(toy_corpus)
我们可以按如下方式生成分词输出:
toy_vectorized_output = toy_vectorization_layer(toy_corpus)
它将包含:
[[ 9 4 6 2 3 8 7]
[ 2 3 5 10 0 0 0]]
我们还可以查看该层所学到的词汇:
Vocabulary: ['', '[UNK]', 'the', 'market', 'went', 'was', 'to', 'sunday', 'on', 'i', 'empty']
我们可以看到该层已经完成了一些预处理(例如将单词转换为小写并去除了标点符号)。接下来让我们看看如何限制词汇表的大小。我们可以通过max_tokens参数来实现:
toy_vectorization_layer = TextVectorization(max_tokens=5)
toy_vectorization_layer.adapt(toy_corpus)
toy_vectorized_output = toy_vectorization_layer(toy_corpus)
如果你将toy_corpus转换为单词 ID,你将看到:
[[1 4 1 2 3 1 1]
[2 3 1 1 0 0 0]]
词汇表将如下所示:
Vocabulary: ['', '[UNK]', 'the', 'market', 'went']
现在我们可以看到,词汇表中只有五个元素,就像我们指定的那样。现在,如果你需要跳过层内部发生的文本预处理,你可以通过将层中的standardize参数设置为None来实现:
toy_vectorization_layer = TextVectorization(standardize=None)
toy_vectorization_layer.adapt(toy_corpus)
toy_vectorized_output = toy_vectorization_layer(toy_corpus)
这将产生:
[[12 2 4 5 7 6 10]
[ 9 11 3 8 0 0 0]]
词汇表将如下所示:
Vocabulary: ['', '[UNK]', 'went', 'was', 'to', 'the', 'on', 'market', 'empty.', 'The', 'Sunday', 'Market', 'I']
最后,我们还可以通过output_sequence_length命令控制序列的填充/截断。例如,以下命令将在长度为4的位置进行填充/截断:
toy_vectorization_layer = TextVectorization(output_sequence_length=4)
toy_vectorization_layer.adapt(toy_corpus)
toy_vectorized_output = toy_vectorization_layer(toy_corpus)
这将产生:
[[ 9 4 6 2]
[ 2 3 5 10]]
这里的词汇表是:
Vocabulary: ['', '[UNK]', 'the', 'market', 'went', 'was', 'to', 'sunday', 'on', 'i', 'empty']
现在你已经很好地理解了TextVectorization层中的参数及其作用。接下来让我们讨论模型。
定义模型的其余部分
首先,我们将导入必要的模块:
import tensorflow.keras.layers as layers
import tensorflow.keras.backend as K
from tensorflow.keras.layers.experimental.preprocessing import TextVectorization
我们将定义一个输入层,该层有一个单列(即每个句子表示为一个单元),并且dtype=tf.string:
# Input layer
word_input = tf.keras.layers.Input(shape=(1,), dtype=tf.string)
接下来,我们将定义一个函数,该函数接收一个语料库、最大序列长度和词汇表大小,并返回训练好的TextVectorization层和词汇表大小:
def get_fitted_token_vectorization_layer(corpus, max_seq_length, vocabulary_size=None):
""" Fit a TextVectorization layer on given data """
# Define a text vectorization layer
vectorization_layer = TextVectorization(
max_tokens=vocabulary_size, standardize=None,
output_sequence_length=max_seq_length,
)
# Fit it on a corpus of data
vectorization_layer.adapt(corpus)
# Get the vocabulary size
n_vocab = len(vectorization_layer.get_vocabulary())
return vectorization_layer, n_vocab
这个函数做的就是我们已经描述过的内容。然而,注意我们为向量化层设置的各种参数。我们将词汇表大小作为max_tokens传递;我们将standardize设置为None。这是一个重要的设置。在进行命名实体识别(NER)时,保持字符的大小写非常重要。通常,一个实体以大写字母开头(例如人的名字或组织名称)。因此,我们应该保留文本中的大小写。
最后,我们还将output_sequence_length设置为我们在分析过程中找到的序列长度。这样,我们就可以按如下方式创建文本向量化层:
# Text vectorization layer
vectorize_layer, n_vocab = get_fitted_token_vectorization_layer(train_sentences, max_seq_length)
然后将word_input传递给vectorize_layer并获取输出:
# Vectorized output (each word mapped to an int ID)
vectorized_out = vectorize_layer(word_input)
来自vectorize_layer的输出(即vectorized_out)将传递到一个嵌入层。这个嵌入层是一个随机初始化的嵌入层,输出的维度为embedding_size:
# Look up embeddings for the returned IDs
embedding_layer = layers.Embedding(
input_dim=n_vocab,
output_dim=embedding_size,
mask_zero=True
)(vectorized_out)
到目前为止,我们处理的是前馈网络。前馈网络的输出没有时间维度。但是,如果你查看TextVectorization层的输出,它将是一个[batch size, sequence length]形状的输出。当这个输出经过嵌入层时,输出将是一个[batch size, sequence length, embedding size]形状的张量。换句话说,嵌入层的输出中包含了一个额外的时间维度。
另一个区别是引入了mask_true参数。遮蔽(masking)用于掩盖添加到序列中的无效词(例如,为了使句子长度固定而添加的填充符号),因为它们对最终结果没有贡献。遮蔽是序列学习中常用的技术。要了解更多关于遮蔽的内容,请阅读下方的信息框。
序列学习中的遮蔽
自然,文本的长度是任意的。例如,语料库中的句子可能有不同的标记长度。而深度网络处理的是固定维度的张量。为了将任意长度的句子转换为常数长度,我们会用一些特殊的值(例如 0)对这些序列进行填充。然而,这些填充值是人工的,只是为了确保正确的输入形状。它们不应该对最终的损失或评估指标产生影响。为了在损失计算和评估时忽略它们,使用了“遮蔽”技术。其原理是将来自填充时间步长的损失乘以零,实质上将其从最终损失中切断。
在训练模型时手动执行遮蔽操作会非常繁琐。但在 TensorFlow 中,大多数层都支持遮蔽。例如,在嵌入层中,为了忽略填充的值(通常是零),你只需要设置mask_true=True。
当你在某个层中启用遮蔽时,它会将遮蔽传播到下游层,直到损失计算为止。换句话说,你只需要在模型开始时启用遮蔽(就像我们在嵌入层中所做的那样),剩下的部分由 TensorFlow 自动处理。
接下来,我们将定义模型的核心层——RNN:
# Define a simple RNN layer, it returns an output at each position
rnn_layer = layers.SimpleRNN(
units=rnn_hidden_size, return_sequences=True
)
rnn_out = rnn_layer(embedding_layer)
你可以通过简单地调用tf.keras.layers.SimpleRNN来实现一个基础的 RNN。在这里,我们传递了两个重要的参数。除了这两个参数,还有其他有用的参数,但它们将在后续章节中与更复杂的 RNN 变体一起讲解:
-
units(int) – 这定义了 RNN 模型的隐藏输出大小。这个值越大,模型的表示能力就越强。 -
return_sequences(bool) – 是否返回所有时间步的输出,还是仅返回最后一个输出。对于命名实体识别(NER)任务,我们需要标注每个单独的标记。因此,我们需要返回所有时间步的输出。
rnn_layer 接受一个形状为 [batch size, sequence length, embedding size] 的张量,并返回一个形状为 [batch size, sequence length, rnn hidden size] 的张量。最后,来自 RNN 的时间分布输出将传递给一个具有 n_classes 输出节点和 softmax 激活函数的全连接层:
dense_layer = layers.Dense(n_classes, activation='softmax')
dense_out = dense_layer(rnn_out)
最后,我们可以按如下方式定义最终模型。它接收一批字符串句子作为输入,并返回一批标签序列作为输出:
model = tf.keras.Model(inputs=word_input, outputs=dense_out)
我们现在已经完成了模型的构建。接下来,我们将讨论损失函数和评估指标。
评估指标和损失函数
在我们之前的讨论中,我们提到过命名实体识别(NER)任务通常存在较大的类别不平衡问题。文本中非实体相关的标记通常比实体相关的标记更多。这导致出现大量的其他(O)类型标签,而其他类型的标签较少。在训练模型和评估模型时,我们需要考虑这一点。我们将通过两种方式来解决类别不平衡问题:
-
我们将创建一个新的评估指标,能够抵抗类别不平衡
-
我们将使用样本权重来惩罚频繁出现的类别,并提升稀有类别的重要性
在本节中,我们仅解决前者问题。后者将在下一节中讨论。我们将定义一个修改版的准确率。这被称为宏观平均准确率。在宏观平均中,我们分别计算每个类别的准确率,然后求平均。因此,在计算准确率时,类别不平衡问题被忽略。当计算标准指标(如准确率、精确率或召回率)时,有多种不同的平均方式可供选择。欲了解更多信息,请参阅下方的信息框。
不同类型的指标平均方式
指标有多种可用的平均方式。你可以在 scikit-learn 文档中阅读其中一种平均方式,详细信息请见 scikit-learn.org/stable/modules/generated/sklearn.metrics.average_precision_score.html。考虑一个简单的二分类示例,混淆矩阵的结果如下:

图 6.13:示例混淆矩阵结果
-
微观 – 计算全局指标,忽略类别分布的差异。例如 35/65 = ~54%
-
宏观 – 分别计算每个类别的指标并求平均。例如 (35/40 + 0/25)/2 = ~43.7%
-
加权 – 分别计算每个类别的指标并按支持度加权(即每个类别的真实标签数量)。例如 (35/40)* 40 + (0/25) * 25 / 65 = ~54%
在这里,你可以看到微观和加权返回相同的结果。这是因为准确率计算的分母与支持度相同。因此,在加权平均时它们会相互抵消。然而,对于精确率和召回率等其他指标,你将获得不同的值。
在下文中,我们定义了一个函数来计算宏观准确率,输入为一批真实目标(y_true)和预测值(y_pred)。y_true的形状为[batch_size, sequence length],y_pred的形状为[batch size, sequence length, n_classes]:
def macro_accuracy(y_true, y_pred):
# [batch size, time] => [batch size * time]
y_true = tf.cast(tf.reshape(y_true, [-1]), 'int32')
# [batch size, sequence length, n_classes] => [batch size * time]
y_pred = tf.cast(tf.reshape(tf.argmax(y_pred, axis=-1), [-1]),
'int32')
sorted_y_true = tf.sort(y_true)
sorted_inds = tf.argsort(y_true)
sorted_y_pred = tf.gather(y_pred, sorted_inds)
sorted_correct = tf.cast(tf.math.equal(sorted_y_true,
sorted_y_pred), 'int32')
# We are adding one to make sure there are no division by zero
correct_for_each_label =
tf.cast(tf.math.segment_sum(sorted_correct, sorted_y_true),
'float32') + 1
all_for_each_label =
tf.cast(tf.math.segment_sum(tf.ones_like(sorted_y_true),
sorted_y_true), 'float32') + 1
mean_accuracy =
tf.reduce_mean(correct_for_each_label/all_for_each_label)
return mean_accuracy
需要注意的是,我们必须使用 TensorFlow 操作来编写此函数,以确保它们作为图执行。尽管 TensorFlow 2 已转向更具命令式风格的执行操作,但 TensorFlow 1 中引入的声明式风格仍然有所残留。
首先我们将y_true展平,使其成为一个向量。接着,我们使用tf.argmax()函数从y_pred中获取预测标签,并将预测标签展平为一个向量。这两个展平后的结构将具有相同的元素数量。然后,我们对y_true进行排序,使得相同标签的元素紧密排列在一起。
我们在排序后的原始数据中取索引,然后使用tf.gather()函数将y_pred按与y_true相同的顺序排列。换句话说,sorted_y_true和sorted_y_pred之间仍然保持相同的对应关系。tf.gather()函数接收一个张量和一组索引,并根据这些索引对传入的张量进行排序。关于tf.gather()的更多信息,请参考www.tensorflow.org/api_docs/python/tf/gather。
然后我们计算sorted_correct,这是一个简单的指示函数,当sorted_y_true和sorted_y_pred中的对应元素相同时,它会启动,如果不同则保持关闭。接着我们使用tf.math.segment_sum()函数来计算正确预测样本的分段和。每个类别的样本被视为一个单独的段(correct_for_each_label)。segment_sum()函数有两个参数:data和segment_ids。例如,如果data是[0, 1, 2, 3, 4, 5, 6, 7],segment_ids是[0, 0, 0, 1, 1, 2, 3, 3],则分段和为[0+1+2, 3+4, 5, 6+7] = [3, 7, 5, 13]。
然后我们对一个由 1 组成的向量做同样的操作。在这种情况下,我们得到了每个类别在数据批次中存在的真实样本数量(all_for_each_label)。请注意,我们在末尾添加了一个 1。这是为了避免在下一步中出现除以 0 的情况。最后,我们将correct_for_each_label除以all_for_each_label,得到一个包含每个类别准确率的向量。然后我们计算平均准确率,即宏平均准确率。
最后,我们将这个函数封装在一个MeanMetricWrapper中,这将产生一个tf.keras.metrics.Metric对象,我们可以将其传递给model.compile()函数:
mean_accuracy_metric = tf.keras.metrics.MeanMetricWrapper(fn=macro_accuracy, name='macro_accuracy')
通过调用以下方式来编译模型:
model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics=[mean_accuracy_metric])
接下来,我们将使用准备好的数据训练模型。
在 NER 任务上训练和评估 RNN
让我们在准备好的数据上训练模型。但首先,我们需要定义一个函数来处理数据集中的类别不平衡问题。我们将把样本权重传递给model.fit()函数。为了计算样本权重,我们首先定义一个名为get_class_weights()的函数,用来计算每个类别的class_weights。接下来,我们将把类别权重传递给另一个函数get_sample_weights_from_class_weights(),该函数将生成样本权重:
def get_class_weights(train_labels):
label_count_ser = pd.Series(chain(*train_labels)).value_counts()
label_count_ser = label_count_ser.min()/label_count_ser
label_id_map = get_label_id_map(train_labels)
label_count_ser.index = label_count_ser.index.map(label_id_map)
return label_count_ser.to_dict()
第一个函数get_class_weights()接受train_labels(一个包含类别 ID 列表的列表)。然后我们使用train_labels创建一个 pandas 的Series对象。注意,我们使用了内置的itertools库中的chain函数,它会将train_labels展平为类别 ID 的列表。这个Series对象包含了在训练数据集中每个类别标签的频次。接下来,为了计算权重,我们将最小频次按元素逐一从其他频次中进行除法运算。换句话说,如果类别标签
的频率用
表示,总标签集用
表示,则类别
的权重计算公式为:

最后,输出被转换为一个字典,其中类别 ID 作为键,类别权重作为值。接下来,我们需要将class_weights转换为sample_weights。我们只需对每个标签执行字典查找操作,按元素逐一生成样本权重,基于class_weights。sample_weights的形状将与train_labels相同,因为每个样本都有一个权重:
def get_sample_weights_from_class_weights(labels, class_weights):
""" From the class weights generate sample weights """
return np.vectorize(class_weights.get)(labels)
我们可以使用 NumPy 的np.vectorize()函数来实现这一点。np.vectorize()接受一个函数(例如,class_weights.get()是 Python 提供的键查找函数),并将其应用于所有元素,从而得到样本权重。调用我们之前定义的函数来生成实际的权重:
train_class_weights = get_class_weights(train_labels)
print("Class weights: {}".format(train_class_weights))
# Get sample weights (we cannot use class_weight with TextVectorization
# layer)
train_sample_weights = get_sample_weights_from_class_weights(padded_train_labels, train_class_weights)
在我们拥有了样本权重后,我们可以训练模型。你可以通过打印class_weights来查看它们。这将给出:
labels_map: {
'B-ORG': 0,
'O': 1,
'B-MISC': 2,
'B-PER': 3,
'I-PER': 4,
'B-LOC': 5,
'I-ORG': 6,
'I-MISC': 7,
'I-LOC': 8
}
Class weights: {
1: 0.006811025015037328,
5: 0.16176470588235295,
3: 0.17500000000000002,
0: 0.18272425249169436,
4: 0.25507950530035334,
6: 0.31182505399568033,
2: 0.33595113438045376,
8: 0.9982713915298186,
7: 1.0
}
你可以看到类别Other的权重最低(因为它是最频繁的类别),而类别I-MISC的权重最高,因为它是最不频繁的类别。现在我们将使用准备好的数据训练我们的模型:
# Make train_sequences an array
train_sentences = np.array(train_sentences)
# Training the model
model.fit(
train_sentences, padded_train_labels,
sample_weight=train_sample_weights,
batch_size=batch_size,
epochs=epochs,
validation_data=(np.array(valid_sentences),
padded_valid_labels)
)
你应该能得到大约 78-79%的准确率,没有进行任何特殊的性能优化技巧。接下来,你可以使用以下命令在测试数据上评估模型:
model.evaluate(np.array(test_sentences), padded_test_labels)
这将给出大约 77%的测试准确率。由于验证准确率和测试准确率相当,我们可以说模型的泛化表现良好。但为了确保这一点,让我们视觉检查一下测试集中的一些样本。
可视化分析输出
为了分析输出,我们将使用测试集中的前五个句子:
n_samples = 5
visual_test_sentences = test_sentences[:n_samples]
visual_test_labels = padded_test_labels[:n_samples]
接下来使用模型进行预测,并将这些预测转换为预测的类别 ID:
visual_test_predictions = model.predict(np.array(visual_test_sentences))
visual_test_pred_labels = np.argmax(visual_test_predictions, axis=-1)
我们将创建一个反转的labels_map,它将标签 ID 映射到标签字符串:
rev_labels_map = dict(zip(labels_map.values(), labels_map.keys()))
最后,我们将打印出结果:
for i, (sentence, sent_labels, sent_preds) in enumerate(zip(visual_test_sentences, visual_test_labels, visual_test_pred_labels)):
n_tokens = len(sentence.split())
print("Sample:\t","\t".join(sentence.split()))
print("True:\t","\t".join([rev_labels_map[i] for i in
sent_labels[:n_tokens]]))
print("Pred:\t","\t".join([rev_labels_map[i] for i in
sent_preds[:n_tokens]]))
print("\n")
这将打印出:
Sample: SOCCER - JAPAN GET LUCKY WIN , CHINA IN SURPRISE DEFEAT .
True: O O B-LOC O O O O B-LOC O O O O
Pred: O O B-MISC O O O O B-PER O B-LOC O O
Sample: Nadim Ladki
True: B-PER I-PER
Pred: B-LOC O
Sample: AL-AIN , United Arab Emirates 1996-12-06
True: B-LOC O B-LOC I-LOC I-LOC O
Pred: B-LOC O B-LOC I-LOC I-LOC I-ORG
Sample: Japan began the defence of their Asian Cup title with a lucky 2-1 win against Syria in a Group C championship match on Friday .
True: B-LOC O O O O O B-MISC I-MISC O O O O O O O B-LOC O O O O O O O O O
Pred: B-LOC I-LOC O O O O B-MISC I-MISC I-MISC O O O O O O B-LOC O O O O O O O O O
可以看到我们的模型表现不错。它擅长识别位置,但在识别人物名称上存在困难。在这里,我们结束了关于执行命名实体识别(NER)的基本 RNN 解决方案的讨论。在接下来的部分,我们将使模型更加复杂,赋予它通过提供更细粒度的细节来更好理解文本的能力。让我们了解一下如何改进我们的模型。
使用字符和标记嵌入进行命名实体识别(NER)
目前,用于解决命名实体识别(NER)任务的递归模型比仅使用单一嵌入层和 RNN 模型要复杂得多。它们涉及使用更高级的递归模型,如长短期记忆(LSTM)、门控递归单元(GRU)等。我们将在接下来的几章中暂时不讨论这些高级模型。这里,我们将重点讨论一种能够提供多尺度模型嵌入的技术,从而使其更好地理解语言。也就是说,除了依赖标记嵌入外,还要使用字符嵌入。然后,通过在标记的字符上滑动卷积窗口,利用字符嵌入生成标记嵌入。如果你现在还不理解细节,别担心,接下来的章节将详细介绍解决方案。这个练习可以在Ch06-Recurrent-Neural-Networks文件夹中的ch06_rnns_for_named_entity_recognition.ipynb找到。
使用卷积生成标记嵌入
组合字符嵌入和卷积核可以用来生成标记嵌入(图 6.14)。该方法如下:
-
将每个标记(例如单词)填充到预定的长度
-
查找标记中字符的字符嵌入,来自嵌入层
-
将卷积核滑过字符嵌入序列,生成标记嵌入

图 6.14:如何使用字符嵌入和卷积操作生成标记嵌入
我们需要做的第一件事是分析语料库中每个标记的字符统计信息。类似于之前的方法,我们可以使用 pandas 来完成:
vocab_ser = pd.Series(
pd.Series(train_sentences).str.split().explode().unique()
)
vocab_ser.str.len().describe(percentiles=[0.05, 0.95])
在计算vocab_ser时,第一部分(即pd.Series(train_sentences).str.split())将产生一个 pandas Series,其元素是标记列表(句子中的每个标记都是该列表的一个元素)。接下来,explode()将把包含标记列表的Series转换成单独的标记Series,即将每个标记转换为Series中的一个独立元素。最后,我们只取该Series中的唯一标记。最终我们会得到一个 pandas Series,其中每一项是一个唯一的标记。
我们现在将使用str.len()函数获取每个标记的长度(即字符数),并查看其中的 95%分位数。我们将得到以下结果:
count 23623.000000
mean 6.832705
std 2.749288
min 1.000000
5% 3.000000
50% 7.000000
95% 12.000000
max 61.000000
dtype: float64
我们可以看到大约 95%的单词字符数小于或等于 12 个。接下来,我们将编写一个函数来填充较短的标记:
def prepare_corpus_for_char_embeddings(tokenized_sentences, max_seq_length):
""" Pads each sequence to a maximum length """
proc_sentences = []
for tokens in tokenized_sentences:
if len(tokens) >= max_seq_length:
proc_sentences.append([[t] for t in
tokens[:max_seq_length]])
else:
proc_sentences.append([[t] for t in
tokens+['']*(max_seq_length-len(tokens))])
return proc_sentences
该函数接受一组标记化的句子(即每个句子作为一个标记列表,而不是字符串)和一个最大序列长度。请注意,这是我们之前使用的最大序列长度,而不是我们讨论过的新标记长度。该函数将执行以下操作:
-
对于较长的句子,只返回
max_seq_length个标记 -
对于较短的句子,追加
‘’作为标记,直到达到max_seq_length
让我们在一个小型的玩具数据集上运行这个函数:
# Define sample data
data = ['aaaa bb c', 'd eee']
# Pad sequences
tokenized_sentences = prepare_corpus_for_char_embeddings([d.split() for d in data], 3)
这将返回:
Padded sequence: [[['aaaa'], ['bb'], ['c']], [['d'], ['eee'], ['']]]
现在我们将定义一个新的TextVectorization层来应对我们对数据所做的变化。新的TextVectorization层必须在字符级进行标记化,而不是在标记级进行。为此,我们需要做一些更改。我们将再次编写一个函数来包含这个向量化层:
def get_fitted_char_vectorization_layer(corpus, max_seq_length, max_token_length, vocabulary_size=None):
""" Fit a TextVectorization layer on given data """
def _split_char(token):
return tf.strings.bytes_split(token)
# Define a text vectorization layer
vectorization_layer = TextVectorization(
standardize=None,
split=_split_char,
output_sequence_length=max_token_length,
)
tokenized_sentences = [sent.split() for sent in corpus]
padded_tokenized_sentences =
prepare_corpus_for_char_embeddings(tokenized_sentences,
max_seq_length)
# Fit it on a corpus of data
vectorization_layer.adapt(padded_tokenized_sentences)
# Get the vocabulary size
n_vocab = len(vectorization_layer.get_vocabulary())
return vectorization_layer, n_vocab
我们首先定义一个名为_split_char()的函数,它接收一个标记(作为tf.Tensor)并返回一个字符标记化的张量。例如,_split_char(tf.constant(['abcd']))将返回<tf.RaggedTensor [[b'a', b'b', b'c', b'd']]>。然后,我们定义一个TextVectorization层,使用这个新定义的函数作为分割数据的方式。我们还会将output_sequence_length定义为max_token_length。接着,我们创建tokenized_sentences,这是一个包含字符串列表的列表,并使用之前定义的prepare_corpus_for_char_embeddings()函数对其进行填充。最后,我们使用TextVectorization层的adapt()函数来调整其适配我们准备的数据。之前基于标记的文本向量化器和这个基于字符的文本向量化器之间的两个关键区别在于输入维度和最终输出维度:
-
基于标记的向量化器 – 接收一个
[batch size, 1]大小的输入并生成一个[batch size, sequence length]大小的输出 -
基于字符的向量化器 – 接收一个
[batch size, sequence length, 1]大小的输入并生成一个[batch size, sequence length, token length]大小的输出
现在我们已经具备了实现新改进的 NER 分类器所需的所有要素。
实现新的 NER 模型
在对模型有了良好的概念理解后,让我们实现新的 NER 模型。我们将首先定义一些超参数,接着像之前一样定义文本向量化器。然而,在这一部分中,我们的TextVectorization将变得更为复杂,因为我们将进行多层次的标记化(例如,字符级和标记级)。最后,我们定义一个基于 RNN 的模型来生成输出。
定义超参数
首先,我们将定义如下两个超参数:
max_seq_length = 40
max_token_length = 12
定义输入层
接着,我们定义一个与之前相同的数据类型为tf.strings的输入层:
# Input layer (tokens)
word_input = tf.keras.layers.Input(shape=(1,), dtype=tf.string)
该层的输入将是一批句子,其中每个句子都是一个字符串。
定义基于标记的 TextVectorization 层
然后,我们像上面一样定义标记级别的TextVectorization层:
# Text vectorize layer (token)
token_vectorize_layer, n_token_vocab = get_fitted_token_vectorization_layer(train_sentences, max_seq_length)
# Vectorized output (each word mapped to an int ID)
token_vectorized_out = token_vectorize_layer(word_input)
定义基于字符的 TextVectorization 层
对于字符级别的向量化层,我们将使用上面定义的get_fitted_char_vectorization_layer()函数:
# Text vectorize layer (char)
char_vectorize_layer, n_char_vocab = get_fitted_char_vectorization_layer(train_sentences, max_seq_length, max_token_length)
接下来,我们将讨论该层的输入。
处理 char_vectorize_layer 的输入
我们将对这个新的向量化层使用相同的word_input。然而,使用相同的输入意味着我们需要引入一些中间预处理步骤,以将输入转换为适合此层的正确格式。请记住,传入此层的输入需要是一个形状为[batch size, sequence length, 1]的张量。
这意味着句子需要被标记化为一系列令牌。为此,我们将使用tf.keras.layers.Lambda()层和tf.strings.split()函数:
tokenized_word_input = layers.Lambda(
lambda x: tf.strings.split(x).to_tensor(default_value='',
shape=[None, max_seq_length, 1])
)(word_input)
char_vectorized_out = char_vectorize_layer(tokenized_word_input)
Lambda层用于从自定义的 TensorFlow/Keras 函数创建一个层,这个函数可能在 Keras 中没有作为标准层提供。在这里,我们使用Lambda层来定义一个层,将传入的输入标记化为一系列令牌。此外,tf.strings.split()函数返回一个稀疏张量。在典型的张量中,所有维度需要具有固定大小。而稀疏张量是一种特殊的张量,其维度不是固定的。例如,由于句子列表不太可能有相同数量的令牌,因此这会导致一个稀疏张量。但是,TensorFlow 会抱怨,如果你尝试继续使用tf.RaggedTensor,因为大多数层不支持这些张量。因此,我们需要使用to_tensor()函数将其转换为标准张量。我们可以向该函数传递一个形状,它会确保结果张量的形状为定义的形状(通过填充和截断)。
需要特别注意的一点是每个层如何转换输入输出张量的形状。例如,我们一开始使用的是一个形状为[batch size, 1]的张量,进入Lambda层后转变为形状为[batch size, sequence length, 1]的层。最后,char_vectorize_layer将其转换为形状为[batch size, sequence length, token length]的张量。
然后我们将定义一个嵌入层,通过它我们可以查找来自char_vectorize_layer的字符 ID 对应的嵌入向量:
# Produces a [batch size, seq length, token_length, emb size]
char_embedding_layer = layers.Embedding(input_dim=n_char_vocab, output_dim=32, mask_zero=True)(char_vectorized_out)
这个层生成一个形状为[batch size, sequence length, token length, 32]的张量,每个字符在张量中都有一个字符嵌入向量。现在是时候对这个输出进行卷积操作了。
对字符嵌入进行卷积操作
我们将定义一个 1D 卷积层,卷积核大小为 5(即卷积窗口大小),步幅为 1,'same'填充,并使用 ReLU 激活函数。然后我们将前一部分的输出传递给这个层:
# A 1D convolutional layer that will generate token embeddings by shifting # a convolutional kernel over the sequence of chars in each token (padded)
char_token_output = layers.Conv1D(filters=1, kernel_size=5, strides=1, padding='same', activation='relu')(char_embedding_layer)
这个层通常接受一个大小为[批次大小, 宽度, 输入通道]的张量。然而,在我们的案例中,我们有一个四维输入。这意味着,我们的 Conv1D 层将以时间分布的方式进行运算。换句话说,它将处理一个具有时间维度(即序列长度维度)的输入,并生成一个保持该维度不变的输出。换句话说,它会接受形状为[批次大小, 序列长度, 标记长度, 32 (输入通道)]的输入,并生成一个形状为[批次大小, 序列长度, 标记长度, 1 (输出通道)]的输出。你可以看到,卷积只在最后两个维度上进行运算,而保持前两个维度不变。
另一种思考方式是,忽略批次和序列维度,直观地理解卷积如何在宽度和输入通道维度上进行运算。然后,将相同的操作逐元素应用到其他维度,同时将二维的[宽度, 输入通道]张量视为一个单独的计算单元。
记住,我们有一个大小为[批次大小, 序列长度, 标记长度, 1]的输出。它在最后有一个额外的维度 1。我们将写一个简单的Lambda层来去掉这个维度:
# There is an additional dimension of size 1 (out channel dimension) that
# we need to remove
char_token_output = layers.Lambda(lambda x: x[:, :, :, 0])(char_token_output)
为了得到最终的输出嵌入(即标记嵌入和基于字符的嵌入的结合),我们在最后一个维度上连接这两种嵌入。这样会得到一个长度为 48 的向量(即 32 长度的标记嵌入 + 12 长度的基于字符的标记嵌入):
# Concatenate the token and char embeddings
concat_embedding_out = layers.Concatenate()([token_embedding_out, char_token_output])
剩下的模型部分,我们保持不变。首先定义一个 RNN 层,并将concat_embedding_out作为输入:
# Define a simple bidirectional RNN layer, it returns an output at each
# position
rnn_layer_1 = layers.SimpleRNN(
units=64, activation='tanh', use_bias=True, return_sequences=True
)
rnn_out_1 = rnn_layer_1(concat_embedding_out)
记住,我们已将return_sequences=True,这意味着它会在每个时间步产生一个输出,而不是仅在最后一个时间步产生输出。接下来,我们定义最终的 Dense 层,它有n_classes个输出节点(即 9 个),并使用softmax激活函数:
# Defines the final prediction layer
dense_layer = layers.Dense(n_classes, activation='softmax')
dense_out = dense_layer(rnn_out_1)
我们像以前一样定义并编译模型:
# Defines the model
char_token_embedding_rnn = tf.keras.Model(inputs=word_input, outputs=dense_out)
# Define a macro accuracy measure
mean_accuracy_metric = tf.keras.metrics.MeanMetricWrapper(fn=macro_accuracy, name='macro_accuracy')
# Compile the model with a loss optimizer and metrics
char_token_embedding_rnn.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics=[mean_accuracy_metric])
这是我们的最终模型。与之前的解决方案相比,这个模型的关键区别在于它使用了两种不同的嵌入类型。一种是标准的基于标记的嵌入层,另一种是复杂的基于字符的嵌入,用于生成通过卷积操作得到的标记嵌入。现在,让我们来训练这个模型。
模型训练与评估
模型训练与我们为标准 RNN 模型所做的训练相同,因此我们将不再进一步讨论。
# Make train_sequences an array
train_sentences = np.array(train_sentences)
# Get sample weights (we cannot use class_weight with TextVectorization
# layer)
train_sample_weights = get_sample_weights_from_class_weights(padded_train_labels, train_class_weights)
# Training the model
char_token_embedding_rnn.fit(
train_sentences, padded_train_labels,
sample_weight=train_sample_weights,
batch_size=64,
epochs=3,
validation_data=(np.array(valid_sentences), padded_valid_labels)
)
在这些修改后,你应该能够获得大约 ~2% 的验证准确率提升和 ~1% 的测试准确率提升。
你可以做的其他改进
在这里,我们将讨论一些可以进一步提升模型性能的改进。
-
更多 RNN 层 — 添加更多堆叠的 RNN 层。通过增加更多的隐藏 RNN 层,我们可以使模型学习到更精细的潜在表示,从而提高性能。以下是一个示例用法:
rnn_layer_1 = layers.SimpleRNN( units=64, activation='tanh', use_bias=True, return_sequences=True ) rnn_out_1 = rnn_layer_1(concat_embedding_out) rnn_layer_2 = layers.SimpleRNN( units=32, activation='tanh', use_bias=True, return_sequences=True ) rnn_out_1 = rnn_layer_1(rnn_out_1) -
使 RNN 层具有双向性 – 到目前为止,我们讨论的 RNN 模型都是单向的,即从前向后看文本序列。然而,另一种变体称为双向 RNN,会从两个方向查看序列,即从前向后和从后向前。这有助于模型更好地理解语言,并不可避免地提高性能。我们将在接下来的章节中更详细地讨论这一变体。下面是一个示例用法:
rnn_layer_1 = layers.Bidreictional(layers.SimpleRNN( units=64, activation='tanh', use_bias=True, return_sequences=True )) -
融入正则化技术 – 你可以利用 L2 正则化和丢弃法(dropout)技术来避免过拟合,并提高模型的泛化能力。
-
使用早停和学习率衰减来减少过拟合 – 在模型训练过程中,使用早停(即仅在验证准确率提升时继续训练模型)和学习率衰减(即在训练过程中逐步降低学习率)。
我们建议你自己尝试一些这些技术,看看它们如何最大化 RNN 的性能。
总结
在本章中,我们看到了与传统的前馈神经网络不同的 RNN,它在解决时间序列任务时更为强大。
具体来说,我们讨论了如何从前馈神经网络结构得出 RNN。
我们假设有一个输入输出序列,并设计了一个能够表示输入输出序列的计算图。
这个计算图结果是将函数复制应用于序列中的每个输入输出元组。然后,通过将这个模型推广到序列中的任意单个时间步 t,我们能够得出 RNN 的基本计算图。我们讨论了计算隐藏状态和输出的精确方程和更新规则。
接下来,我们讨论了如何使用 BPTT 训练 RNN。我们分析了如何通过标准反向传播方法得到 BPTT,以及为什么不能使用标准的反向传播来训练 RNN。我们还讨论了使用 BPTT 时出现的两个重要实际问题——梯度消失和梯度爆炸——以及如何在表面层面解决这些问题。
然后我们继续探讨了 RNN 的实际应用。我们讨论了四种主要的 RNN 架构。单一对单一架构用于文本生成、场景分类和视频帧标注等任务。多对单一架构用于情感分析,在这里我们逐词处理句子/短语(与上一章中一次性处理完整句子不同)。单对多架构在图像字幕生成任务中常见,其中我们将单张图像映射为一个任意长的句子短语来描述该图像。多对多架构用于机器翻译任务。
我们使用 RNN 解决了命名实体识别(NER)任务。在 NER 中,问题是根据给定的标记序列,为每个标记预测一个标签。该标签表示一个实体(例如组织、位置、人物等)。为此,我们使用了嵌入以及 RNN 来处理每个标记,同时将标记序列视为时间序列输入。我们还使用了一个文本向量化层将标记转换为词 ID。文本向量化层的一个关键优势是它是模型的一部分,而不像我们之前使用的分词器那样单独存在。
最后,我们探讨了如何采用字符嵌入和卷积操作来生成标记嵌入。我们将这些新生成的标记嵌入与标准的词嵌入结合使用,以提高模型的准确性。
在下一章,我们将讨论一种更强大的 RNN 模型——长短时记忆(LSTM)网络,它进一步减少了消失梯度的负面影响,从而产生更好的结果。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,结识志同道合的人,并与超过 1000 名成员一起学习:packt.link/nlp

第七章:理解长短期记忆网络
本章将讨论一种更高级 RNN 变体背后的基本原理,这种变体被称为长短期记忆网络(LSTMs)。在这里,我们将专注于理解 LSTM 背后的理论,以便在下一章讨论它们的实现。LSTM 广泛应用于许多顺序任务(包括股市预测、语言建模和机器翻译),并且已被证明在大量数据的支持下,比旧的顺序模型(如标准 RNN)表现更好。LSTM 旨在避免我们在上一章讨论的梯度消失问题。
梯度消失带来的主要实际限制是,它阻止了模型学习长期依赖关系。然而,通过避免梯度消失问题,LSTM 能够存储比普通 RNN 更长时间的记忆(可达数百个时间步)。与只维持单一隐藏状态的 RNN 不同,LSTM 拥有更多的参数,并能更好地控制在每个训练步骤中应该存储哪些记忆、丢弃哪些记忆。例如,RNN 无法决定存储哪些记忆以及丢弃哪些记忆,因为隐藏状态在每个训练步骤都会被强制更新。
具体来说,我们将从一个非常高层次的角度讨论 LSTM 是什么,以及 LSTM 的功能如何使其能够存储长期依赖关系。然后,我们将深入探讨 LSTM 背后的实际数学框架,并通过一个例子来强调每个计算的重要性。我们还将比较 LSTM 和普通 RNN,看到 LSTM 拥有一个更加复杂的架构,使其在顺序任务中超越普通 RNN。
通过回顾梯度消失问题,并通过一个示例来说明这一问题,我们将理解 LSTM 是如何解决该问题的。
此后,我们将讨论为提高标准 LSTM 预测结果而引入的几种技术(例如,在文本生成任务中提高生成文本的质量/多样性)。例如,一次生成多个预测,而不是逐个预测,可以帮助提高生成预测的质量。我们还将介绍双向 LSTM(BiLSTMs),这是标准 LSTM 的扩展,它比标准 LSTM 在捕捉序列中的模式方面具有更强的能力。
最后,我们将讨论两个近期的 LSTM 变种。首先,我们将介绍窥视孔连接,它向 LSTM 门引入了更多的参数和信息,从而使 LSTM 能够更好地执行任务。接下来,我们将讨论门控循环单元(GRUs),由于其结构比标准 LSTM 更简单,并且不会降低性能,GRUs 正变得越来越受欢迎。
具体来说,本章将涵盖以下主要主题:
-
理解长短期记忆网络
-
LSTM 如何解决梯度消失问题
-
改进 LSTM
-
LSTM 的其他变种
Transformer 模型已成为一种更强大的序列学习替代方案。Transformer 模型提供了更好的性能,因为这些模型可以在给定的步骤访问序列的完整历史,而 LSTM 模型只能看到给定步骤的前一个输出。我们将在第十章《Transformers》和第十一章《使用 Transformer 进行图像描述》中详细讨论 Transformer 模型。然而,学习 LSTM 仍然值得,因为它们为下一代模型(如 Transformer)奠定了基础。此外,LSTM 在某些情况下仍被使用,尤其是在内存受限环境中的时间序列问题中。
理解长短期记忆网络
在本节中,我们将首先解释 LSTM 单元是如何工作的。我们将看到,除了隐藏状态外,还存在一个门控机制来控制单元内的信息流动。
然后,我们将通过一个详细的例子来演示,看看门控和状态如何在例子中的不同阶段帮助实现期望的行为,最终得到期望的输出。最后,我们将对比 LSTM 与标准 RNN,了解 LSTM 与标准 RNN 的区别。
什么是 LSTM?
LSTM 可以看作是 RNN 家族中更复杂、更强大的成员。尽管 LSTM 是一个复杂的系统,但 LSTM 的基本原理与 RNN 相同;它们通过按顺序处理每次输入的序列项来处理序列。LSTM 主要由五个不同的组件组成:
-
单元状态:这是 LSTM 单元的内部单元状态(即记忆)
-
隐藏状态:这是暴露给其他层并用于计算预测的外部隐藏状态
-
输入门:它决定当前输入有多少被读取到单元状态中
-
遗忘门:它决定之前的单元状态有多少被传递到当前单元状态中
-
输出门:它决定有多少单元状态被输出到隐藏状态中
我们可以将 RNN 包装成一个单元架构,如下所示:该单元会输出某些状态(带有非线性激活函数),该状态依赖于之前的单元状态和当前输入。然而,在 RNN 中,单元状态会随着每一个输入的到来不断更新。这种行为对于存储长期依赖关系来说是非常不理想的。
LSTM 可以决定何时添加、更新或忘记存储在每个神经元中的信息。换句话说,LSTM 配备了一种机制,可以保持单元状态不变(如果有助于更好的性能),从而使它们能够存储长期依赖关系。
这是通过引入门控机制来实现的。LSTM 为单元需要执行的每个操作配备了门控。门控是连续的(通常是 sigmoid 函数),其值介于 0 和 1 之间,其中 0 表示没有信息流经该门,1 表示所有信息都流经该门。每个 LSTM 单元使用一个这样的门控来控制每个神经元。正如在介绍中所解释的,这些门控控制以下内容:
-
当前输入写入单元状态的多少(输入门)
-
从上一个单元状态中忘记了多少信息(遗忘门)
-
从单元状态输出到最终隐藏状态的信息量(输出门)
图 7.1 说明了一个假设场景中的这一功能。每个门决定了各种数据(例如当前输入、上一个隐藏状态或上一个单元状态)流入状态的多少(即最终的隐藏状态或单元状态)。每条线的粗细表示从/到该门的信息流量(在某些假设场景中)。例如,在此图中,你可以看到输入门允许从当前输入流入的信息比从上一个最终隐藏状态流入的信息更多,而遗忘门则允许从上一个最终隐藏状态流入的信息比从当前输入流入的信息更多:

图 7.1:LSTM 中数据流的抽象视图
更详细的 LSTM
在这里,我们将介绍 LSTM 的实际机制。我们将首先简要讨论 LSTM 单元的整体视图,然后开始讨论 LSTM 单元中各个计算的细节,并结合一个文本生成的示例。
正如我们之前讨论的,LSTM 具有由以下三种门控组成的门控机制:
-
输入门:一个门,它输出的值介于 0(当前输入不会写入单元状态)和 1(当前输入完全写入单元状态)之间。使用 sigmoid 激活函数将输出压缩到 0 和 1 之间。
-
遗忘门:一个 sigmoid 门,它输出的值介于 0(上一个单元状态在计算当前单元状态时完全被遗忘)和 1(上一个单元状态在计算当前单元状态时完全被读取)之间。
-
输出门:一个 sigmoid 门,它输出的值介于 0(当前单元状态在计算最终状态时完全被丢弃)和 1(当前单元状态在计算最终隐藏状态时完全被使用)之间。
这可以通过 图 7.2 展示。这是一个非常高层次的图示,为了避免杂乱,一些细节被省略了。我们展示了带环路和不带环路的 LSTM,以便于理解。右侧的图显示了一个带环路的 LSTM,左侧的图则展示了相同的 LSTM,但环路已经展开,以便模型中没有环路:

图 7.2:带有递归链接(即,循环)的 LSTM(右)和展开的递归链接的 LSTM(左)
现在,为了更好地理解 LSTM,让我们考虑一个语言建模的例子。我们将并排讨论实际的更新规则和方程式,以便更好地理解 LSTM。
让我们考虑一个从以下句子开始生成文本的例子:
John 给 Mary 一只小狗。
我们输出的故事应该是关于 John、Mary 和 puppy 的。假设我们的 LSTM 在给定句子后输出两个句子:
John 给 Mary 一只小狗。____________________. _____________________.
以下是我们 LSTM 输出的结果:
John 给 Mary 一只小狗。它非常大声地叫。它们给它取名为 Luna。
我们离输出像这样的真实短语还远远不够。然而,LSTM 可以学习名词和代词之间的关系。例如,it 与 puppy 相关,they 与 John 和 Mary 相关。接下来,它应该学习名词/代词和动词之间的关系。例如,对于 it,动词的末尾应该加上 s。我们在图 7.3中展示了这些关系/依赖关系。正如我们所看到的,短期(例如,It --> barks)和长期(例如,Luna --> puppy)的依赖关系都存在于这个短语中。实线箭头表示名词与代词之间的联系,虚线箭头表示名词/代词与动词之间的联系:

图 7.3:LSTM 给出的句子和预测的句子,其中单词之间的各种关系被高亮显示
现在让我们考虑 LSTM 如何通过其各种操作,建模这些关系和依赖,以便在给定起始句子的情况下输出合理的文本。
输入门 (i[t]) 接收当前输入 (x[t]) 和上一个最终隐藏状态 (h[t-1]) 作为输入,并计算 i[t],计算方式如下:

输入门 i[t] 可以理解为在标准的单隐藏层 RNN 的隐藏层中执行的计算,该 RNN 使用的是 sigmoid 激活函数。记住我们是通过以下方式计算标准 RNN 的隐藏状态的:

因此,LSTM 的 i[t] 计算与标准 RNN 的 h[t] 计算非常相似,唯一的区别在于激活函数的变化和添加了偏置项。
经过计算,i[t]的值为 0 意味着当前输入的任何信息都不会流入单元状态,而值为 1 则意味着所有当前输入的信息都会流入单元状态。
接下来,计算另一个值(称为候选值),该值将被用于后续计算当前单元状态。这个值将被视为当前时间步长最终单元状态的潜在候选值:

我们可以在图 7.4中可视化这些计算:

图 7.4:i[t] 和
(加粗)在所有 LSTM 计算(灰色部分)上下文中的计算
在我们的例子中,在学习的最初阶段,输入门需要高度激活,因为模型对任务没有任何先验知识。LSTM 输出的第一个词是它。此外,为了做到这一点,LSTM 必须学会puppy也可以称作它。假设我们的 LSTM 有五个神经元来存储状态。我们希望 LSTM 存储的信息是它指的是puppy。我们希望 LSTM 学习的另一个信息(在不同的神经元中)是,当使用代词它时,动词的现在时应加上's'。
LSTM 还需要知道的一件事是puppy barks loud。图 7.5展示了这条知识如何可能被编码到 LSTM 的单元状态中。每个圆圈代表单元状态中的一个神经元(即一个隐藏单元):

图 7.5:应编码到单元状态中以输出第一个句子的知识
有了这些信息,我们可以输出第一个新的句子:
约翰给了玛丽一只小狗。它叫得非常大声。
接下来,忘记门的计算如下:

忘记门的作用如下。忘记门的值为 0 意味着* c [t-1]中的信息不会传递到计算 c [t],而值为 1 则意味着 c *[t-1]的所有信息都会传递到 c [t]的计算中。这可能听起来有些反直觉,因为打开忘记门会让模型记住前一步的内容,反之亦然。但为了尊重原始命名规范和设计,我们将继续使用它们。
现在我们将看到忘记门如何帮助预测下一句话:
他们把它命名为 Luna。
如你所见,我们现在关注的新的关系是约翰和玛丽以及他们之间的关系。因此,我们不再需要关于它的信息,也不再需要动词bark的行为,因为主语是约翰和玛丽。我们可以结合当前的主语他们和相应的动词命名来替代存储在当前主语和当前主语动词神经元中的信息(见图 7.6):

图 7.6:第三个神经元(从左数)的知识(it --> barks)被新信息(they --> named)替代
就权重值而言,我们在图 7.7中展示了这种转化。我们不会改变保持it --> puppy关系的神经元的状态,因为puppy在最后一句话中作为一个对象出现。这是通过将连接it --> puppy的权重从c[t-1]到c[t]设置为 1 来完成的。然后我们将保持当前主语和动词信息的神经元替换为新的主语和动词。这是通过将该神经元的forget权重f[t]设置为 0 来实现的。接着,我们将连接当前主语和动词到相应状态神经元的i[t]权重设置为 1。我们可以将
(候选值)看作是单元记忆的潜在候选者,因为它包含了来自当前输入x[t]的信息:

图 7.7:如何使用前一个状态 c[t-1]和候选值
来计算单元状态 c[t]
当前的单元状态将如下更新:

换句话说,当前状态是以下内容的组合:
-
忘记/记住来自前一个单元状态的信息
-
添加/丢弃当前输入的信息
接下来,在图 7.8中,我们突出显示了到目前为止我们所计算的内容,涉及 LSTM 内部进行的所有计算:

图 7.8:到目前为止的计算,包括 i[t]、f[t]、
和 c[t]
学习完整的单元状态后,它将像图 7.9那样:

图 7.9:输出两句话后,完整的单元状态将如下所示
接下来,我们将看看 LSTM 单元的最终状态(h[t])是如何计算的:


在我们的示例中,我们希望输出以下句子:
他们给它取名为 Luna。
对于这个,我们不需要倒数第二个神经元来计算这个句子,因为它包含了关于小狗叫声的信息,而这个句子是关于小狗的名字。因此,在预测最后一句话时,我们可以忽略这个神经元(包含叫声 -> 大声关系)。这正是o[t]所做的;它忽略了不必要的记忆,并且在计算 LSTM 单元的最终输出时,只从单元状态中提取相关的记忆。同时,在图 7.10中,我们展示了完整的 LSTM 单元的概览:

图 7.10:完整的 LSTM 单元结构
在这里,我们总结了与 LSTM 单元内操作相关的所有方程式:






现在从更大的角度来看,对于一个序列学习问题,我们可以将 LSTM 单元在时间上展开,显示它们如何相互连接,以便接收细胞的前一个状态来计算下一个状态,如图 7.11所示:

图 7.11:LSTM 如何在时间上连接
然而,这还不足以完成一些有用的任务。我们通常使用机器学习模型来解决形式化为分类或回归问题的任务。正如你所看到的,我们仍然没有输出层来输出预测。但是,如果我们想要使用 LSTM 实际学到的东西,我们需要一种方法来从 LSTM 中提取最终的输出。因此,我们将在 LSTM 上方安装一个softmax层(带有权重W[s]和偏置b[s])。最终输出是通过以下公式获得的:

现在,带有 softmax 层的 LSTM 的最终图像看起来像图 7.12:

图 7.12:带有 softmax 输出层的 LSTM 在时间上连接
在 LSTM 上附加 softmax 头后,它现在可以执行给定的分类任务,并且能够端到端地完成。现在,让我们比较和对比 LSTM 和上一章讨论的标准 RNN 模型。
LSTM 与标准 RNN 的区别
现在让我们研究 LSTM 与标准 RNN 的比较。与标准 RNN 相比,LSTM 具有更复杂的结构。一个主要的区别是,LSTM 有两个不同的状态:细胞状态c[t]和最终隐藏状态h[t]。然而,RNN 只有一个隐藏状态h[t]。下一个主要区别是,由于 LSTM 有三个不同的门,LSTM 对如何在计算最终隐藏状态h[t]时处理当前输入和前一个细胞状态具有更大的控制权。
拥有这两个不同的状态是非常有优势的。通过这种机制,我们可以将模型的短期记忆和长期记忆解耦。换句话说,即使细胞状态在快速变化,最终的隐藏状态仍然会更慢地变化。所以,尽管细胞状态在学习短期和长期依赖关系,但最终的隐藏状态可以仅反映短期依赖、仅反映长期依赖,或者同时反映两者。
接下来,门控机制由三个门组成:输入门、遗忘门和输出门。
很明显,这是一种更加有原则的方法(特别是与标准 RNN 相比),它允许更好地控制当前输入和前一个细胞状态在当前细胞状态中的贡献。此外,输出门可以更好地控制细胞状态对最终隐藏状态的贡献。
在图 7.13中,我们比较了标准 RNN 和 LSTM 的示意图,以强调这两种模型在功能上的区别:

图 7.13:标准 RNN 与 LSTM 单元的并排比较
总结来说,通过设计保持两种不同状态,LSTM 可以学习短期和长期的依赖关系,这有助于解决我们将在下一节讨论的梯度消失问题。
LSTM 如何解决梯度消失问题
正如我们之前讨论的,尽管 RNN 从理论上是合理的,但在实践中它们存在一个严重缺陷。也就是说,当使用时间反向传播(BPTT)时,梯度会迅速衰减,这使得我们只能传播几个时间步的信息。因此,我们只能存储非常少的时间步信息,从而只有短期记忆。这反过来限制了 RNN 在实际序列任务中的使用。
通常,有用且有趣的序列任务(例如股票市场预测或语言建模)需要能够学习和存储长期依赖关系。考虑以下预测下一个单词的例子:
约翰是一个有天赋的学生。他是一个 A 等生,且会打橄榄球和板球。其他所有学生都羡慕 ______。
对我们来说,这是一个非常简单的任务。答案是约翰。然而,对于 RNN 来说,这是一个困难的任务。我们正在尝试预测一个位于文本开头的答案。而且,为了解决这个任务,我们需要一种方法在 RNN 的状态中存储长期依赖关系。这正是 LSTM 设计用来解决的任务。
在第六章,递归神经网络中,我们讨论了在没有任何非线性函数存在的情况下,梯度消失/爆炸是如何出现的。现在我们将看到,即使有非线性项存在,梯度消失问题仍然可能发生。为此,我们将推导出标准 RNN 的项!和 LSTM 网络的项!,以理解它们之间的差异。这是导致梯度消失的关键项,正如我们在上一章所学的那样。
假设标准 RNN 的隐藏状态计算如下:

为了简化计算,我们可以忽略当前输入相关的项,专注于递归部分,这将给出以下方程:

如果我们计算前面方程的
,我们将得到以下结果:


现在让我们看看当
或
(随着学习的进行,这将发生)时会发生什么。在这两种情况下,
将开始趋近于 0,从而产生消失梯度。即使在
时,对于 sigmoid 激活函数,梯度在最大值(0.25)下,经过多次时间步长的乘积,整体梯度变得非常小。此外,项
(可能由于初始化不当)也可能导致梯度爆炸或消失。然而,与由于
或
导致的梯度消失相比,项
所导致的梯度消失/爆炸相对较容易解决(通过仔细初始化权重和梯度裁剪)。
现在让我们来看看 LSTM 单元。更具体地,我们将查看由以下方程给出的单元状态:

这是 LSTM 中所有忘记门应用的乘积。然而,如果你以类似的方式计算 LSTM 中的
(也就是说,忽略
项和* b *[f],因为它们是非递归的),我们得到以下结果:

在这种情况下,尽管当
时梯度会消失,另一方面,如果
,则导数将比标准 RNN 中的下降速度慢得多。因此,我们有一个替代方法,在这种方法下梯度不会消失。此外,随着压缩函数的使用,梯度不会由于
过大而爆炸(这通常是导致梯度爆炸的原因)。此外,当
时,我们获得一个接近 1 的最大梯度,这意味着梯度不会像我们在 RNN 中看到的那样迅速减小(当梯度处于最大值时)。最后,推导中没有
这样的项。然而,对于
的推导更加棘手。让我们看看在
的推导中是否存在这样的项。如果你计算这个的导数,你将得到以下形式的结果:

一旦你解决了这个问题,你将得到以下形式的结果:

我们不关心
或
中的内容,因为无论其值如何,它都将被限制在(0,1)或(-1,1)之间。如果我们通过将
、
、
和
项替换为公共符号,如
,我们得到以下形式:

或者,我们得到以下结果(假设外部
被每个
项吸收,这些项存在于方括号内):

这将给出以下结果:

这意味着,尽管术语
安全地避免了任何
术语,但
却不是。因此,我们在初始化 LSTM 的权重时必须小心,并且应该使用梯度裁剪。
注意
然而,h[t]对于 LSTM 来说并不像 RNN 那样由于梯度消失而不安全,因为c[t]仍然可以存储长期依赖性,而不受梯度消失的影响,并且h[t]如果需要的话可以从c[t]中检索长期依赖性。
改进 LSTM
拥有一个基于坚实基础的模型并不总能在实际应用中保证切实的成功。自然语言非常复杂。有时经验丰富的作家也难以创作出高质量的内容。因此,我们不能指望 LSTM 突然间就能神奇地输出有意义、写得很好的内容。拥有一个复杂的设计——使得能够更好地建模数据中的长期依赖性——确实有帮助,但我们仍然需要在推理过程中使用更多的技术来生成更好的文本。因此,已经开发出了许多扩展,以帮助 LSTM 在预测阶段表现得更好。这里我们将讨论几种改进方法:贪心采样、束搜索、使用词向量代替词的独热编码表示、以及使用双向 LSTM。需要注意的是,这些优化技术并非专门针对 LSTM 的;任何序列模型都可以从中受益。
贪心采样
如果我们总是尝试预测概率最高的单词,LSTM 往往会产生非常单调的结果。例如,由于停用词(例如the)的频繁出现,它可能会在切换到另一个单词之前重复这些停用词很多次。
解决这个问题的一种方法是使用贪心采样,即我们选择预测出的最佳n并从该集合中进行采样。这有助于打破预测的单调性。
让我们考虑前一个例子中的第一句话:
约翰给玛丽一只小狗。
假设我们从第一个单词开始,并希望预测接下来的四个单词:
约翰 ____ ____ _ _____。
如果我们尝试以确定性方式选择样本,LSTM 可能会输出如下内容:
约翰给玛丽给约翰。
然而,通过从词汇表中的子集(最有可能的词)中采样下一个单词,LSTM 被迫变化预测,可能会输出以下内容:
约翰给玛丽一只小狗。
或者,它可能会给出以下输出:
约翰给小狗了一只小狗。
然而,尽管贪心采样有助于为生成的文本增加更多的风味/多样性,但这种方法并不能保证输出的内容始终是现实的,尤其是在输出较长的文本序列时。现在,我们将看到一种更好的搜索技术,它实际上会在做出预测之前向前看几个步骤。
束搜索
束搜索是一种帮助提高 LSTM 生成的预测质量的方法。在这个过程中,预测是通过解决一个搜索问题来找到的。特别地,我们在每一步为多个候选词预测多个步骤。这就产生了一个树状结构,其中包含单词的候选序列(图 7.14)。束搜索的关键思想是一次生成b个输出(即
),而不是生成单一的输出y[t]。这里,b 被称为束的长度,生成的b个输出被称为束。更技术上来说,我们选择具有最高联合概率
的束,而不是选择具有最高概率的
。我们在做出预测之前向未来预测得更远,这通常会导致更好的结果。
让我们通过前面的示例来理解束搜索:
John gave Mary a puppy.
比如,我们逐词预测,最初我们有以下内容:
John ____ ____ _ _____.
假设我们的 LSTM 通过束搜索生成了示例句子。那么每个单词的概率可能如下所示,如图 7.14所示。假设束长b = 2,我们将在搜索的每个阶段考虑n = 3个最佳候选词。
搜索树看起来如下图所示:

图 7.14:束搜索的搜索空间,b=2,n=3
我们从单词John开始,并获取词汇表中所有单词的概率。在我们的示例中,由于n = 3,我们为树的下一层选择最佳的三个候选词:gave、Mary 和 puppy。(请注意,这些可能不是实际 LSTM 找到的候选词,仅用于示例。)然后从这些选定的候选词中,树的下一层会继续扩展。接着,我们将从中选出最好的三个候选词,搜索会重复,直到我们达到树的深度b。
给出最高联合概率的路径(即
)用较粗的箭头突出显示。此外,这是一种更好的预测机制,因为它会为像John gave Mary这样的短语返回更高的概率或奖励,而不是John Mary John或John John gave。
请注意,在我们的示例中,通过贪婪采样和束搜索生成的输出是相同的,这是一个包含五个单词的简单句子。然而,当我们将其扩展到输出一小段文章时,情况就不一样了。那时,束搜索生成的结果将比贪婪采样生成的结果更具现实性和意义。
使用词向量
提高 LSTM 性能的另一种流行方法是使用词向量,而不是使用独热编码向量作为 LSTM 的输入。我们通过一个例子来理解这种方法的价值。假设我们想要从某个随机词开始生成文本。在我们的案例中,它将是以下内容:
约翰 ____ ____ _ _____.
我们已经在以下句子上训练过我们的 LSTM:
约翰给了玛丽一只小狗。玛丽给鲍勃送了一只小猫。
假设我们有如图 7.15所示的位置的词向量。记住,语义相似的词会有相近的词向量:

图 7.15:假定词向量在二维空间中的拓扑
这些词的词嵌入,在其数字形式下,可能看起来像如下:
小猫: [0.5, 0.3, 0.2]
小狗: [0.49, 0.31, 0.25]
给: [0.1, 0.8, 0.9]
可以看出,distance(小猫, 小狗) < distance(小猫, 给)。然而,如果我们使用独热编码,它们将变成如下:
小猫: [ 1, 0, 0, …]
小狗: [0, 1, 0, …]
给: [0, 0, 1, …]
然后,distance(小猫, 小狗) = distance(小猫, 给)。正如我们已经看到的,独热编码向量不能捕捉词与词之间的适当关系,它们将所有词视为相等的距离。然而,词向量能够捕捉这些关系,更适合用于机器学习模型中的文本表示。
使用词向量,LSTM 将更好地学习词与词之间的关系。例如,使用词向量时,LSTM 将学到以下内容:
约翰给了玛丽一只小猫。
这与以下内容非常接近:
约翰给了玛丽一只小狗。
此外,它与以下内容有很大的不同:
约翰给了玛丽一个给。
然而,如果使用独热编码向量,情况就不一样了。
双向 LSTM(BiLSTM)
使 LSTM 变为双向 LSTM 是提高 LSTM 预测质量的另一种方法。这里的意思是用从开始到结束和从结束到开始的文本来训练 LSTM。到目前为止,在训练 LSTM 时,我们将创建如下的数据集:
考虑以下两个句子:
约翰给了玛丽一个 _____. 它叫得非常大声。
在这个阶段,有一个句子中缺失了数据,我们希望 LSTM 能合理地填充这个缺失部分。
如果我们从句子的开头读到缺失单词,它将是如下:
约翰给了玛丽一个 _____.
这并没有提供足够的信息来确定缺失单词的上下文。然而,如果我们从两个方向阅读,它将变成以下内容:
约翰给了玛丽一个 _____.
_____. 它叫得非常大声。
如果我们同时创建了这两部分数据,那么可以预测缺失的单词应该是像dog或puppy这样的词。因此,某些问题可以从双向读取数据中显著受益。BiLSTM 还帮助解决多语言问题,因为不同语言可能有非常不同的句子结构。
BiLSTM 的另一个应用是神经机器翻译,其中我们将源语言的句子翻译成目标语言。由于不同语言之间没有具体的一对一对齐关系,能够访问源语言中给定词汇的前后信息可以极大地帮助更好地理解上下文,从而生成更好的翻译。例如,考虑将菲律宾语翻译成英语。在菲律宾语中,句子的顺序通常是动词-宾语-主语,而在英语中,则是主语-动词-宾语。在这个翻译任务中,前后双向阅读句子将极大地帮助生成良好的翻译。
BiLSTM 本质上是两个独立的 LSTM 网络。一个网络从头到尾学习数据,另一个网络从尾到头学习数据。在图 7.16中,我们展示了 BiLSTM 网络的架构。
训练分为两个阶段。首先,实线网络使用从头到尾读取文本生成的数据进行训练。这个网络代表了标准 LSTM 的常规训练过程。其次,虚线网络使用从后向前读取文本生成的数据进行训练。然后,在推理阶段,我们通过连接实线和虚线的状态信息(并生成一个向量)来预测缺失的单词:

图 7.16:双向 LSTM 的示意图
在这一部分,我们讨论了几种不同的方法来提高 LSTM 模型的性能。这包括采用更好的预测策略,引入结构性变化,如词向量和双向 LSTM(BiLSTM)。
LSTM 的其他变体
尽管我们将主要关注标准 LSTM 架构,但许多变体已经出现,它们要么简化了标准 LSTM 中的复杂架构,要么提高了性能,或者两者兼有。我们将探讨两种引入结构性修改的 LSTM 变体:窥视连接(peephole connections)和 GRU。
窥视连接
窥视连接允许门不仅查看当前输入和先前的最终隐藏状态,还可以查看先前的细胞状态。这增加了 LSTM 单元中的权重数量。已经证明,拥有这种连接可以产生更好的结果。方程式将如下所示:






让我们简要看看这如何帮助 LSTM 表现得更好。到目前为止,门控机制只能看到当前输入和最终隐藏状态,但看不到单元状态。然而,在这种配置下,如果输出门接近零,即使单元状态包含对更好性能至关重要的信息,最终的隐藏状态也会接近零。因此,门控机制在计算时不会考虑隐藏状态。直接将单元状态包括在门控计算方程中,可以对单元状态进行更多控制,即使在输出门接近零的情况下,它也能表现良好。
我们在图 7.17中展示了具有窥视连接的 LSTM 架构。我们已将标准 LSTM 中所有现有的连接设为灰色,新增的连接则用黑色表示:

图 7.17:具有窥视连接的 LSTM(窥视连接用黑色表示,其他连接用灰色表示)
门控递归单元
GRU可以看作是标准 LSTM 架构的简化版。正如我们之前所见,LSTM 有三个不同的门和两个不同的状态。仅这一点就需要大量的参数,即使对于一个较小的状态尺寸来说也是如此。因此,科学家们研究了减少参数数量的方法。GRU 是其中一个成果。
GRU 与 LSTM 相比,有几个主要的区别。
首先,GRU 将两个状态,即单元状态和最终隐藏状态,合并成一个单一的隐藏状态h[t]。现在,由于这个简单的修改没有两个不同的状态,我们可以去除输出门。记住,输出门仅仅是决定有多少单元状态被读取到最终隐藏状态中。这个操作大大减少了单元中的参数数量。
接下来,GRU 引入了一个重置门,当它接近 1 时,在计算当前状态时会完全采纳前一个状态的信息。而当重置门接近 0 时,它会忽略前一个状态,只关注当前状态的计算:


然后,GRU 将输入门和遗忘门合并成一个更新门。标准的 LSTM 有两个门,分别是输入门和遗忘门。输入门决定当前输入有多少被读入到单元状态中,而遗忘门决定前一个单元状态有多少被读入到当前单元状态中。数学上,这可以表示如下:


GRU 将这两种操作合并成一个单一的门控操作,称为更新门。如果更新门为 0,则将前一单元状态的全部状态信息传递到当前单元状态,此时不会将当前输入读入状态。如果更新门为 1,则所有当前输入都会读入当前单元状态,且前一单元状态不会传递到当前单元状态。换句话说,输入门i[t]变成了遗忘门的反向,即!:


现在让我们将所有的公式整理到一起。GRU 的计算过程如下所示:




这比 LSTM 更加简洁。在图 7.18中,我们可以将 GRU 单元(左)和 LSTM 单元(右)并排展示:

图 7.18:GRU(左)和标准 LSTM(右)的并排比较
在本节中,我们学习了 LSTM 的两种变体:带窥视孔的 LSTM 和 GRU。由于其简洁性以及与更复杂的 LSTM 相当的性能,GRU 已经成为比 LSTM 更受欢迎的选择。
总结
在本章中,你学习了 LSTM 网络。首先,我们讨论了 LSTM 是什么及其高层次的架构。我们还深入探讨了 LSTM 中的详细计算,并通过一个例子讨论了这些计算。
我们看到,LSTM 主要由五个不同的部分组成:
-
单元状态:LSTM 单元的内部单元状态
-
隐藏状态:用于计算预测的外部隐藏状态
-
输入门:决定多少当前输入被读取到单元状态中
-
遗忘门:决定多少前一单元状态被发送到当前单元状态
-
输出门:决定多少单元状态被输出到隐藏状态中
拥有如此复杂的结构,使得 LSTM 能够很好地捕捉短期和长期依赖。
我们将 LSTM 与普通 RNN 进行了比较,发现 LSTM 实际上能够学习长期依赖,这是其结构的固有部分,而 RNN 则可能无法学习长期依赖。之后,我们讨论了 LSTM 如何通过其复杂的结构解决消失梯度问题。
然后我们讨论了几种改进 LSTM 性能的扩展。首先是一个非常简单的技术,叫做贪婪采样,在这种方法中,我们并非总是输出最佳候选,而是从一组最佳候选中随机采样一个预测。我们看到这提高了生成文本的多样性。之后,我们看了一个更复杂的搜索技术,叫做束搜索。使用束搜索时,我们不是仅预测单个时间步的未来,而是预测多个时间步的未来,并选择产生最佳联合概率的候选。另一个改进是观察词向量如何帮助提升 LSTM 的预测质量。通过使用词向量,LSTM 能更有效地学习在预测时替换语义相似的词(例如,LSTM 可能会输出 cat 代替 dog),从而使生成的文本更加真实和准确。最后,我们考虑的扩展是双向 LSTM(BiLSTM)。BiLSTM 的一个流行应用是填补短语中的缺失词。BiLSTM 会从两个方向读取文本:从前往后和从后往前。这提供了更多的上下文信息,因为我们在做出预测前,既看到了过去的内容,也看到了未来的内容。
最后,我们讨论了普通 LSTM 的两种变体:窥视孔连接和 GRU。普通 LSTM 在计算门时,只查看当前输入和隐藏状态。而使用窥视孔连接时,门的计算依赖于所有内容:当前输入、隐藏状态和细胞状态。
GRU 是一种比普通 LSTM 更加优雅的变体,它简化了 LSTM,同时没有牺牲性能。GRU 只有两个门和一个状态,而普通的 LSTM 有三个门和两个状态。
在下一章,我们将看到这些不同的架构在实际应用中的表现,展示每种架构的实现,并观察它们在文本生成任务中的表现如何。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,与志同道合的人一起学习,和超过 1000 名成员一起进步,访问链接:packt.link/nlp

第八章:LSTM 的应用——生成文本
现在,我们已经对 LSTM 的基本机制有了充分的理解,例如它们如何解决梯度消失问题和更新规则,我们可以看看如何在 NLP 任务中使用它们。LSTM 被用于文本生成和图像标题生成等任务。例如,语言建模是任何 NLP 任务的核心,因为有效的语言建模能力直接导致了有效的语言理解。因此,语言建模通常用于预训练下游决策支持 NLP 模型。单独使用时,语言建模可以用于生成歌曲(towardsdatascience.com/generating-drake-rap-lyrics-using-language-models-and-lstms-8725d71b1b12),电影剧本(builtin.com/media-gaming/ai-movie-script)等。
本章将介绍的应用是构建一个能够编写新民间故事的 LSTM。为此任务,我们将下载格林兄弟的一些民间故事的翻译版本。我们将使用这些故事来训练一个 LSTM,并让它输出一个全新的故事。我们将通过将文本拆分为字符级的二元组(n-gram,其中n=2)来处理文本,并用唯一的二元组构建词汇表。请注意,将二元组表示为独热编码向量对机器学习模型来说非常低效,因为它迫使模型将每个二元组视为完全不同于其他二元组的独立文本单元。而二元组之间是共享语义的,某些二元组会共同出现,而有些则不会。独热编码将忽略这一重要属性,这是不理想的。为了在建模中利用这一特性,我们将使用嵌入层,并与模型一起联合训练。
我们还将探索如何实现先前描述的技术,如贪婪采样或束搜索,以提高预测质量。之后,我们将看看如何实现除了标准 LSTM 之外的时间序列模型,如 GRU。
具体来说,本章将涵盖以下主要内容:
-
我们的数据
-
实现语言模型
-
将 LSTM 与带有窥视孔连接的 LSTM 以及 GRU 进行比较
-
改进序列模型——束搜索
-
改进 LSTM——用单词而不是 n-gram 生成文本
我们的数据
首先,我们将讨论用于文本生成的数据以及为了清理数据而进行的各种预处理步骤。
关于数据集
首先,我们将了解数据集的样子,以便在看到生成的文本时,能够评估它是否合乎逻辑,基于训练数据。我们将从网站www.cs.cmu.edu/~spok/grimmtmp/下载前 100 本书。这些是格林兄弟的一组书籍的翻译(从德语到英语)。
一开始,我们将通过自动化脚本从网站上下载所有 209 本书,具体如下:
url = 'https://www.cs.cmu.edu/~spok/grimmtmp/'
dir_name = 'data'
def download_data(url, filename, download_dir):
"""Download a file if not present, and make sure it's the right
size."""
# Create directories if doesn't exist
os.makedirs(download_dir, exist_ok=True)
# If file doesn't exist download
if not os.path.exists(os.path.join(download_dir,filename)):
filepath, _ = urlretrieve(url + filename,
os.path.join(download_dir,filename))
else:
filepath = os.path.join(download_dir, filename)
return filepath
# Number of files and their names to download
num_files = 209
filenames = [format(i, '03d')+'.txt' for i in range(1,num_files+1)]
# Download each file
for fn in filenames:
download_data(url, fn, dir_name)
# Check if all files are downloaded
for i in range(len(filenames)):
file_exists = os.path.isfile(os.path.join(dir_name,filenames[i]))
assert file_exists
print('{} files found.'.format(len(filenames)))
现在我们将展示从两个随机挑选的故事中提取的示例文本。以下是第一个片段:
然后她说,我亲爱的本杰明,你父亲为你和你的十一位兄弟做了这些棺材,因为如果我生了一个小女孩,你们都将被杀死并埋葬在其中。当她说这些话时,她哭了起来,而儿子安慰她说,别哭,亲爱的母亲,我们会自救的,去外面吧。但她说,带着你的十一位兄弟走进森林,让其中一个始终坐在能找到的最高的树上,守望着,朝着城堡中的塔楼看。如果我生了一个小儿子,我会举白旗,然后你们可以回来。但如果我生了一个女孩,我会升起红旗,那个时候你们要尽快逃走,愿上帝保佑你们。
第二段文字如下:
红帽子并不知道自己是多么邪恶的生物,根本不怕他。
“早上好,小红帽。”他说。
“非常感谢,狼。”
“这么早去哪儿,小红帽?”
“去我奶奶家。”
“你围裙里装的是什么?”
“蛋糕和酒。昨天是烘焙日,所以可怜的生病奶奶得吃点好的,增强她的体力。”
“你奶奶住在哪里,小红帽?”
“在森林里再走四分之一里程,过了三棵大橡树,她的房子就在这三棵树下,栗树就在它们下面。你一定知道的。”小红帽回答道。
狼心想,这个小家伙多么温柔。真是一个美味的嫩肉,吃她比吃老太婆要好。
我们现在已经了解了数据的样子。通过这些理解,我们接下来将继续处理我们的数据。
生成训练集、验证集和测试集
我们将把下载的故事分成三个集合:训练集、验证集和测试集。我们将使用每个集合中文件的内容作为训练、验证和测试数据。我们将使用 scikit-learn 的train_test_split()函数来完成这项工作。
from sklearn.model_selection import train_test_split
# Fix the random seed so we get the same output everytime
random_state = 54321
filenames = [os.path.join(dir_name, f) for f in os.listdir(dir_name)]
# First separate train and valid+test data
train_filenames, test_and_valid_filenames = train_test_split(filenames, test_size=0.2, random_state=random_state)
# Separate valid+test data to validation and test data
valid_filenames, test_filenames = train_test_split(test_and_valid_filenames, test_size=0.5, random_state=random_state)
# Print out the sizes and some sample filenames
for subset_id, subset in zip(('train', 'valid', 'test'), (train_filenames, valid_filenames, test_filenames)):
print("Got {} files in the {} dataset (e.g.
{})".format(len(subset), subset_id, subset[:3]))
train_test_split()函数接受一个iterable(例如列表、元组、数组等)作为输入,并根据定义的拆分比例将其拆分为两个集合。在此案例中,输入是一个文件名列表,我们首先按 80%-20%的比例拆分为训练数据和[验证 + 测试]数据。然后,我们进一步将test_and_valid_filenames按 50%-50%拆分,生成测试集和验证集。请注意,我们还将一个随机种子传递给train_test_split函数,以确保在多次运行中获得相同的拆分。
这段代码将输出以下文本:
Got 167 files in the train dataset (e.g. ['data\\117.txt', 'data\\133.txt', 'data\\069.txt'])
Got 21 files in the valid dataset (e.g. ['data\\023.txt', 'data\\078.txt', 'data\\176.txt'])
Got 21 files in the test dataset (e.g. ['data\\129.txt', 'data\\207.txt', 'data\\170.txt'])
我们可以看到,从 209 个文件中,大约 80%的文件被分配为训练数据,10%为验证数据,剩下的 10%为测试数据。
分析词汇量
我们将使用二元组(即n=2的 n-gram)来训练我们的语言模型。也就是说,我们将把故事拆分为两个字符的单元。此外,我们将把所有字符转换为小写,以减少输入的维度。使用字符级的二元组有助于我们使用较小的词汇表进行语言建模,从而加速模型训练。例如:
国王正在森林中打猎。
将被分解为如下的二元组序列:
[‘th’, ‘e ‘, ‘ki’, ‘ng’, ‘ w’, ‘as’, …]
让我们找出词汇表的大小。为此,我们首先定义一个set对象。接下来,我们遍历每个训练文件,读取内容,并将其作为字符串存储在变量 document 中。
最后,我们用包含每个故事的字符串中的所有二元组更新set对象。通过每次遍历字符串两个字符来获取二元组:
bigram_set = set()
# Go through each file in the training set
for fname in train_filenames:
document = [] # This will hold all the text
with open(fname, 'r') as f:
for row in f:
# Convert text to lower case to reduce input dimensionality
document.append(row.lower())
# From the list of text we have, generate one long string
# (containing all training stories)
document = " ".join(document)
# Update the set with all bigrams found
bigram_set.update([document[i:i+2] for i in range(0,
len(document), 2)])
# Assign to a variable and print
n_vocab = len(bigram_set)
print("Found {} unique bigrams".format(n_vocab))
这将打印:
Found 705 unique bigrams
我们的词汇表包含 705 个二元组。如果我们决定将每个单词视为一个单元,而不是字符级的二元组,词汇量会更大。
定义 tf.data 管道
我们现在将定义一个完善的数据管道,能够从磁盘读取文件,并将内容转换为可用于训练模型的格式或结构。TensorFlow 中的tf.data API 允许你定义数据管道,可以以特定的方式处理数据,以适应机器学习模型。为此,我们将定义一个名为generate_tf_dataset()的函数,它接受以下内容:
-
filenames– 包含用于模型的文本的文件名列表 -
ngram_width– 要提取的 n-gram 的宽度 -
window_size– 用于生成模型单一数据点的 n-gram 序列的长度 -
batch_size– 批量大小 -
shuffle– (默认为False)是否打乱数据
例如,假设ngram_width为 2,batch_size为 1,window_size为 5。此函数将接受字符串“国王正在森林中打猎”并输出:
Batch 1: ["th", "e ", "ki", " ng", " w"] -> ["e ", "ki", "ng", " w", "as"]
Batch 2: ["as", " h", "un", "ti", "ng"] -> [" h", "un", "ti", "ng", " i"]
…
每个批次中的左侧列表表示输入序列,右侧列表表示目标序列。注意右侧列表只是将左侧列表向右移了一位。还要注意,两条记录中的输入没有重叠。但在实际的函数中,我们将在记录之间保持小的重叠。图 8.1展示了高级过程:

图 8.1:我们将使用 tf.data API 实现的数据转换的高级步骤
让我们讨论如何使用 TensorFlow 的tf.data API 实现管道的具体细节。我们将定义生成数据管道的代码作为可重用的函数:
def generate_tf_dataset(filenames, ngram_width, window_size, batch_size, shuffle=False):
""" Generate batched data from a list of files speficied """
# Read the data found in the documents
documents = []
for f in filenames:
doc = tf.io.read_file(f)
doc = tf.strings.ngrams( # Generate ngrams from the string
tf.strings.bytes_split(
# Create a list of chars from a string
tf.strings.regex_replace(
# Replace new lines with space
tf.strings.lower( # Convert string to lower case
doc
), "\n", " "
)
),
ngram_width, separator=''
)
documents.append(doc.numpy().tolist())
# documents is a list of list of strings, where each string is a story
# From that we generate a ragged tensor
documents = tf.ragged.constant(documents)
# Create a dataset where each row in the ragged tensor would be a
# sample
doc_dataset = tf.data.Dataset.from_tensor_slices(documents)
# We need to perform a quick transformation - tf.strings.ngrams
# would generate all the ngrams (e.g. abcd -> ab, bc, cd) with
# overlap, however for our data we do not need the overlap, so we need
# to skip the overlapping ngrams
# The following line does that
doc_dataset = doc_dataset.map(lambda x: x[::ngram_width])
# Here we are using a window function to generate windows from text
# For a text sequence with window_size 3 and shift 1 you get
# e.g. ab, cd, ef, gh, ij, ... -> [ab, cd, ef], [cd, ef, gh], [ef,
# gh, ij], ...
# each of these windows is a single training sequence for our model
doc_dataset = doc_dataset.flat_map(
lambda x: tf.data.Dataset.from_tensor_slices(
x
).window(
size=window_size+1, shift=int(window_size * 0.75)
).flat_map(
lambda window: window.batch(window_size+1,
drop_remainder=True)
)
)
# From each windowed sequence we generate input and target tuple
# e.g. [ab, cd, ef] -> ([ab, cd], [cd, ef])
doc_dataset = doc_dataset.map(lambda x: (x[:-1], x[1:]))
# Batch the data
doc_dataset = doc_dataset.batch(batch_size=batch_size)
# Shuffle the data if required
doc_dataset = doc_dataset.shuffle(buffer_size=batch_size*10) if
shuffle else doc_dataset
# Return the data
return doc_dataset
现在让我们更详细地讨论上述代码。首先,我们遍历filenames变量中的每个文件,并使用以下方法读取每个文件的内容:
doc = tf.io.read_file(f)
在读取内容后,我们使用tf.strings.ngrams()函数从中生成 n-gram。然而,该函数需要的是字符列表,而不是字符串。
因此,我们使用tf.strings.bytes_split()函数将字符串转换为字符列表。此外,我们还会执行一些预处理步骤,例如:
-
使用
tf.strings.lower()将文本转换为小写 -
将换行符(
\n)替换为空格,以获得一个连续的词流
每个故事都存储在一个列表对象(documents)中。需要注意的是,tf.strings.ngrams()会为给定的 n-gram 长度生成所有可能的 n-grams。换句话说,连续的 n-grams 会有重叠。例如,序列“国王在打猎”如果 n-gram 长度为 2,将生成["Th", "he", "e ", " k", …]。因此,我们稍后需要额外的处理步骤来去除序列中的重叠 n-grams。在所有 n-grams 读取和处理完成后,我们从文档中创建一个RaggedTensor对象:
documents = tf.ragged.constant(documents)
RaggedTensor是一个特殊类型的张量,它可以有接受任意大小输入的维度。例如,几乎不可能所有的故事在每个地方都有相同数量的 n-gram,因为它们彼此之间差异很大。在这种情况下,我们将有任意长的 n-gram 序列来表示我们的故事。因此,我们可以使用RaggedTensor来存储这些任意大小的序列。
tf.RaggedTensor对象是一种特殊类型的张量,可以具有可变大小的维度。你可以在www.tensorflow.org/api_docs/python/tf/RaggedTensor上阅读有关 ragged tensor 的更多信息。有许多方法可以定义一个 ragged tensor。
我们可以通过将包含值的嵌套列表传递给tf.ragged.constant()函数来定义一个 ragged tensor:
a = tf.ragged.constant([[1, 2, 3], [1,2], [1]])
我们还可以定义一个平坦的值序列,并定义在哪里拆分行:
b = tf.RaggedTensor.from_row_splits([1,2,3,4,5,6,7],
row_splits=[0, 3, 3, 6, 7])
在这里,row_splits参数中的每个值定义了结果张量中后续行的结束位置。例如,第一行将包含从索引 0 到 3 的元素(即 0、1、2)。这将输出:
<tf.RaggedTensor [[1, 2, 3], [], [4, 5, 6], [7]]>
你可以使用b.shape获取张量的形状,它将返回:
[4, None]
接下来,我们使用tf.data.Dataset.from_tensor_slices()函数从张量创建一个tf.data.Dataset。
这个函数简单地生成一个数据集,其中数据集中的单个项将是提供的张量的一行。例如,如果你提供一个形状为[10, 8, 6]的标准张量,它将生成 10 个形状为[8, 6]的样本:
doc_dataset = tf.data.Dataset.from_tensor_slices(documents)
在这里,我们仅通过每次取序列中的每个n^(th)个 n-gram 来去除重叠的 n-grams:
doc_dataset = doc_dataset.map(lambda x: x[::ngram_width])
然后,我们将使用tf.data.Dataset.window()函数从每个故事中创建较短的固定长度窗口序列:
doc_dataset = doc_dataset.flat_map(
lambda x: tf.data.Dataset.from_tensor_slices(
x
).window(
size=window_size+1, shift=int(window_size * 0.75)
).flat_map(
lambda window: window.batch(window_size+1,
drop_remainder=True)
)
)
从每个窗口中,我们生成输入和目标对,如下所示。我们将所有 n-gram(除了最后一个)作为输入,将所有 n-gram(除了第一个)作为目标。这样,在每个时间步,模型将根据所有先前的 n-gram 预测下一个 n-gram。shift 决定了在每次迭代时窗口的移动量。记录之间的一些重叠可以确保模型不会将故事视为独立的窗口,这可能导致性能差。我们将保持两个连续序列之间大约 25%的重叠:
doc_dataset = doc_dataset.map(lambda x: (x[:-1], x[1:]))
我们使用tf.data.Dataset.shuffle()对数据进行洗牌,并按预定义的批量大小对数据进行分批。请注意,我们需要为shuffle()函数指定buffer_size。buffer_size决定了洗牌前获取多少数据。你缓存的数据越多,洗牌效果会越好,但内存消耗也会越高:
doc_dataset = doc_dataset.shuffle(buffer_size=batch_size*10) if shuffle else doc_dataset
doc_dataset = doc_dataset.batch(batch_size=batch_size)
最后,我们指定必要的超参数,并生成三个数据集:训练集、验证集和测试集:
ngram_length = 2
batch_size = 256
window_size = 128
train_ds = generate_tf_dataset(train_filenames, ngram_length, window_size, batch_size, shuffle=True)
valid_ds = generate_tf_dataset(valid_filenames, ngram_length, window_size, batch_size)
test_ds = generate_tf_dataset(test_filenames, ngram_length, window_size, batch_size)
让我们生成一些数据,并查看这个函数生成的数据:
ds = generate_tf_dataset(train_filenames, 2, window_size=10, batch_size=1).take(5)
for record in ds:
print(record[0].numpy(), '->', record[1].numpy())
这将返回:
[[b'th' b'er' b'e ' b'wa' b's ' b'on' b'ce' b' u' b'po' b'n ']] -> [[b'er' b'e ' b'wa' b's ' b'on' b'ce' b' u' b'po' b'n ' b'a ']]
[[b' u' b'po' b'n ' b'a ' b'ti' b'me' b' a' b' s' b'he' b'ph']] -> [[b'po' b'n ' b'a ' b'ti' b'me' b' a' b' s' b'he' b'ph' b'er']]
[[b' s' b'he' b'ph' b'er' b'd ' b'bo' b'y ' b'wh' b'os' b'e ']] -> [[b'he' b'ph' b'er' b'd ' b'bo' b'y ' b'wh' b'os' b'e ' b'fa']]
…
在这里,你可以看到目标序列只是将输入序列向右移动一个位置。字符前面的b表示这些字符作为字节存储。接下来,我们将查看如何实现模型。
实现语言模型
在这里,我们将讨论 LSTM 实现的细节。
首先,我们将讨论 LSTM 使用的超参数及其效果。
之后,我们将讨论实现 LSTM 所需的参数(权重和偏置)。然后,我们将讨论这些参数如何用于编写 LSTM 内部发生的操作。接下来,我们将理解如何按顺序将数据传递给 LSTM。接着,我们将讨论如何训练模型。最后,我们将研究如何使用训练好的模型输出预测结果,这些预测结果本质上是 bigrams,最终将构成一个有意义的故事。
定义 TextVectorization 层
我们讨论了TextVectorization层,并在第六章,递归神经网络中使用了它。我们将使用相同的文本向量化机制对文本进行分词。总结来说,TextVectorization层为你提供了一种方便的方式,将文本分词(即将字符串转换为整数 ID 表示的标记列表)集成到模型中作为一个层。
在这里,我们将定义一个TextVectorization层,将 n-gram 序列转换为整数 ID 序列:
import tensorflow.keras.layers as layers
import tensorflow.keras.models as models
# The vectorization layer that will convert string bigrams to IDs
text_vectorizer = tf.keras.layers.TextVectorization(
max_tokens=n_vocab, standardize=None,
split=None, input_shape=(window_size,)
)
请注意,我们正在定义几个重要的参数,例如 max_tokens(词汇表的大小)、standardize 参数(不进行任何文本预处理)、split 参数(不进行任何分割),最后是 input_shape 参数,用于告知该层输入将是一个由 n-gram 序列组成的批次。通过这些参数,我们需要训练文本向量化层,以识别可用的 n-gram 并将其映射到唯一的 ID。我们可以直接将训练好的 tf.data 数据管道传递给该层,让它学习这些 n-gram。
text_vectorizer.adapt(train_ds)
接下来,让我们打印词汇表中的单词,看看这一层学到了什么:
text_vectorizer.get_vocabulary()[:10]
它将输出:
['', '[UNK]', 'e ', 'he', ' t', 'th', 'd ', ' a', ', ', ' h']
一旦 TextVectorization 层训练完成,我们必须稍微修改我们的训练、验证和测试数据管道。请记住,我们的数据管道将 n-gram 字符串序列作为输入和目标输出。我们需要将目标序列转换为 n-gram ID 序列,以便计算损失。为此,我们只需通过 text_vectorizer 层使用 tf.data.Dataset.map() 功能将数据集中的目标传递给该层:
train_ds = train_ds.map(lambda x, y: (x, text_vectorizer(y)))
valid_ds = valid_ds.map(lambda x, y: (x, text_vectorizer(y)))
接下来,我们将查看我们将使用的基于 LSTM 的模型。我们将逐一介绍模型的各个组件,如嵌入层、LSTM 层和最终的预测层。
定义 LSTM 模型。
我们将定义一个简单的基于 LSTM 的模型。我们的模型将包含:
-
之前训练过的
TextVectorization层。 -
一个随机初始化并与模型一起训练的嵌入层。
-
两个 LSTM 层,分别具有 512 和 256 个节点。
-
一个具有 1024 个节点并使用 ReLU 激活函数的全连接隐藏层。
-
最终的预测层具有
n_vocab个节点,并使用softmax激活函数。
由于模型的结构非常简单,层是顺序定义的,因此我们将使用 Sequential API 来定义该模型。
import tensorflow.keras.backend as K
K.clear_session()
lm_model = models.Sequential([
text_vectorizer,
layers.Embedding(n_vocab+2, 96),
layers.LSTM(512, return_state=False, return_sequences=True),
layers.LSTM(256, return_state=False, return_sequences=True),
layers.Dense(1024, activation='relu'),
layers.Dropout(0.5),
layers.Dense(n_vocab, activation='softmax')
])
我们从调用 K.clear_session() 开始,这是一个清除当前 TensorFlow 会话的函数(例如,清除已定义的层、变量及其状态)。否则,如果你在笔记本中多次运行,它将创建不必要的层和变量。此外,让我们更详细地查看 LSTM 层的参数:
-
return_state– 将此设置为False表示该层仅输出最终输出,而如果设置为True,则它将返回状态向量以及该层的最终输出。例如,对于一个 LSTM 层,设置return_state=True会得到三个输出:最终输出、单元状态和隐藏状态。请注意,在这种情况下,最终输出和隐藏状态将是相同的。 -
return_sequences– 将此设置为True将使得该层输出完整的输出序列,而不仅仅是最后一个输出。例如,将其设置为False将得到一个大小为 [b, n] 的输出,其中 b 是批次大小,n 是该层中的节点数。如果设置为True,它将输出一个大小为 [b, t, n] 的输出,其中 t 是时间步数。
你可以通过执行以下命令查看该模型的摘要:
lm_model.summary()
它返回的结果为:
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
text_vectorization (TextVec multiple 0
torization)
embedding (Embedding) (None, 128, 96) 67872
lstm (LSTM) (None, 128, 512) 1247232
lstm_1 (LSTM) (None, 128, 256) 787456
dense (Dense) (None, 128, 1024) 263168
dropout (Dropout) (None, 128, 1024) 0
dense_1 (Dense) (None, 128, 705) 722625
=================================================================
Total params: 3,088,353
Trainable params: 3,088,353
Non-trainable params: 0
_________________________________________________________________
接下来,让我们看看可以用来跟踪模型性能的指标,并最终使用适当的损失函数、优化器和指标来编译模型。
定义指标并编译模型
对于我们的语言模型,我们需要定义一个性能指标,用以展示模型的优劣。我们通常看到准确度作为一种通用的评估指标,广泛应用于不同的机器学习任务。然而,准确度可能不适合这个任务,主要是因为它依赖于模型在给定时间步选择与数据集中完全相同的单词/二元组。而语言是复杂的,给定一段文本,生成下一个单词/二元组可能有多种不同的选择。因此,自然语言处理从业者依赖于一个叫做困惑度的指标,它衡量的是模型在看到 1:t二元组后,对下一个t+1 二元组的“困惑”或“惊讶”程度。
困惑度计算很简单。它只是熵的平方。熵是衡量事件的不确定性或随机性的指标。事件结果越不确定,熵值越高(想了解更多关于熵的信息,请访问machinelearningmastery.com/what-is-information-entropy/)。熵的计算公式为:

在机器学习中,为了优化机器学习模型,我们会衡量给定样本的预测概率分布与目标概率分布之间的差异。为此,我们使用交叉熵,它是熵在两个分布之间的扩展:

最后,我们定义困惑度为:

想了解更多关于交叉熵和困惑度之间关系的信息,请访问thegradient.pub/understanding-evaluation-metrics-for-language-models/。
在 TensorFlow 中,我们定义了一个自定义的tf.keras.metrics.Metric对象来计算困惑度。我们将使用tf.keras.metrics.Mean作为我们的父类,因为它已经知道如何计算和跟踪给定指标的均值:
class PerplexityMetric(tf.keras.metrics.Mean):
def __init__(self, name='perplexity', **kwargs):
super().__init__(name=name, **kwargs)
self.cross_entropy =
tf.keras.losses.SparseCategoricalCrossentropy(
from_logits=False, reduction='none')
def _calculate_perplexity(self, real, pred):
# The next 4 lines zero-out the padding from loss
# calculations, this follows the logic from:
# https://www.tensorflow.org/beta/tutorials/text/transformer#loss_
# and_metrics
loss_ = self.cross_entropy(real, pred)
# Calculating the perplexity steps:
step1 = K.mean(loss_, axis=-1)
perplexity = K.exp(step1)
return perplexity
def update_state(self, y_true, y_pred, sample_weight=None):
perplexity = self._calculate_perplexity(y_true, y_pred)
super().update_state(perplexity)
在这里,我们只是为给定批次的预测和目标计算交叉熵损失,然后将其指数化以获得困惑度。接下来,我们将使用以下命令编译我们的模型:
-
使用稀疏类别交叉熵作为我们的损失函数
-
使用 Adam 作为我们的优化器
-
使用准确度和困惑度作为我们的指标
lm_model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics=['accuracy', PerplexityMetric()])
在这里,困惑度指标将在模型训练和验证过程中被跟踪并打印出来,类似于准确度指标。
训练模型
现在是训练我们模型的时候了。由于我们已经完成了所有需要的繁重工作(例如读取文件、预处理和转换文本,以及编译模型),我们只需要调用模型的fit()函数:
lm_model.fit(train_ds, validation_data=valid_ds, epochs=60)
这里我们将 train_ds(训练数据管道)作为第一个参数,将 valid_ds(验证数据管道)作为 validation_data 参数,并设置训练运行 60 个周期。训练完成后,我们通过简单地调用以下代码来评估模型在测试数据集上的表现:
lm_model.evaluate(test_ds)
这会产生如下输出:
5/5 [==============================] - 0s 45ms/step - loss: 2.4742 - accuracy: 0.3968 - perplexity: 12.3155
你可能会看到度量有所不同,但它应该大致收敛到相同的值。
定义推理模型
在训练过程中,我们训练了模型并对大双字组序列进行了评估。这对我们有效,因为在训练和评估时,我们可以使用完整的文本。然而,当我们需要生成新文本时,我们无法访问任何现有的内容。因此,我们必须对训练模型进行调整,使其能够从零开始生成文本。
我们通过定义一个递归模型来实现这一点,该模型将当前时间步的模型输出作为下一个时间步的输入。通过这种方式,我们可以无限次地预测单词/双字组。我们提供的初始种子是从语料库中随机选取的单词/双字组(或甚至一组双字组)。
图 8.2 展示了推理模型的工作原理。

图 8.2:我们将基于训练模型构建的推理模型的操作视图
我们的推理模型将会更加复杂,因为我们需要设计一个迭代过程,使用先前的预测作为输入生成文本。因此,我们将使用 Keras 的功能性 API 来实现该模型:
# Define inputs to the model
inp = tf.keras.layers.Input(dtype=tf.string, shape=(1,))
inp_state_c_lstm = tf.keras.layers.Input(shape=(512,))
inp_state_h_lstm = tf.keras.layers.Input(shape=(512,))
inp_state_c_lstm_1 = tf.keras.layers.Input(shape=(256,))
inp_state_h_lstm_1 = tf.keras.layers.Input(shape=(256,))
text_vectorized_out = lm_model.get_layer('text_vectorization')(inp)
# Define embedding layer and output
emb_layer = lm_model.get_layer('embedding')
emb_out = emb_layer(text_vectorized_out)
# Defining a LSTM layers and output
lstm_layer = tf.keras.layers.LSTM(512, return_state=True, return_sequences=True)
lstm_out, lstm_state_c, lstm_state_h = lstm_layer(emb_out, initial_state=[inp_state_c_lstm, inp_state_h_lstm])
lstm_1_layer = tf.keras.layers.LSTM(256, return_state=True, return_sequences=True)
lstm_1_out, lstm_1_state_c, lstm_1_state_h = lstm_1_layer(lstm_out, initial_state=[inp_state_c_lstm_1, inp_state_h_lstm_1])
# Defining a Dense layer and output
dense_out = lm_model.get_layer('dense')(lstm_1_out)
# Defining the final Dense layer and output
final_out = lm_model.get_layer('dense_1')(dense_out)
# Copy the weights from the original model
lstm_layer.set_weights(lm_model.get_layer('lstm').get_weights())
lstm_1_layer.set_weights(lm_model.get_layer('lstm_1').get_weights())
# Define final model
infer_model = tf.keras.models.Model(
inputs=[inp, inp_state_c_lstm, inp_state_h_lstm,
inp_state_c_lstm_1, inp_state_h_lstm_1],
outputs=[final_out, lstm_state_c, lstm_state_h, lstm_1_state_c,
lstm_1_state_h])
我们从定义一个输入层开始,该层接收一个时间步长的输入。
请注意,我们正在定义 shape 参数。这意味着它可以接受任意大小的批量数据(只要它具有一个时间步)。我们还定义了其他几个输入,以维持 LSTM 层的状态。这是因为我们必须显式维护 LSTM 层的状态向量,因为我们正在递归地从模型中生成输出:
inp = tf.keras.layers.Input(dtype=tf.string, shape=(1,))
inp_state_c_lstm = tf.keras.layers.Input(shape=(512,))
inp_state_h_lstm = tf.keras.layers.Input(shape=(512,))
inp_state_c_lstm_1 = tf.keras.layers.Input(shape=(256,))
inp_state_h_lstm_1 = tf.keras.layers.Input(shape=(256,))
接下来,我们检索训练好的模型的 text_vectorization 层,并使用它将文本转换为整数 ID:
text_vectorized_out = lm_model.get_layer('text_vectorization')(inp)
然后,我们获取训练模型的嵌入层并使用它来生成嵌入输出:
emb_layer = lm_model.get_layer('embedding')
emb_out = emb_layer(text_vectorized_out)
我们将创建一个全新的 LSTM 层,代表训练模型中的第一个 LSTM 层。这是因为推理 LSTM 层与训练 LSTM 层之间会有一些细微差异。因此,我们将定义新的层,并稍后将训练好的权重复制过来。我们将 return_state 参数设置为 True。通过将其设置为 True,我们在调用该层时将获得三个输出:最终输出、单元状态和最终状态向量。注意,我们还传递了另一个名为 initial_state 的参数。initial_state 需要是一个张量列表:按顺序包括单元状态和最终状态向量。我们将输入层作为这些状态并将在运行时相应地填充它们:
lstm_layer = tf.keras.layers.LSTM(512, return_state=True, return_sequences=True)
lstm_out, lstm_state_c, lstm_state_h = lstm_layer(emb_out, initial_state=[inp_state_c_lstm, inp_state_h_lstm])
同样地,第二层 LSTM 将被定义。我们得到稠密层,并复制在训练模型中找到的全连接层。请注意,最后一层我们没有使用softmax。
这是因为在推理时,softmax只是额外的开销,因为我们只需要输出具有最高输出分数的类(即不需要是概率分布):
# Defining a Dense layer and output
dense_out = lm_model.get_layer('dense')(lstm_1_out)
# Defining the final Dense layer and output
final_out = lm_model.get_layer('dense_1')(dense_out)
不要忘记将训练好的 LSTM 层的权重复制到我们新创建的 LSTM 层:
lstm_layer.set_weights(lm_model.get_layer('lstm').get_weights())
lstm_1_layer.set_weights(lm_model.get_layer('lstm_1').get_weights())
最后,我们定义模型:
infer_model = tf.keras.models.Model(
inputs=[inp, inp_state_c_lstm, inp_state_h_lstm,
inp_state_c_lstm_1, inp_state_h_lstm_1],
outputs=[final_out, lstm_state_c, lstm_state_h, lstm_1_state_c,
lstm_1_state_h])
我们的模型将 1 个二元组作为输入序列,以及两个 LSTM 层的状态向量,输出最终的预测概率和两个 LSTM 层的新状态向量。现在,让我们从模型中生成新文本。
使用模型生成新文本
我们将使用新的推理模型生成一个故事。我们将定义一个初始种子,用来生成故事。这里,我们从一个测试文件的第一句话开始。然后我们通过递归使用预测的二元组在时间t时作为时间t+1 的输入来生成文本。我们将运行 500 步:
text = ["When adam and eve were driven out of paradise, they were compelled to build a house for themselves on barren ground"]
seq = [text[0][i:i+2] for i in range(0, len(text[0]), 2)]
# build up model state using the given string
print("Making predictions from a {} element long input".format(len(seq)))
vocabulary = infer_model.get_layer("text_vectorization").get_vocabulary()
index_word = dict(zip(range(len(vocabulary)), vocabulary))
# Reset the state of the model initially
infer_model.reset_states()
# Defining the initial state as all zeros
state_c = np.zeros(shape=(1,512))
state_h = np.zeros(shape=(1,512))
state_c_1 = np.zeros(shape=(1,256))
state_h_1 = np.zeros(shape=(1,256))
# Recursively update the model by assigning new state to state
for c in seq:
#print(c)
out, state_c, state_h, state_c_1, state_h_1 = infer_model.predict(
[np.array([[c]]), state_c, state_h, state_c_1, state_h_1]
)
# Get final prediction after feeding the input string
wid = int(np.argmax(out[0],axis=-1).ravel())
word = index_word[wid]
text.append(word)
# Define first input to generate text recursively from
x = np.array([[word]])
# Code listing 10.7
for _ in range(500):
# Get the next output and state
out, state_c, state_h, state_c_1, state_h_1 =
infer_model.predict([x, state_c, state_h, state_c_1, state_h_1 ])
# Get the word id and the word from out
out_argsort = np.argsort(out[0], axis=-1).ravel()
wid = int(out_argsort[-1])
word = index_word[wid]
# If the word ends with space, we introduce a bit of randomness
# Essentially pick one of the top 3 outputs for that timestep
# depending on their likelihood
if word.endswith(' '):
if np.random.normal()>0.5:
width = 5
i = np.random.choice(list(range(-width,0)),
p=out_argsort[-width:]/out_argsort[-width:].sum())
wid = int(out_argsort[i])
word = index_word[wid]
# Append the prediction
text.append(word)
# Recursively make the current prediction the next input
x = np.array([[word]])
# Print the final output
print('\n')
print('='*60)
print("Final text: ")
print(''.join(text))
注意我们如何递归地使用变量x、state_c、state_h、state_c_1和state_h_1来生成并分配新值。
out, state_c, state_h, state_c_1, state_h_1 =
infer_model.predict([x, state_c, state_h, state_c_1, state_h_1 ])
此外,我们将使用一个简单的条件来多样化我们生成的输入:
if word.endswith(' '):
if np.random.normal()>0.5:
width = 5
i = np.random.choice(list(range(-width,0)),
p=out_argsort[-width:]/out_argsort[-width:].sum())
wid = int(out_argsort[i])
word = index_word[wid]
本质上,如果预测的二元组以' '字符结尾,我们将随机选择下一个二元组,从前五个二元组中选择。每个二元组将根据其预测的可能性被选中。让我们看看输出文本是什么样的:
When adam and eve were driven out of paradise, they were compelled to build a house for themselves on barren groundy the king's daughter and said, i will so the king's daughter angry this they were and said, "i will so the king's daughter. the king's daughter.' they were to the forest of the stork. then the king's daughters, and they were to the forest of the stork, and, and then they were to the forest. ...
看起来我们的模型能够生成实际的单词和短语,且有意义。接下来,我们将研究从标准 LSTM 生成的文本与其他模型的比较,例如带有窥视连接的 LSTM 和 GRU。
将 LSTM 与带有窥视连接的 LSTM 和 GRU 进行比较
现在,我们将在文本生成任务中将 LSTM 与带有窥视连接的 LSTM 和 GRU 进行比较。这将帮助我们比较不同模型(带窥视连接的 LSTM 和 GRU)在困惑度方面的表现。记住,我们更看重困惑度而不是准确率,因为准确率假设给定一个先前的输入序列时只有一个正确的标记。然而,正如我们所学,语言是复杂的,给定先前的输入,生成文本有很多不同正确的方式。这个内容作为练习可以在ch08_lstms_for_text_generation.ipynb中找到,位于Ch08-Language-Modelling-with-LSTMs文件夹中。
标准 LSTM
首先,我们将重述标准 LSTM 的组件。我们不会重复标准 LSTM 的代码,因为它与我们之前讨论的完全相同。最后,我们将看到一个 LSTM 生成的文本。
回顾
在这里,我们将重新审视标准 LSTM 的结构。如前所述,一个 LSTM 包含以下组件:
-
输入门 – 它决定当前输入有多少被写入到单元状态
-
遗忘门 – 它决定了多少前一个单元状态将写入当前单元状态
-
输出门 – 它决定了多少来自单元状态的信息将暴露到外部隐藏状态中
在图 8.3中,我们展示了每个门、输入、单元状态和外部隐藏状态是如何连接的:

图 8.3:LSTM 单元
门控递归单元(GRU)
在这里,我们将首先简要描述一个 GRU 由哪些部分组成,接着展示实现 GRU 单元的代码。最后,我们来看一些由 GRU 单元生成的代码。
回顾
让我们简要回顾一下 GRU 是什么。GRU 是 LSTM 操作的优雅简化。GRU 对 LSTM 进行了两项不同的修改(见图 8.4):
-
它将内部单元状态和外部隐藏状态连接成一个单一的状态
-
然后它将输入门和遗忘门结合为一个更新门

图 8.4:GRU 单元
GRU 模型采用了比 LSTM 更简单的门控机制。然而,它仍然能够捕获重要的功能,如记忆更新、遗忘等。
模型
在这里,我们将定义一个基于 GRU 的语言模型:
text_vectorizer = tf.keras.layers.TextVectorization(
max_tokens=n_vocab, standardize=None,
split=None, input_shape=(window_size,)
)
# Train the model on existing data
text_vectorizer.adapt(train_ds)
lm_gru_model = models.Sequential([
text_vectorizer,
layers.Embedding(n_vocab+2, 96),
layers.GRU(512, return_sequences=True),
layers.GRU(256, return_sequences=True),
layers.Dense(1024, activation='relu'),
layers.Dropout(0.5),
layers.Dense(n_vocab, activation='softmax')
])
训练代码与我们训练基于 LSTM 的模型时相同。因此,我们在这里不再重复讨论。接下来,我们将看看 LSTM 模型的一个略有不同的变体。
带有窥视连接的 LSTM
在这里,我们将讨论带有窥视连接的 LSTM,以及它们与标准 LSTM 的不同之处。之后,我们将讨论它们的实现。
回顾
现在,让我们简要看一下带有窥视连接的 LSTM。窥视连接本质上是一种让门(输入、遗忘和输出门)直接看到单元状态的方式,而不是等待外部隐藏状态(见图 8.5):

图 8.5:带有窥视连接的 LSTM
代码
请注意,我们使用的是对角线的窥视连接实现。我们发现,非对角线窥视连接(由 Gers 和 Schmidhuber 在他们的论文《时间和计数的递归网络》,《神经网络》,2000中提出)对于这个语言建模任务的表现影响较大,反而更多是有害而非有益。因此,我们使用了不同的变体,它使用了对角线窥视连接,就像 Sak、Senior 和 Beaufays 在他们的论文《大规模声学建模的长短时记忆递归神经网络架构》,《国际语音通信协会年会会议录》中所使用的那样。
幸运的是,我们已经将此技术作为tensorflow_addons中的RNNCell对象进行了实现。因此,我们所需要做的就是将这个PeepholeLSTMCell对象包装在layers.RNN对象中,以生成所需的层。以下是代码实现:
text_vectorizer = tf.keras.layers.TextVectorization(
max_tokens=n_vocab, standardize=None,
split=None, input_shape=(window_size,)
)
# Train the model on existing data
text_vectorizer.adapt(train_ds)
lm_peephole_model = models.Sequential([
text_vectorizer,
layers.Embedding(n_vocab+2, 96),
layers.RNN(
tfa.rnn.PeepholeLSTMCell(512),
return_sequences=True
),
layers.RNN(
tfa.rnn.PeepholeLSTMCell(256),
return_sequences=True
),
layers.Dense(1024, activation='relu'),
layers.Dropout(0.5),
layers.Dense(n_vocab, activation='softmax')
])
现在让我们看看不同模型的训练和验证困惑度,以及它们如何随时间变化。
训练和验证困惑度随时间变化
在 图 8.6 中,我们绘制了 LSTM、带窥视孔的 LSTM 和 GRU 的困惑度随时间变化的行为。我们可以看到,GRU 在性能上明显优于其他模型。这可以归因于 GRU 单元对 LSTM 单元的创新性简化。但看起来 GRU 模型确实会过拟合。因此,使用早停等技术来防止这种行为是非常重要的。我们可以看到,带窥视孔的 LSTM 在性能上并没有给我们带来太多优势。但需要记住的是,我们使用的是一个相对较小的数据集。
对于更大、更复杂的数据集,性能可能会有所不同。我们将把 GRU 单元的实验留给读者,继续讨论 LSTM 模型:

图 8.6:训练数据的困惑度随时间的变化(LSTM、LSTM(窥视孔)和 GRU)
注意
当前文献表明,在 LSTM 和 GRU 之间,没有明显的胜者,很多因素取决于任务本身(参见论文 门控递归神经网络在序列建模中的经验评估,Chung 等人,2014 年 NIPS 深度学习工作坊,2014 年 12 月,arxiv.org/abs/1412.3555)。
在本节中,我们讨论了三种不同的模型:标准 LSTM、GRU 和带窥视孔的 LSTM。
结果清楚地表明,对于这个数据集,GRU 优于其他变体。在下一节中,我们将讨论可以增强序列模型预测能力的技术。
改进序列模型——束搜索
正如我们之前所看到的,生成的文本可以改进。现在,让我们看看我们在 第七章,理解长短期记忆网络 中讨论的束搜索,是否能够帮助提高性能。从语言模型进行预测的标准方法是一次预测一个步骤,并使用前一个时间步的预测结果作为新的输入。在束搜索中,我们会在选择输入之前预测多个步骤。
这使我们能够选择那些单独看可能不那么吸引人的输出序列,但作为一个整体来看会更好。束搜索的工作方式是,在给定的时间,通过预测 m^n 个输出序列或束来进行。m 被称为束宽度,n 是束的深度。每个输出序列(或束)是预测的 n 个二元组,预测到未来。我们通过将束中每个项的单独预测概率相乘来计算每个束的联合概率。然后我们选择具有最高联合概率的束作为该时间步的输出序列。请注意,这是一个贪心搜索,这意味着我们会在树的每个深度计算最佳候选项,并逐步进行,随着树的增长。需要注意的是,这种搜索不会得到全局最优的束。图 8.7 展示了一个例子。我们将用粗体字和箭头标出最佳束候选(及其概率):

图 8.7:一个束搜索示例,展示了在每一步更新束状态的需求。每个单词下方的数字表示该单词被选择的概率。对于非粗体字的单词,你可以认为它们的概率可以忽略不计。
我们可以看到,在第一步中,单词“hunting”具有最高的概率。然而,如果我们执行一个深度为 3 的束搜索,我们得到的序列是 [“king”, “was”, “hunting”],其联合概率为 0.3 * 0.5 * 0.4 = 0.06,作为最佳束。
这个概率高于从单词“hunting”开始的束(它的联合概率为 0.5 * 0.1 * 0.3 = 0.015)。
实现束搜索
我们将束搜索实现为一个递归函数。但首先,我们将实现一个执行递归函数单步操作的函数,称为 beam_one_step()。该函数简单地接受模型、输入和状态(来自 LSTM),并生成输出和新状态。
def beam_one_step(model, input_, states):
""" Perform the model update and output for one step"""
out = model.predict([input_, *states])
output, new_states = out[0], out[1:]
return output, new_states
接下来,我们编写执行束搜索的主要递归函数。该函数接受以下参数:
-
model– 基于推理的语言模型 -
input_– 初始输入 -
states– 初始状态向量 -
beam_depth– 束的搜索深度 -
beam_width– 束搜索的宽度(即在给定深度下考虑的候选词数)
现在让我们讨论这个函数:
def beam_search(model, input_, states, beam_depth=5, beam_width=3):
""" Defines an outer wrapper for the computational function of
beam search """
vocabulary =
infer_model.get_layer("text_vectorization").get_vocabulary()
index_word = dict(zip(range(len(vocabulary)), vocabulary))
def recursive_fn(input_, states, sequence, log_prob, i):
""" This function performs actual recursive computation of the
long string"""
if i == beam_depth:
""" Base case: Terminate the beam search """
results.append((list(sequence), states, np.exp(log_prob)))
return sequence, log_prob, states
else:
""" Recursive case: Keep computing the output using the
previous outputs"""
output, new_states = beam_one_step(model, input_, states)
# Get the top beam_width candidates for the given depth
top_probs, top_ids = tf.nn.top_k(output, k=beam_width)
top_probs, top_ids = top_probs.numpy().ravel(),
top_ids.numpy().ravel()
# For each candidate compute the next prediction
for p, wid in zip(top_probs, top_ids):
new_log_prob = log_prob + np.log(p)
# we are going to penalize joint probability whenever
# the same symbol is repeating
if len(sequence)>0 and wid == sequence[-1]:
new_log_prob = new_log_prob + np.log(1e-1)
sequence.append(wid)
_ = recursive_fn(np.array([[index_word[wid]]]),
new_states, sequence, new_log_prob, i+1)
sequence.pop()
results = []
sequence = []
log_prob = 0.0
recursive_fn(input_, states, sequence, log_prob, 0)
results = sorted(results, key=lambda x: x[2], reverse=True)
return results
beam_search() 函数实际上定义了一个嵌套的递归函数(recursive_fn),每次调用时都会累积输出,并将结果存储在一个名为 results 的列表中。recursive_fn() 做如下操作。如果函数已经被调用了与 beam_depth 相等的次数,那么它会返回当前结果。如果函数调用次数尚未达到预定深度,那么对于给定的深度索引,recursive_fn() 会:
-
使用
beam_one_step()函数计算新的输出和状态 -
获取前两个候选词的 ID 和概率
-
在对数空间中计算每个束的联合概率(在对数空间中,我们可以获得更好的数值稳定性,尤其是对于较小的概率值)
-
最后,我们使用新的输入、新的状态和下一个深度索引调用相同的函数
有了这个,你可以简单地调用 beam_search() 函数,从推理模型中获得预测的束。接下来让我们看看如何实现这一点。
使用束搜索生成文本
在这里,我们只展示我们如何通过迭代调用 beam_search() 来生成新文本的部分。完整的代码请参见 ch08_lstms_for_text_generation.ipynb。
for i in range(50):
print('.', end='')
# Get the results from beam search
result = beam_search(infer_model, x, states, 5, 5)
# Get one of the top 10 results based on their likelihood
n_probs = np.array([p for _,_,p in result[:10]])
p_j = np.random.choice(list(range(n_probs.size)),
p=n_probs/n_probs.sum())
best_beam_ids, states, _ = result[p_j]
x = np.array([[index_word[best_beam_ids[-1]]]])
text.extend([index_word[w] for w in best_beam_ids])
我们简单地调用函数 beam_search(),传入 infer_model、当前输入 x、当前状态 states、beam_depth 和 beam_width,并更新 x 和 states 以反映获胜的束。然后模型将迭代使用获胜的束生成下一个束。
让我们看看 LSTM 在使用束搜索(beam search)时的表现:
When adam and eve were driven out of paradise, they were compelled to build a house for themselves on barren groundr, said the king's daughter went out of the king's son to the king's daughter, and then the king's daughter went into the world, and asked the hedgehog's daughter that the king was about to the forest, and there was on the window, and said, "if you will give her that you have been and said, i will give him the king's daughter, but when she went to the king's sister, and when she was still before the window, and said to himself, and when he said to her father, and that he had nothing and said to hi
这是标准的 LSTM 使用贪婪采样(即一次预测一个词)时的输出:
When adam and eve were driven out of paradise, they were compelled to build a house for themselves on barren groundr, and then this they were all the third began to be able to the forests, and they were. the king's daughter was no one was about to the king's daughter to the forest of them to the stone. then the king's daughter was, and then the king's daughter was nothing-eyes, and the king's daughter was still, and then that had there was about through the third, and the king's daughters was seems to the king's daughter to the forest of them to the stone for them to the forests, and that it was not been to be ables, and the king's daughter wanted to be and said, ...
与 LSTM 生成的文本相比,这段文本似乎有更多的变化,同时保持了语法的一致性。因此,实际上,束搜索(beam search)相比逐字预测能帮助生成更高质量的预测。但仍然有些情况下,词语组合在一起并没有太大意义。让我们看看如何进一步改进我们的 LSTM。
改进 LSTMs —— 使用词汇而非 n-gram 生成文本
在这里,我们将讨论如何改进 LSTM。到目前为止,我们一直使用二元组(bigrams)作为文本的基本单位。但如果使用词汇而非二元组,你将获得更好的结果。这是因为使用词汇可以减少模型的开销,避免需要学习如何从二元组中构建词汇。我们将讨论如何在代码中使用词向量,以便与使用二元组相比,生成更高质量的文本。
维度诅咒
阻止我们将词汇作为 LSTM 输入的一个主要限制是,这将大幅增加模型中的参数数量。让我们通过一个例子来理解这一点。假设我们的输入大小为500,单元状态大小为100。这将导致大约240K的参数数量(不包括 softmax 层),如图所示:

现在我们将输入大小增加到1000。此时,总参数数目将约为440K,如图所示:

如你所见,当输入维度增加 500 单位时,参数的数量增长了 20 万。这不仅增加了计算复杂度,还因大量的参数而增加了过拟合的风险。因此,我们需要一些方法来限制输入的维度。
Word2vec 来拯救我们
如你所记得,Word2vec 不仅能提供比独热编码(one-hot encoding)更低维度的词特征表示,还能提供语义上合理的特征。为了理解这一点,我们来看三个词:cat、dog 和 volcano。如果我们对这三个词进行独热编码,并计算它们之间的欧氏距离,结果会如下:
distance(cat,volcano) = distance(cat,dog)
然而,如果我们学习词嵌入,它将如下所示:
distance(cat,volcano) > distance(cat,dog)
我们希望我们的特征能代表后一种情况,其中相似的东西之间的距离小于不相似的东西。这样,模型将能够生成更高质量的文本。
使用 Word2vec 生成文本
模型的结构基本保持不变,我们所考虑的仅是文本单元的变化。
图 8.8 展示了 LSTM-Word2vec 的总体架构:

图 8.8:使用词向量的语言建模 LSTM 结构
使用词向量时,你有几个选择。你可以:
-
随机初始化词向量,并在任务过程中共同学习它们
-
预先使用词向量算法(例如 Word2vec、GloVe 等)训练嵌入层
-
使用可以自由下载的预训练词向量来初始化嵌入层
注意
下面列出了一些可以自由下载的预训练词向量。通过从包含数十亿单词的文本语料库中学习得到的词向量可以自由下载并使用:
-
Word2vec:
code.google.com/archive/p/word2vec/ -
预训练 GloVe 词向量:
nlp.stanford.edu/projects/glove/ -
fastText 词向量:
github.com/facebookresearch/fastText
我们在这里结束关于语言建模的讨论。
总结
在这一章中,我们研究了 LSTM 算法的实现以及其他各个重要方面,以提升 LSTM 超越标准性能。作为练习,我们在格林兄弟的故事文本上训练了我们的 LSTM,并让 LSTM 输出一个全新的故事。我们讨论了如何通过提取自练习的代码示例来实现一个 LSTM 模型。
接下来,我们进行了关于如何实现带窥视孔的 LSTM 和 GRU 的技术讨论。然后,我们对标准 LSTM 及其变种进行了性能比较。我们发现 GRU 比带窥视孔的 LSTM 和 LSTM 表现更好。
然后我们讨论了提升 LSTM 输出质量的一些改进方法。第一个改进是束搜索。我们查看了束搜索的实现,并逐步介绍了如何实现它。接着,我们研究了如何利用词嵌入来教导 LSTM 输出更好的文本。
总之,LSTM 是非常强大的机器学习模型,能够捕捉长期和短期的依赖关系。
此外,与逐个预测相比,束搜索实际上有助于生成更具现实感的文本短语。
在下一章中,我们将探讨如何使用顺序模型来解决一种更复杂的问题类型,称为序列到序列问题。具体来说,我们将研究如何将机器翻译问题转化为序列到序列问题。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,结识志同道合的人,与超过 1000 名成员一起学习:packt.link/nlp

第九章:序列到序列学习 – 神经机器翻译
序列到序列学习是用于需要将任意长度序列映射到另一个任意长度序列的任务的术语。这是自然语言处理(NLP)中最复杂的任务之一,涉及学习多对多的映射。该任务的例子包括神经机器翻译(NMT)和创建聊天机器人。NMT 是指我们将一个语言(源语言)的句子翻译成另一种语言(目标语言)。谷歌翻译就是一个 NMT 系统的例子。聊天机器人(即能够与人类沟通/回答问题的软件)能够以现实的方式与人类对话。这对于各种服务提供商尤其有用,因为聊天机器人可以用来解答顾客可能遇到的易于解决的问题,而不是将他们转接给人工客服。
在本章中,我们将学习如何实现一个 NMT 系统。然而,在深入探讨这些最新进展之前,我们首先会简要回顾一些统计机器翻译(SMT)方法,这些方法是 NMT 之前的技术,并且在 NMT 赶超之前是当时的先进系统。接下来,我们将逐步讲解构建 NMT 所需的步骤。最后,我们将学习如何实现一个实际的 NMT 系统,从德语翻译到英语,逐步进行。
具体来说,本章将涵盖以下主要主题:
-
机器翻译
-
机器翻译的简短历史回顾
-
理解神经机器翻译
-
准备 NMT 系统的数据
-
定义模型
-
训练 NMT
-
BLEU 分数 – 评估机器翻译系统
-
可视化注意力模式
-
使用 NMT 进行推理
-
Seq2Seq 模型的其他应用 – 聊天机器人
机器翻译
人类常常通过语言彼此交流,相较于其他交流方式(例如,手势)。目前,全球有超过 6,000 种语言在使用。此外,要将一门语言学到能够被该语言的母语者轻松理解的水平,是一项难以掌握的任务。然而,交流对于分享知识、社交和扩大人际网络至关重要。因此,语言成为与世界其他地方的人进行交流的障碍。这就是机器翻译(MT)发挥作用的地方。MT 系统允许用户输入他们自己的语言(称为源语言)的句子,并输出所需目标语言的句子。
MT 的问题可以这样表述:假设我们给定一个句子(或一系列单词)W[s],它属于源语言S,由以下公式定义:

在这里,
。
源语言将被翻译成一个句子!,其中T是目标语言,并由以下公式给出:

在这里,
。
通过机器翻译系统得到的输出如下:

在这里,
是算法为源句子找到的可能翻译候选池。此外,从候选池中选出的最佳候选翻译由以下方程给出:

在这里,
是模型参数。在训练过程中,我们优化模型以最大化一组已知目标翻译的概率,这些目标翻译与对应的源语言翻译(即训练数据)相对应。
到目前为止,我们已经讨论了我们感兴趣的语言翻译问题的正式设置。接下来,我们将回顾机器翻译的历史,了解早期人们是如何尝试解决这一问题的。
机器翻译的简史
在这里,我们将讨论机器翻译的历史。机器翻译的起源涉及基于规则的系统。随后,出现了更多统计学上可靠的机器翻译系统。统计机器翻译(SMT)利用语言的各种统计量来生成目标语言的翻译。随后进入了神经机器翻译(NMT)时代。与其他方法相比,NMT 在大多数机器学习任务中目前保持着最先进的性能。
基于规则的翻译
神经机器翻译(NMT)是在统计机器学习之后很久才出现的,而统计机器学习已经存在超过半个世纪了。统计机器翻译方法的起源可以追溯到 1950-60 年,当时在第一次有记录的项目之一——乔治敦-IBM 实验中,超过 60 个俄语句子被翻译成了英语。为了提供一些背景,这一尝试几乎和晶体管的发明一样久远。
机器翻译的初期技术之一是基于词汇的机器翻译。该系统通过使用双语词典进行逐词翻译。然而,正如你所想的,这种方法有着严重的局限性。显而易见的局限性是,逐词翻译并不是不同语言之间的逐一映射。此外,逐词翻译可能导致不正确的结果,因为它没有考虑到给定单词的上下文。源语言中给定单词的翻译可以根据其使用的上下文而变化。为了通过一个具体的例子来理解这一点,我们来看一下图 9.1中的英法翻译示例。你可以看到,在给定的两个英语句子中,一个单词发生了变化。然而,这种变化导致了翻译的显著不同:

图 9.1:语言之间的翻译(英法)不是逐词映射
在 1960 年代,自动语言处理咨询委员会(ALPAC)发布了一份报告,《语言与机器:计算机在翻译与语言学中的应用》,美国国家科学院(1966),讨论了机器翻译(MT)的前景。结论是:
没有直接或可预测的前景表明机器翻译会变得有用。
这是因为机器翻译(MT)当时比人工翻译更慢、更不准确且更昂贵。这对机器翻译的进展造成了巨大打击,几乎有十年的时间处于沉寂状态。
接下来是基于语料库的机器翻译(MT),其中一个算法通过使用源句子的元组进行训练,并通过平行语料库获得对应的目标句子,即平行语料库的格式为[(<source_sentence_1>, <target_sentence_1>), (<source_sentence_2>, <target_sentence_2>), …]。平行语料库是一个由源语言文本及其对应的翻译组成的元组形式的大型文本语料库。这个示例如表 9.1所示。需要注意的是,构建平行语料库比构建双语词典更容易,而且它们更准确,因为训练数据比逐词训练数据更丰富。此外,基于平行语料库的机器翻译可以建立双语词典(即转移模型),而不直接依赖于人工创建的双语词典。转移模型展示了给定当前源词或短语时,目标词或短语是正确翻译的可能性。除了学习转移模型外,基于语料库的机器翻译还学习了词对齐模型。词对齐模型可以表示源语言中的短语的单词如何与该短语的翻译对应。平行语料库和词对齐模型的示例如图 9.2所示:

图 9.2:两种不同语言之间的词对齐
一个平行语料库的示例如表 9.1所示:
| 源语言句子(英语) | 目标语言句子(法语) |
|---|---|
| I went home | Je suis allé à la maison |
| John likes to play guitar | John aime jouer de la guitare |
| He is from England | Il est d’Angleterre |
| … | …. |
表 9.1:英语和法语句子的平行语料库
另一种方法是跨语言机器翻译,它涉及将源语言句子翻译成一种语言中立的中介语(即元语言),然后从中介语生成翻译后的句子。更具体地说,跨语言机器翻译系统由两个重要组件组成,一个是分析器,另一个是合成器。分析器将获取源语言句子并识别出代理(例如名词)、动作(例如动词)等元素,以及它们之间的相互关系。接下来,这些识别出的元素通过跨语言词汇表进行表示。跨语言词汇表的一个例子可以通过 WordNet 中的同义词集(即共享共同意义的同义词组)来构建。然后,合成器将从这种跨语言表示中生成翻译。由于合成器通过跨语言表示了解名词、动词等,它可以通过结合特定语言的语法规则在目标语言中生成翻译。
统计机器翻译(SMT)
接下来,更多统计上更为合理的系统开始出现。这个时代的先锋模型之一是 IBM 模型 1-5,它进行的是基于单词的翻译。然而,正如我们之前讨论的,单词翻译并不是一一对应的(例如复合词和形态学)。最终,研究人员开始尝试基于短语的翻译系统,这在机器翻译领域取得了一些显著的进展。
基于短语的翻译与基于单词的翻译类似,不同之处在于它使用语言的短语作为翻译的基本单位,而不是单个单词。这是一种更合理的方法,因为它使得建模单词之间的多对一、多对多或一对多关系变得更容易。基于短语的翻译的主要目标是学习一个短语翻译模型,其中包含不同候选目标短语对于给定源短语的概率分布。如你所想,这种方法需要维护两种语言之间大量短语的数据库。由于不同语言之间的句子没有单调的词序,因此还需要对短语进行重新排序。
这一点的例子如图 9.2所示;如果单词在语言之间是单调排序的,单词映射之间就不会有交叉。
这种方法的一个局限性是解码过程(为给定源短语找到最佳目标短语)代价高昂。这是因为短语数据库的庞大,以及一个源短语通常包含多个目标语言短语。为了减轻这一负担,基于语法的翻译应运而生。
在基于语法的翻译中,源句子通过语法树来表示。在图 9.3中,NP表示名词短语,VP表示动词短语,S表示句子。然后进入重排序阶段,在这个阶段,树节点会根据目标语言的需要重新排序,以改变主语、动词和宾语的顺序。这是因为句子结构会根据语言的不同而变化(例如,英语是主语-动词-宾语,而日语是主语-宾语-动词)。重排序是根据一种叫做r 表的东西来决定的。r 表包含了树节点按照某种顺序重排的可能性概率:

图 9.3:一个句子的语法树
然后进入插入阶段。在插入阶段,我们随机地将一个词插入到树的每个节点中。这是由于假设存在一个不可见的NULL词,它会在树的随机位置生成目标词汇。此外,插入一个词的概率由一种叫做n 表的东西决定,它是一个包含将特定词插入到树中的概率的表格。
接下来,进入翻译阶段,在这个阶段,每个叶子节点都按逐词的方式被翻译成目标词。最后,通过读取语法树中的翻译句子,构建目标句子。
神经机器翻译(NMT)
最后,在 2014 年左右,NMT 系统被引入。NMT 是一种端到端的系统,它将整个句子作为输入,进行某些转换,然后输出与源句子对应的翻译句子。
因此,NMT 消除了机器翻译所需的特征工程,例如构建短语翻译模型和构建语法树,这对 NLP 社区来说是一个巨大的胜利。此外,NMT 在非常短的时间内(仅两到三年)超越了所有其他流行的 MT 技术。在图 9.4中,我们展示了 MT 文献中报告的各种 MT 系统的结果。例如,2016 年的结果来自 Sennrich 等人在他们的论文《爱丁堡神经机器翻译系统(WMT 16),计算语言学协会,第一个机器翻译会议论文集,2016 年 8 月:371-376》中的报告,也来自 Williams 等人在他们的论文《爱丁堡统计机器翻译系统(WMT16),计算语言学协会,第一个机器翻译会议论文集,2016 年 8 月:399-410》中的报告。所有 MT 系统都通过 BLEU 分数进行了评估。BLEU 分数表示候选翻译与参考翻译匹配的 n-grams 数量(例如,单字和双字组合)。因此,BLEU 分数越高,MT 系统越好。我们将在本章后面详细讨论 BLEU 指标。不言而喻,NMT 无疑是赢家:

图 9.4:统计机器翻译系统与 NMT 系统的比较。感谢 Rico Sennrich 提供。
一个评估 NMT 系统潜力的案例研究可以在《神经机器翻译准备好部署了吗?30 种翻译方向的案例研究》中找到,作者为 Junczys-Dowmunt、Hoang 和 Dwojak,发表于第九届国际口语语言翻译研讨会,西雅图(2016)。
该研究探讨了不同系统在多种语言之间(英语、阿拉伯语、法语、俄语和中文)的翻译任务中的表现。结果还表明,NMT 系统(NMT 1.2M 和 NMT 2.4M)的表现优于 SMT 系统(PB-SMT 和 Hiero)。
图 9.5显示了 2017 年最先进的机器翻译系统的一些统计数据。这些数据来自 Konstantin Savenkov(Intento 公司联合创始人兼 CEO)制作的演示文稿《机器翻译现状,Intento 公司,2017》。我们可以看到,DeepL(www.deepl.com)所生成的机器翻译性能与其他大型机器翻译系统,包括 Google,表现得非常接近。比较包括了 DeepL(NMT)、Google(NMT)、Yandex(NMT-SMT 混合)、Microsoft(同时拥有 SMT 和 NMT)、IBM(SMT)、Prompt(基于规则)和 SYSTRAN(基于规则/SMT 混合)等机器翻译系统。图表清晰地显示了 NMT 系统目前在机器翻译技术进展中处于领先地位。LEPOR 得分用于评估不同的系统。LEPOR 是一种比 BLEU 更先进的评估指标,它尝试解决语言偏差问题。语言偏差问题指的是一些评估指标(如 BLEU)在某些语言上表现良好,但在其他语言上表现较差。
然而,也应注意,由于在这次比较中使用了平均机制,结果确实存在一定的偏差。例如,Google 翻译是基于一个更大范围的语言集合(包括较难的翻译任务)进行平均的,而 DeepL 则是基于一个较小且相对容易的语言子集进行平均的。因此,我们不应得出结论认为 DeepL 的机器翻译系统总是优于 Google 的机器翻译系统。尽管如此,整体结果仍然为当前的 NMT 和 SMT 系统提供了一个大致的性能对比:

图 9.5:各种机器翻译系统的表现。感谢 Intento 公司提供
我们看到 NMT 在短短几年内已经超过了 SMT 系统,成为当前的最先进技术。接下来,我们将讨论 NMT 系统的细节和架构。最后,我们将从头开始实现一个 NMT 系统。
理解神经机器翻译
现在我们已经理解了机器翻译如何随着时间的推移而发展,让我们尝试理解最先进的 NMT 是如何工作的。首先,我们将看看神经机器翻译模型的架构,然后再深入了解实际的训练算法。
NMT 系统背后的直觉
首先,让我们理解 NMT 系统设计背后的直觉。假设你是一个流利的英语和德语使用者,并且被要求将以下句子翻译成德语:
我回家了
该句的翻译如下:
Ich ging nach Hause
尽管对流利的人来说,翻译这个句子可能只需要几秒钟,但翻译是有一定过程的。首先,你阅读英文句子,然后在脑海中形成一个关于这个句子的思想或概念。最后,你将句子翻译成德语。构建 NMT 系统时使用了相同的思路(见图 9.6)。编码器读取源句子(类似于你阅读英文句子的过程)。然后,编码器输出一个上下文向量(该上下文向量对应你在阅读句子后想象的思想/概念)。最后,解码器接收上下文向量并输出德语翻译:

图 9.6:NMT 系统的概念架构
NMT 架构
现在我们将更详细地看一下架构。序列到序列的方法最初是由 Sutskever、Vinyals 和 Le 在他们的论文《Sequence to Sequence Learning with Neural Networks, Proceedings of the 27th International Conference on Neural Information Processing Systems - Volume 2: 3104-3112.》中提出的。
从图 9.6的示意图中,我们可以看到 NMT 架构中有两个主要组件。它们被称为编码器和解码器。换句话说,NMT 可以看作是一个编码器-解码器架构。编码器将源语言的句子转换为思想向量(即上下文化的表示),而解码器将思想向量解码或翻译为目标语言。正如你所看到的,这与我们简要讨论过的中介语言机器翻译方法有些相似。这个解释在图 9.7中得到了说明。上下文向量的左侧表示编码器(它逐字读取源句子以训练时间序列模型)。右侧表示解码器,它逐字输出(同时使用前一个词作为当前输入)源句子的相应翻译。我们还将使用嵌入层(对于源语言和目标语言),在这些层中,单个词元的语义将被学习并作为输入传递给模型:

图 9.7:源句子和目标句子随时间展开
在对 NMT 的基本理解之后,我们来正式定义 NMT 的目标。NMT 系统的最终目标是最大化对给定源句子 x[s] 及其对应的 y[t] 的对数似然。即,最大化以下内容:

这里,N 指的是我们作为训练数据拥有的源句子和目标句子输入的数量。
然后,在推理过程中,对于给定的源句子,
,我们将使用以下方法找到
翻译:

在这里,
是 i^(th) 时刻的预测标记,
是可能的候选句子集合。
在我们研究 NMT 架构的每个部分之前,让我们先定义一些数学符号,以便更具体地理解这个系统。作为我们的序列模型,我们将选择 门控循环单元 (GRU),因为它比 LSTM 更简单,且表现相对较好。
让我们定义编码器 GRU 为
,解码器 GRU 为
。在时间步长
处,定义一般 GRU 的输出状态为 h[t]。也就是说,将输入 x[t] 输入到 GRU 中会得到 h[t]:

现在,我们将讨论嵌入层、编码器、上下文向量,最后是解码器。
嵌入层
我们已经看到词嵌入的强大功能。在这里,我们也可以利用嵌入来提高模型性能。我们将使用两个词嵌入层,
用于源语言,
用于目标语言。所以,我们将不直接将 x[t] 输入到 GRU 中,而是得到
。然而,为了避免过多的符号表示,我们假设
。
编码器
如前所述,编码器负责生成一个思维向量或上下文向量,表示源语言的含义。为此,我们将使用基于 GRU 的网络(见 图 9.8):

图 9.8:一个 GRU 单元
编码器在时间步长 0 (h[0]) 处用零向量初始化。编码器接受一个词序列,
,作为输入,并计算一个上下文向量,
,其中 v 是处理序列 x[s] 的最后一个元素
后得到的最终外部隐藏状态。我们表示为以下内容:


上下文向量
上下文向量 (v) 的概念是简洁地表示源语言的句子。此外,与编码器的状态初始化方式(即初始化为零)相对,上下文向量成为解码器 GRU 的初始状态。换句话说,解码器 GRU 并非以零向量作为初始状态,而是以上下文向量作为初始状态。这在编码器和解码器之间创建了联系,使整个模型成为端到端可微分的。我们将在接下来详细讨论这一点。
解码器
解码器负责将上下文向量解码为所需的翻译。我们的解码器也是一个 RNN。虽然编码器和解码器可以共享相同的权重集,但通常使用两个不同的网络分别作为编码器和解码器会更好。这增加了我们模型中的参数数量,使我们能够更有效地学习翻译。
首先,解码器的状态通过上下文向量进行初始化,即
,如图所示:
。
在这里,
是解码器的初始状态向量(
)。
这个(v)是连接编码器和解码器,形成端到端计算链的关键链接(见图 9.6,编码器和解码器共享的唯一内容是v)。此外,这是解码器获取源句子的唯一信息。
然后,我们将通过以下公式计算翻译句子的m^(th)个预测结果:


带有 GRU 单元在编码器和解码器之间连接细节,并且使用 softmax 层输出预测结果的完整 NMT 系统,如图 9.9所示:

图 9.9:带有 GRU 的编码器-解码器架构。编码器和解码器都有一个独立的 GRU 组件。此外,解码器还具有一个全连接(密集)层和一个 softmax 层,用于生成最终的预测结果。
在下一节中,我们将介绍为模型准备数据所需的步骤。
为 NMT 系统准备数据
在本节中,我们将了解数据,并学习如何准备数据以进行 NMT 系统的训练和预测。首先,我们将讨论如何准备训练数据(即源句子和目标句子对),以训练 NMT 系统,然后输入给定的源句子以生成该源句子的翻译。
数据集
本章中我们将使用的数据集是来自nlp.stanford.edu/projects/nmt/的 WMT-14 英德翻译数据。大约有 450 万个句子对可用。然而,由于计算可行性,我们只会使用 25 万个句子对。词汇表由最常见的 50,000 个英语单词和最常见的 50,000 个德语单词组成,词汇表中未找到的单词将被特殊标记<unk>替代。你需要下载以下文件:
-
train.de– 包含德语句子的文件 -
train.en– 包含英语句子的文件 -
vocab.50K.de– 包含德语词汇的文件 -
vocab.50K.en– 包含英语词汇的文件
train.de和train.en分别包含德语和英语的平行句子。一旦下载,我们将按照以下方式加载这些句子:
n_sentences = 250000
# Loading English sentences
original_en_sentences = []
with open(os.path.join('data', 'train.en'), 'r', encoding='utf-8') as en_file:
for i,row in enumerate(en_file):
if i >= n_sentences: break
original_en_sentences.append(row.strip().split(" "))
# Loading German sentences
original_de_sentences = []
with open(os.path.join('data', 'train.de'), 'r', encoding='utf-8') as de_file:
for i, row in enumerate(de_file):
if i >= n_sentences: break
original_de_sentences.append(row.strip().split(" "))
如果你打印刚刚加载的数据,对于这两种语言,你会看到如下的句子:
English: a fire restant repair cement for fire places , ovens , open fireplaces etc .
German: feuerfester Reparaturkitt für Feuerungsanlagen , Öfen , offene Feuerstellen etc.
English: Construction and repair of highways and ...
German: Der Bau und die Reparatur der Autostraßen ...
English: An announcement must be commercial character .
German: die Mitteilungen sollen den geschäftlichen kommerziellen Charakter tragen .
添加特殊标记
下一步是向我们的句子开始和结束添加一些特殊标记。我们将添加<s>来标记句子的开始,添加</s>来标记句子的结束。我们可以通过以下列表推导轻松实现这一点:
en_sentences = [["<s>"]+sent+["</s>"] for sent in original_en_sentences]
de_sentences = [["<s>"]+sent+["</s>"] for sent in original_de_sentences]
这将给我们带来:
English: <s> a fire restant repair cement for fire places , ovens , open fireplaces etc . </s>
German: <s> feuerfester Reparaturkitt für Feuerungsanlagen , Öfen , offene Feuerstellen etc. </s>
English: <s> Construction and repair of highways and ... </s>
German: <s> Der Bau und die Reparatur der Autostraßen ... </s>
English: <s> An announcement must be commercial character . </s>
German: <s> die Mitteilungen sollen den geschäftlichen kommerziellen Charakter tragen . </s>
这是 Seq2Seq 模型中非常重要的一步。<s>和</s>标记在模型推理过程中起着极其重要的作用。正如你将看到的,在推理时,我们将使用解码器逐步预测一个单词,通过使用上一步的输出作为输入。这样,我们就可以预测任意数量的时间步。使用<s>作为起始标记使我们能够向解码器发出信号,指示它应开始预测目标语言的标记。接下来,如果我们不使用</s>标记来标记句子的结束,我们就无法向解码器发出结束句子的信号。这可能会导致模型进入无限预测循环。
划分训练、验证和测试数据集
我们需要将数据集拆分成三个部分:训练集、验证集和测试集。具体来说,我们将使用 80%的句子来训练模型,10%作为验证数据,剩下的 10%作为测试数据:
from sklearn.model_selection import train_test_split
train_en_sentences, valid_test_en_sentences, train_de_sentences, valid_test_de_sentences = train_test_split(
np.array(en_sentences), np.array(de_sentences), test_size=0.2
)
valid_en_sentences, valid_de_sentences, test_en_sentences, test_de_sentences = train_test_split(
valid_test_en_sentences, valid_test_de_sentences, test_size=0.5)
为两种语言定义序列长度
我们现在必须理解的一个关键统计数据是,我们的语料库中的句子通常有多长。两种语言的句子长度很可能会有所不同。为了学习这个统计数据,我们将使用 pandas 库,具体方法如下:
pd.Series(train_en_sentences).str.len().describe(percentiles=[0.05, 0.5, 0.95])
在这里,我们首先将train_en_sentences转换为一个pd.Series对象。pd.Series是一个带索引的值序列(数组)。在这里,每个值是属于每个句子的标记列表。调用.str.len()将给我们每个标记列表的长度。最后,describe方法将提供重要的统计数据,如均值、标准差和百分位数。在这里,我们特别请求 5%、50%和 95%的百分位数。
请注意,我们仅使用训练数据进行此计算。如果将验证或测试数据集包括在计算中,我们可能会泄露有关验证和测试数据的信息。因此,最好仅使用训练数据集进行这些计算。
前面的代码结果给我们带来了:
Sequence lengths (English)
count 40000.000000
mean 25.162625
std 13.857748
min 6.000000
5% 9.000000
50% 22.000000
95% 53.000000
max 100.000000
dtype: float64
我们可以通过以下方式获得德语句子的相同信息:
pd.Series(train_de_sentences).str.len().describe(percentiles=[0.05, 0.5, 0.95])
这给我们带来了:
Sequence lengths (German)
count 40000.000000
mean 22.882550
std 12.574325
min 6.000000
5% 9.000000
50% 20.000000
95% 47.000000
max 100.000000
dtype: float64
在这里我们可以看到,95%的英语句子有 53 个标记,而 95%的德语句子有 47 个标记。
填充句子
接下来,我们需要填充我们的句子。为此,我们将使用 Keras 提供的pad_sequences()函数。该函数接受以下参数的值:
-
sequences– 一个字符串/ID 的列表,表示文本语料库。每个文档可以是一个字符串列表或一个整数列表。 -
maxlen– 要填充的最大长度(默认为None) -
dtype– 数据类型(默认为'int32') -
padding– 填充短序列的方向(默认为'pre') -
truncating– 截断长序列的方向(默认为'pre') -
value– 用于填充的值(默认为0.0)
我们将按如下方式使用这个函数:
from tensorflow.keras.preprocessing.sequence import pad_sequences
train_en_sentences_padded = pad_sequences(train_en_sentences, maxlen=n_en_seq_length, value=unk_token, dtype=object, truncating='post', padding='post')
valid_en_sentences_padded = pad_sequences(valid_en_sentences, maxlen=n_en_seq_length, value=unk_token, dtype=object, truncating='post', padding='post')
test_en_sentences_padded = pad_sequences(test_en_sentences, maxlen=n_en_seq_length, value=unk_token, dtype=object, truncating='post', padding='post')
train_de_sentences_padded = pad_sequences(train_de_sentences, maxlen=n_de_seq_length, value=unk_token, dtype=object, truncating='post', padding='post')
valid_de_sentences_padded = pad_sequences(valid_de_sentences, maxlen=n_de_seq_length, value=unk_token, dtype=object, truncating='post', padding='post')
test_de_sentences_padded = pad_sequences(test_de_sentences, maxlen=n_de_seq_length, value=unk_token, dtype=object, truncating='post', padding='post')
我们正在对所有的训练、验证和测试句子进行填充处理,无论是英文还是德文。我们将使用最近找到的序列长度作为填充/截断长度。
反转源语言句子
我们还可以对源语言句子执行一个特殊的技巧。假设我们有一个句子ABC,我们想将其翻译成目标语言中的
。我们将首先反转源语言句子,使得句子ABC被读取为CBA。这意味着,为了将ABC翻译为
,我们需要输入CBA。这种方法显著提高了模型的性能,尤其是当源语言和目标语言共享相同句子结构时(例如,主语-动词-宾语)。
让我们试着理解为什么这有帮助。主要是,它有助于在编码器和解码器之间建立良好的沟通。让我们从前面的例子开始。我们将把源语言句子和目标语言句子连接起来:

如果你计算从A到
或从B到
的距离(即,两个词之间的单词数),它们将是相同的。然而,考虑到反转源句子时的情况,如此处所示:

在这里,A与
非常接近,以此类推。另外,为了构建好的翻译,开始时建立良好的交流非常重要。这个简单的技巧可能有助于 NMT 系统提升其性能。
请注意,反转源句子的步骤是一个主观的预处理步骤。对于某些翻译任务,这可能并不是必要的。例如,如果你的翻译任务是从日语(通常是主语-宾语-动词格式)翻译到菲律宾语(通常是动词-主语-宾语格式),那么反转源句子可能会适得其反,反而带来不利影响。这是因为通过反转日语文本,你会增加目标句子中起始元素(即动词(在日语中))与对应的源语言实体(即动词(在菲律宾语中))之间的距离。
接下来,我们来定义我们的编码器-解码器模型。
定义模型
在这一部分,我们将定义一个端到端的模型。
我们将实现一个基于编码器-解码器的 NMT 模型,并配备附加技术来提升性能。让我们从将字符串标记转换为 ID 开始。
将标记转换为 ID
在我们进入模型之前,还有一个文本处理操作剩下,那就是将处理过的文本标记转换为数字 ID。我们将使用tf.keras.layers.Layer来实现这一点。具体来说,我们将使用StringLookup层在模型中创建一个层,将每个标记转换为数字 ID。第一步,让我们加载数据中提供的词汇表文件。在此之前,我们将定义变量n_vocab来表示每种语言词汇表的大小:
n_vocab = 25000 + 1
最初,每个词汇表包含 50,000 个标记。然而,我们将只取其中的一半,以减少内存需求。请注意,我们允许额外的一个标记,因为有一个特殊的标记<unk>表示超出词汇表(OOV)的单词。使用 50,000 个标记的词汇表,由于我们最终要构建的预测层的大小,内存很容易就会耗尽。在减少词汇表大小的同时,我们必须确保保留最常见的 25,000 个单词。幸运的是,每个词汇表文件的组织方式是按单词出现的频率排序(从高到低)。因此,我们只需从文件中读取前 25,001 行文本:
en_vocabulary = []
with open(os.path.join('data', 'vocab.50K.en'), 'r', encoding='utf-8') as en_file:
for ri, row in enumerate(en_file):
if ri >= n_vocab: break
en_vocabulary.append(row.strip())
然后我们对德语的词汇表做相同的操作:
de_vocabulary = []
with open(os.path.join('data', 'vocab.50K.de'), 'r', encoding='utf-8') as de_file:
for ri, row in enumerate(de_file):
if ri >= n_vocab: break
de_vocabulary.append(row.strip())
每个词汇表的第一行都包含特殊的 OOV 标记<unk>。我们将从en_vocabulary和de_vocabulary列表中移除它,因为在下一步中我们需要它:
en_unk_token = en_vocabulary.pop(0)
de_unk_token = de_vocabulary.pop(0)
下面是我们如何定义英语的StringLookup层:
en_lookup_layer = tf.keras.layers.StringLookup(
vocabulary=en_vocabulary, oov_token=en_unk_token,
mask_token=pad_token, pad_to_max_tokens=False
)
让我们理解传递给这个层的参数:
-
vocabulary– 包含在语料库中找到的单词列表(除了以下将讨论的某些特殊标记) -
oov_token– 一个特殊的超出词汇表(out-of-vocabulary)标记,用于替换词汇表中没有列出的标记 -
mask_token– 一个特殊的标记,用于遮蔽输入(例如,不含信息的填充标记) -
pad_to_max_tokens– 如果需要进行填充,将任意长度的序列调整为数据批次中的相同长度
同样,我们为德语定义一个查找层:
de_lookup_layer = tf.keras.layers.StringLookup(
vocabulary=de_vocabulary, oov_token=de_unk_token,
mask_token=pad_token, pad_to_max_tokens=False
)
在打好基础后,我们可以开始构建编码器。
定义编码器
我们从输入层开始构建编码器。输入层将接受一个包含标记序列的批次。每个标记序列的长度为n_en_seq_length个元素。记住,我们已经填充或截断了句子,确保它们的固定长度为n_en_seq_length:
encoder_input = tf.keras.layers.Input(shape=(n_en_seq_length,), dtype=tf.string)
接下来,我们使用之前定义的StringLookup层将字符串标记转换为词 ID。如我们所见,StringLookup层可以接受一个独特单词的列表(即词汇表),并创建一个查找操作,将给定的标记转换为数字 ID:
encoder_wid_out = en_lookup_layer(encoder_input)
将词元转换为 ID 后,我们将生成的单词 ID 传递给词元嵌入层。我们传入词汇表的大小(从en_lookup_layer的get_vocabulary()方法中获取)和嵌入大小(128),最后我们要求该层对任何零值输入进行掩蔽,因为它们不包含任何信息:
en_full_vocab_size = len(en_lookup_layer.get_vocabulary())
encoder_emb_out = tf.keras.layers.Embedding(en_full_vocab_size, 128, mask_zero=True)(encoder_wid_out)
嵌入层的输出存储在 encoder_emb_out 中。接下来,我们定义一个 GRU 层来处理英文词元嵌入序列:
encoder_gru_out, encoder_gru_last_state = tf.keras.layers.GRU(256, return_sequences=True, return_state=True)(encoder_emb_out)
注意,我们将 return_sequences 和 return_state 参数都设置为 True。总结一下,return_sequences 返回完整的隐藏状态序列作为输出(而不是仅返回最后一个状态),而 return_state 返回模型的最后状态作为额外的输出。我们需要这两个输出才能构建模型的其余部分。例如,我们需要将编码器的最后状态传递给解码器作为初始状态。为此,我们需要编码器的最后状态(存储在 encoder_gru_last_state 中)。我们将在后续详细讨论这个目的。现在我们已经准备好定义模型的编码器部分。它接收一批字符串词元序列,并返回完整的 GRU 隐藏状态序列作为输出。
encoder = tf.keras.models.Model(inputs=encoder_input, outputs=encoder_gru_out)
定义好编码器后,让我们来构建解码器。
定义解码器
我们的解码器将比编码器更复杂。解码器的目标是,给定最后一个编码器状态和解码器预测的前一个词,预测下一个词。例如,对于德语句子:
ich ging zum Laden
我们定义:
| 输入 | ich | ging | zum | Laden | |
|---|---|---|---|---|---|
| 输出 | ich | ging | zum | Laden |
这种技术被称为 教师强制。换句话说,解码器利用目标语言的前一个词元来预测下一个词元。这使得翻译任务对模型来说变得更容易。我们可以通过以下方式理解这一现象。假设老师让幼儿园的学生完成以下句子,只给出第一个词:
I ___ ____ ___ ___ ____ ____
这意味着孩子需要选择主语、动词和宾语;了解语言的语法结构;理解语言的语法规则;等等。因此,孩子生成不正确句子的可能性很高。
然而,如果我们要求孩子逐个单词地生成句子,他们可能更擅长于构造一个完整的句子。换句话说,我们要求孩子在给定以下条件的情况下生成下一个单词:
I ____
然后我们要求他们在给定的情况下填空:
I like ____
然后继续以相同的方式进行:
I like to ___, I like to fly ____, I like to fly kites ____
这样,孩子可以更好地生成一个正确且有意义的句子。我们可以采用相同的方法来减轻翻译任务的难度,如 图 9.10 所示:

图 9.10:教师强制机制。输入中的深色箭头表示新引入的输入连接到解码器。右侧的图显示了解码器 GRU 单元如何变化。
为了解码器输入先前由解码器预测的标记,我们需要为解码器提供一个输入层。当以这种方式构造解码器的输入和输出时,对于长度为 n 的标记序列,输入和输出的长度是 n-1 个标记:
decoder_input = tf.keras.layers.Input(shape=(n_de_seq_length-1,), dtype=tf.string)
接下来,我们使用之前定义的de_lookup_layer将标记转换为 ID:
decoder_wid_out = de_lookup_layer(decoder_input)
类似于编码器,让我们为德语定义一个嵌入层:
de_full_vocab_size = len(de_lookup_layer.get_vocabulary())
decoder_emb_out = tf.keras.layers.Embedding(de_full_vocab_size, 128, mask_zero=True)(decoder_wid_out)
我们在解码器中定义一个 GRU 层,它将接受标记嵌入并生成隐藏输出:
decoder_gru_out = tf.keras.layers.GRU(256, return_sequences=True)(decoder_emb_out, initial_state=encoder_gru_last_state)
请注意,我们将编码器的最后状态传递给 GRU 的call()方法中的一个特殊参数initial_state。这确保了解码器使用编码器的最后状态来初始化其内存。
我们旅程的下一步将带我们走向机器学习中最重要的概念之一——“注意力”。到目前为止,解码器必须依赖编码器的最后状态作为关于源语言的“唯一”输入/信号。这就像要求用一个单词总结一个句子。通常,在这样做时,你会失去很多转换中的意义和信息。注意力缓解了这个问题。
注意力:分析编码器状态
不仅仅依赖编码器的最后状态,注意力使解码器能够分析整个状态输出历史。解码器在每一步的预测中都会这样做,并根据它在该步骤需要生成的内容创建所有状态输出的加权平均值。例如,在翻译 I went to the shop -> ich ging zum Laden 时,在预测单词 ging 时,解码器会更多地关注英文句子的前半部分,而不是后半部分。
多年来,注意力机制有许多不同的实现。正确强调注意力在神经机器翻译(NMT)系统中的重要性是非常重要的。正如你之前所学到的,位于编码器和解码器之间的上下文向量或思想向量是一个性能瓶颈(见图 9.11):

图 9.11:编码器-解码器架构
为了理解为什么这是一个瓶颈,让我们想象一下翻译下面的英文句子:
我去花市买花
这转换为以下内容:
Ich ging zum Blumenmarkt, um Blumen zu kaufen
如果我们要将其压缩成一个固定长度的向量,结果向量需要包含以下内容:
-
关于主语的信息(我)
-
关于动词的信息(买 和 去)
-
关于宾语的信息(花 和 花市)
-
句子中主语、动词和宾语相互作用
通常,上下文向量的大小为 128 或 256 元素。依赖上下文向量来存储所有这些信息,而只使用一个小尺寸的向量是非常不切实际的,而且对于系统来说是一个极其困难的要求。因此,大多数时候,上下文向量未能提供进行良好翻译所需的完整信息。这导致解码器性能不佳,无法以最优方式翻译句子。
更糟糕的是,在解码过程中,上下文向量只能在开始时观察到。此后,解码器 GRU 必须记住上下文向量,直到翻译结束。对于长句子来说,这变得越来越困难。
注意力解决了这个问题。通过注意力机制,解码器将在每个解码时间步获得编码器的完整状态历史。这使得解码器能够访问源句子的丰富表示。此外,注意力机制引入了一个 softmax 层,允许解码器计算过去观察到的编码器状态的加权平均值,并将其作为解码器的上下文向量。这样,解码器就可以在不同的解码步骤中对不同的单词赋予不同的关注权重。
图 9.12 展示了注意力机制的概念性分解:

图 9.12:NMT 中的概念性注意力机制
接下来,让我们看看如何计算注意力。
计算注意力
现在,让我们详细探讨注意力机制的实际实现。为此,我们将使用 Bahdanau 等人论文《通过学习联合对齐和翻译的神经机器翻译》中的 Bahdanau 注意力机制。我们将讨论原始的注意力机制。然而,由于 TensorFlow 的限制,我们将实现一个略有不同的版本。为了与论文保持一致,我们将使用以下符号:
-
编码器的第 j^(th) 个隐藏状态:h[j]
-
第 i^(th) 个目标词:y[i]
-
第 i^(th) 时间步的解码隐藏状态:s[i]
-
上下文向量:c[i]
我们的解码器 GRU 是输入 y[i] 和上一步隐藏状态
的函数。这可以表示如下:

在这里,f 代表用于计算 y[i] 和 s[i-1] 的实际更新规则。通过引入注意力机制,我们为第 i^(th) 解码步骤引入了一个新的时间相关的上下文向量 c[i]。这个 c[i] 向量是所有展开的编码器步骤的隐藏状态的加权平均值。如果第 j^(th) 个单词在翻译第 i^(th) 个目标语言单词时更为重要,那么编码器的第 j^(th) 个隐藏状态将赋予更高的权重。这意味着模型可以学习在什么时间步,哪些单词更为重要,而不考虑两种语言的方向性或对齐不匹配的问题。现在,解码器的 GRU 模型变成了这样:

从概念上讲,注意力机制可以被看作是一个独立的层,如图 9.13所示。正如所示,注意力作为一个层运作。注意力层负责生成解码过程中的第i^(th)时间步的c[i]。
现在让我们来看看如何计算c[i]:

在这里,L是源句子中的单词数量,
是一个标准化权重,表示在计算第i(th)解码器预测时,第*j*(th)编码器隐藏状态的重要性。这个值是通过所谓的能量值计算的。我们将e[ij]表示为编码器在第j(th)位置的能量,用于预测解码器的第*i*(th)位置。e[ij]通过一个小型全连接网络计算,如下所示:

换句话说,
是通过一个多层感知机计算的,该网络的权重是v[a]、W[a]和U[a],而
(解码器的前一个隐藏状态,来自第(i-1)(th)时间步)和*h*[j](编码器的第*j*(th)隐藏输出)是网络的输入。最后,我们使用 softmax 标准化对所有编码器时间步的能量值(即权重)进行标准化计算:

注意力机制如图 9.13所示:

图 9.13:注意力机制
实现注意力机制
如上所述,我们将实现 Bahdanau 注意力的一个稍微不同的变体。这是因为 TensorFlow 目前不支持可以在每个时间步骤上迭代计算的注意力机制,类似于 RNN 的工作方式。因此,我们将把注意力机制与 GRU 模型解耦,并单独计算。我们将把注意力输出与 GRU 层的隐藏输出拼接,并将其输入到最终的预测层。换句话说,我们不是将注意力输出输入到 GRU 模型,而是直接输入到预测层。这在图 9.14中有所示意:

图 9.14:本章中采用的注意力机制
为了实现注意力机制,我们将使用 Keras 的子类化 API。我们将定义一个名为BahdanauAttention的类(该类继承自Layer类),并重写其中的两个函数:
-
__init__()– 定义层的初始化逻辑 -
call()– 定义层的计算逻辑
我们定义的类将如下所示。但不用担心,下面我们将详细讲解这两个函数:
class BahdanauAttention(tf.keras.layers.Layer):
def __init__(self, units):
super().__init__()
# Weights to compute Bahdanau attention
self.Wa = tf.keras.layers.Dense(units, use_bias=False)
self.Ua = tf.keras.layers.Dense(units, use_bias=False)
self.attention =
tf.keras.layers.AdditiveAttention(use_scale=True)
def call(self, query, key, value, mask,
return_attention_scores=False):
# Compute 'Wa.ht'.
wa_query = self.Wa(query)
# Compute 'Ua.hs'.
ua_key = self.Ua(key)
# Compute masks
query_mask = tf.ones(tf.shape(query)[:-1], dtype=bool)
value_mask = mask
# Compute the attention
context_vector, attention_weights = self.attention(
inputs = [wa_query, value, ua_key],
mask=[query_mask, value_mask, value_mask],
return_attention_scores = True,
)
if not return_attention_scores:
return context_vector
else:
return context_vector, attention_weights
首先,我们将查看__init__()函数。
在这里,你可以看到我们定义了三个层:权重矩阵 W_a,权重矩阵 U_a,以及最终的 AdditiveAttention 层,其中包含了我们之前讨论的注意力计算逻辑。AdditiveAttention 层接受查询、值和键。查询是解码器的状态,值和键是所有由编码器产生的状态。
我们很快会更详细地讨论这一层。接下来让我们来看一下call()函数中定义的计算:
def call(self, query, key, value, mask, return_attention_scores=False):
# Compute 'Wa.ht'
wa_query = self.Wa(query)
# Compute 'Ua.hs'
ua_key = self.Ua(key)
# Compute masks
query_mask = tf.ones(tf.shape(query)[:-1], dtype=bool)
value_mask = mask
# Compute the attention
context_vector, attention_weights = self.attention(
inputs = [wa_query, value, ua_key],
mask=[query_mask, value_mask, value_mask],
return_attention_scores = True,
)
if not return_attention_scores:
return context_vector
else:
return context_vector, attention_weights
首先需要注意的是,这个函数接受查询、键和值这三个输入。这三个元素将驱动注意力计算。在 Bahdanau 注意力中,你可以将键和值看作是相同的东西。查询将代表每个解码器 GRU 在每个时间步的隐藏状态,值(或键)将代表每个编码器 GRU 在每个时间步的隐藏状态。换句话说,我们正在根据编码器的隐藏状态提供的值,为每个解码器位置查询一个输出。
让我们回顾一下我们需要执行的计算:



首先我们计算 wa_query(代表
)和 ua_key(代表
)。接着,我们将这些值传递到注意力层。AdditiveAttention 层(www.tensorflow.org/api_docs/python/tf/keras/layers/AdditiveAttention)执行以下步骤:
-
将
wa_query从[batch_size, Tq, dim]形状重塑为[batch_size, Tq, 1, dim],并将ua_key从[batch_size, Tv, dim]形状重塑为[batch_size, 1, Tv, dim]。 -
计算形状为
[batch_size, Tq, Tv]的分数:scores = tf.reduce_sum(tf.tanh(query + key), axis=-1)。 -
使用分数来计算一个形状为
[batch_size, Tq, Tv]的分布,并通过 softmax 激活函数计算:distribution = tf.nn.softmax(scores)。 -
使用
distribution来创建一个形状为[batch_size, Tq, dim]的值的线性组合。 -
返回
tf.matmul(distribution, value),它代表了所有编码器状态(即value)的加权平均值。
在这里,你可以看到步骤 2 执行了第一个方程,步骤 3 执行了第二个方程,最后步骤 4 执行了第三个方程。另一个值得注意的事项是,步骤 2 并没有提到来自第一个方程的
。
本质上是一个权重矩阵,我们用它来计算点积。我们可以通过在定义 AdditiveAttention 层时设置 use_scale=True 来引入这个权重矩阵:
self.attention = tf.keras.layers.AdditiveAttention(use_scale=True)
另一个重要的参数是 return_attention_scores,当调用 AdditiveAttention 层时,该参数给我们提供了步骤 3中定义的分布权重矩阵。我们将使用它来可视化模型在解码翻译时关注的部分。
定义最终模型
在理解并实现了注意力机制后,让我们继续实现解码器。我们将获得每个时间步的注意力输出序列,每个时间步有一个被关注的输出。
此外,我们还会得到注意力权重分布矩阵,用于可视化注意力模式在输入和输出之间的分布:
decoder_attn_out, attn_weights = BahdanauAttention(256)(
query=decoder_gru_out, key=encoder_gru_out, value=encoder_gru_out,
mask=(encoder_wid_out != 0),
return_attention_scores=True
)
在定义注意力时,我们还会传递一个掩码,表示在计算输出时需要忽略哪些标记(例如,填充的标记)。将注意力输出与解码器的 GRU 输出结合,创建一个单一的拼接输入供预测层使用:
context_and_rnn_output = tf.keras.layers.Concatenate(axis=-1)([decoder_attn_out, decoder_gru_out])
最后,预测层将拼接后的注意力上下文向量和 GRU 输出结合起来,生成每个时间步长的德语标记的概率分布:
# Final prediction layer (size of the vocabulary)
decoder_out = tf.keras.layers.Dense(full_de_vocab_size, activation='softmax')(context_and_rnn_output)
在完全定义编码器和解码器后,我们来定义端到端模型:
seq2seq_model = tf.keras.models.Model(inputs=[encoder.inputs, decoder_input], outputs=decoder_out)
seq2seq_model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics='accuracy')
我们还将定义一个名为attention_visualizer的辅助模型:
attention_visualizer = tf.keras.models.Model(inputs=[encoder.inputs, decoder_input], outputs=[attn_weights, decoder_out])
attention_visualizer可以为给定的输入集生成注意力模式。这是一种便捷的方式,能够判断模型在解码过程中是否关注了正确的词语。此可视化模型将在完整模型训练后使用。接下来,我们将探讨如何训练我们的模型。
训练 NMT
现在我们已经定义了 NMT 架构并预处理了训练数据,训练模型变得相当直接。在这里,我们将定义并展示(见图 9.15)用于训练的确切过程:

图 9.15:NMT 的训练过程
对于模型训练,我们将定义一个自定义的训练循环,因为有一个特殊的度量我们想要跟踪。不幸的是,这个度量并不是一个现成的 TensorFlow 度量。但是在此之前,我们需要定义几个工具函数:
def prepare_data(de_lookup_layer, train_xy, valid_xy, test_xy):
""" Create a data dictionary from the dataframes containing data
"""
data_dict = {}
for label, data_xy in zip(['train', 'valid', 'test'], [train_xy,
valid_xy, test_xy]):
data_x, data_y = data_xy
en_inputs = data_x
de_inputs = data_y[:,:-1]
de_labels = de_lookup_layer(data_y[:,1:]).numpy()
data_dict[label] = {'encoder_inputs': en_inputs,
'decoder_inputs': de_inputs, 'decoder_labels': de_labels}
return data_dict
prepare_data()函数接受源句子和目标句子对,并生成编码器输入、解码器输入和解码器标签。让我们了解一下这些参数:
-
de_lookup_layer– 德语语言的StringLookup层 -
train_xy– 包含训练集中标记化的英语句子和标记化的德语句子的元组 -
valid_xy– 与train_xy类似,但用于验证数据 -
test_xy– 与train_xy类似,但用于测试数据
对于每个训练、验证和测试数据集,此函数会生成以下内容:
-
encoder_inputs– 经过分词处理的英语句子,来自预处理的数据集 -
decoder_inputs– 每个德语句子的所有标记,除去最后一个标记 -
decoder_labels– 每个德语句子的所有标记 ID,除去第一个标记 ID,标记 ID 由de_lookup_layer生成
所以,你可以看到decoder_labels将是decoder_inputs向左移动一个标记。接下来,我们定义shuffle_data()函数,用于打乱提供的数据集:
def shuffle_data(en_inputs, de_inputs, de_labels, shuffle_inds=None):
""" Shuffle the data randomly (but all of inputs and labels at
ones)"""
if shuffle_inds is None:
# If shuffle_inds are not passed create a shuffling
automatically
shuffle_inds =
np.random.permutation(np.arange(en_inputs.shape[0]))
else:
# Shuffle the provided shuffle_inds
shuffle_inds = np.random.permutation(shuffle_inds)
# Return shuffled data
return (en_inputs[shuffle_inds], de_inputs[shuffle_inds],
de_labels[shuffle_inds]), shuffle_inds
这里的逻辑非常简单。我们使用encoder_inputs、decoder_inputs和decoder_labels(由prepare_data()步骤生成)以及shuffle_inds。如果shuffle_inds为None,则生成索引的随机排列。否则,我们生成提供的shuffle_inds的随机排列。最后,我们根据洗牌后的索引对所有数据进行索引。然后我们就可以训练模型:
Def train_model(model, en_lookup_layer, de_lookup_layer, train_xy, valid_xy, test_xy, epochs, batch_size, shuffle=True, predict_bleu_at_training=False):
""" Training the model and evaluating on validation/test sets """
# Define the metric
bleu_metric = BLEUMetric(de_vocabulary)
# Define the data
data_dict = prepare_data(de_lookup_layer, train_xy, valid_xy,
test_xy)
shuffle_inds = None
for epoch in range(epochs):
# Reset metric logs every epoch
if predict_bleu_at_training:
blue_log = []
accuracy_log = []
loss_log = []
# ========================================================== #
# Train Phase #
# ========================================================== #
# Shuffle data at the beginning of every epoch
if shuffle:
(en_inputs_raw,de_inputs_raw,de_labels), shuffle_inds =
shuffle_data(
data_dict['train']['encoder_inputs'],
data_dict['train']['decoder_inputs'],
data_dict['train']['decoder_labels'],
shuffle_inds
)
else:
(en_inputs_raw,de_inputs_raw,de_labels) = (
data_dict['train']['encoder_inputs'],
data_dict['train']['decoder_inputs'],
data_dict['train']['decoder_labels'],
)
# Get the number of training batches
n_train_batches = en_inputs_raw.shape[0]//batch_size
prev_loss = None
# Train one batch at a time
for i in range(n_train_batches):
# Status update
print("Training batch {}/{}".format(i+1, n_train_batches),
end='\r')
# Get a batch of inputs (english and german sequences)
x = [en_inputs_raw[i*batch_size:(i+1)*batch_size],
de_inputs_raw[i*batch_size:(i+1)*batch_size]]
# Get a batch of targets (german sequences offset by 1)
y = de_labels[i*batch_size:(i+1)*batch_size]
loss, accuracy = model.evaluate(x, y, verbose=0)
# Check if any samples are causing NaNs
check_for_nans(loss, model, en_lookup_layer,
de_lookup_layer)
# Train for a single step
model.train_on_batch(x, y)
# Update the epoch's log records of the metrics
loss_log.append(loss)
accuracy_log.append(accuracy)
if predict_bleu_at_training:
# Get the final prediction to compute BLEU
pred_y = model.predict(x)
bleu_log.append(bleu_metric.calculate_bleu_from_
predictions(y, pred_y))
print("")
print("\nEpoch {}/{}".format(epoch+1, epochs))
if predict_bleu_at_training:
print(f"\t(train) loss: {np.mean(loss_log)} - accuracy:
{np.mean(accuracy_log)} - bleu: {np.mean(bleu_log)}")
else:
print(f"\t(train) loss: {np.mean(loss_log)} - accuracy:
{np.mean(accuracy_log)}")
# ========================================================== #
# Validation Phase #
# ========================================================== #
val_en_inputs = data_dict['valid']['encoder_inputs']
val_de_inputs = data_dict['valid']['decoder_inputs']
val_de_labels = data_dict['valid']['decoder_labels']
val_loss, val_accuracy, val_bleu = evaluate_model(
model, de_lookup_layer, val_en_inputs, val_de_inputs,
val_de_labels, batch_size
)
# Print the evaluation metrics of each epoch
print("\t(valid) loss: {} - accuracy: {} - bleu:
{}".format(val_loss, val_accuracy, val_bleu))
# ============================================================== #
# Test Phase #
# ============================================================== #
test_en_inputs = data_dict['test']['encoder_inputs']
test_de_inputs = data_dict['test']['decoder_inputs']
test_de_labels = data_dict['test']['decoder_labels']
test_loss, test_accuracy, test_bleu = evaluate_model(
model, de_lookup_layer, test_en_inputs, test_de_inputs,
test_de_labels, batch_size
)
print("\n(test) loss: {} - accuracy: {} - bleu:
{}".format(test_loss, test_accuracy, test_bleu))
在模型训练过程中,我们执行以下操作:
-
使用
prepare_data()函数准备编码器和解码器输入以及解码器输出 -
对于每个周期:
-
如果标志
shuffle设置为True,则需要对数据进行洗牌 -
对于每次迭代:
-
从准备好的输入和输出中获取一个批次的数据
-
使用
model.evaluate评估该批次,以获取损失和准确率 -
检查是否有样本返回
nan值(这对于调试很有用) -
在批次数据上进行训练
-
如果标志
predict_bleu_at_training设置为True,则计算 BLEU 分数
-
-
在验证数据上评估模型,以获取验证损失和准确率
-
计算验证数据集的 BLEU 分数
-
-
计算测试数据上的损失、准确率和 BLEU 分数
您可以看到,我们正在计算一个新的度量标准,称为 BLEU 分数。BLEU 是一种专门用于衡量序列到序列问题表现的指标。它试图最大化 n-gram 标记的正确性,而不是单独标记的准确性(例如,准确率)。BLEU 分数越高,效果越好。您将在下一部分了解更多关于 BLEU 分数如何计算的信息。您可以在代码中查看BLEUMetric对象定义的逻辑。
在这一步,我们主要进行文本预处理,去除无意义的标记,以避免 BLEU 分数被高估。例如,如果我们包括<pad>标记,您会看到高的 BLEU 分数,因为短句中会有长的<pad>标记序列。为了计算 BLEU 分数,我们将使用一个第三方实现,链接地址为:github.com/tensorflow/nmt/blob/master/nmt/scripts/bleu.py。
注意
如果批次大小较大,您可能会看到 TensorFlow 抛出如下异常:
Resource exhausted: OOM when allocating tensor with ...
在这种情况下,您可能需要重启笔记本内核、减少批次大小,并重新运行代码。
另外,我们还会做一件事,但尚未讨论,那就是检查是否存在NaN(即不是数字)值。看到损失值在训练周期结束时变为NaN是非常令人沮丧的。这是通过使用check_for_nan()函数完成的。该函数会打印出导致NaN值的具体数据点,这样您就能更清楚地了解原因。您可以在代码中找到check_for_nan()函数的实现。
注意
到了 2021 年,德语到英语的当前最先进的 BLEU 分数为 35.14 (paperswithcode.com/sota/machine-translation-on-wmt2014-english-german)。
一旦模型完全训练完成,你应该会看到验证和测试数据的 BLEU 分数大约为 15。考虑到我们使用的数据比例非常小(即 250,000 个句子,来自 400 多万句子),而且与最先进的模型相比我们使用的模型较为简单,这个分数已经相当不错了。
提高 NMT 性能的深度 GRU
一个显而易见的改进是增加层数,通过将 GRU 层堆叠起来,从而创建一个深度 GRU。例如,Google 的 NMT 系统使用了八层堆叠的 LSTM 层 (Google 的神经机器翻译系统:弥合人类与机器翻译之间的差距,Wu 等人,技术报告(2016 年))。尽管这会影响计算效率,但更多的层大大提高了神经网络学习两种语言语法和其他语言特征的能力。
接下来,让我们详细了解如何计算 BLEU 分数。
BLEU 分数 – 评估机器翻译系统
BLEU 代表 Bilingual Evaluation Understudy,是一种自动评估机器翻译系统的方法。该指标最早在论文 BLEU: A Method for Automatic Evaluation of Machine Translation, Papineni 等人, 第 40 届计算语言学协会年会论文集 (ACL),费城,2002 年 7 月: 311-318 中提出。我们将使用在 github.com/tensorflow/nmt/blob/master/nmt/scripts/bleu.py 上找到的 BLEU 分数实现。让我们了解在机器翻译的上下文中如何计算这个分数。
让我们通过一个例子来学习如何计算 BLEU 分数。假设我们有两个候选句子(即由我们的机器翻译系统预测的句子)和一个参考句子(即对应的实际翻译),用于某个给定的源句子:
-
参考 1:猫坐在垫子上
-
候选句子 1:猫在垫子上
为了评估翻译的质量,我们可以使用一个度量标准,精确度。精确度是指候选翻译中有多少词语实际上出现在参考翻译中。通常情况下,如果你考虑一个有两个类别(分别为负类和正类)的分类问题,精确度的计算公式如下:

现在让我们计算候选句子 1 的精确度:
精确度 = 候选词语在参考中出现的次数 / 候选中的词语总数
数学上,这可以通过以下公式表示:

候选句子 1 的精确度 = 5/6
这也被称为 1-gram 精确度,因为我们一次只考虑一个单词。
现在让我们引入一个新的候选项:
- 候选项 2:The the the cat cat cat
人类不难看出候选项 1 比候选项 2 要好得多。让我们计算精度:
候选项 2 的精度 = 6/6 = 1
如我们所见,精度分数与我们做出的判断不一致。因此,仅依靠精度不能作为翻译质量的一个可靠衡量标准。
修改后的精度
为了解决精度的局限性,我们可以使用修改后的 1-gram 精度。修改后的精度通过参考中该单词出现的次数来裁剪候选句子中每个唯一单词的出现次数:

因此,对于候选项 1 和 2,修改后的精度如下:
Mod-1-gram-Precision 候选项 1 = (1 + 1 + 1 + 1 + 1)/ 6 = 5/6
Mod-1-gram-Precision 候选项 2 = (2 + 1) / 6 = 3/6
我们已经可以看到,这是一个很好的修改,因为候选项 2 的精度已经降低。这可以扩展到任何 n-gram,通过考虑一次n个单词,而不是单个单词。
简洁度惩罚
精度自然偏向短句子。这在评估中引发了一个问题,因为机器翻译系统可能会为较长的参考句子生成较短的句子,并且仍然具有更高的精度。因此,引入了简洁度惩罚来避免这种情况。简洁度惩罚按以下方式计算:

这里,c是候选句子的长度,r是参考句子的长度。在我们的例子中,我们按如下方式进行计算:
-
候选项 1 的简洁度惩罚 =
![]()
-
候选项 2 的简洁度惩罚 =
![]()
最终的 BLEU 得分
接下来,为了计算 BLEU 得分,我们首先计算不同n=1,2,…,N值的几个修改后的 n-gram 精度。然后,我们将计算 n-gram 精度的加权几何平均值:

这里,w[n]是修改后的 n-gram 精度p[n]的权重。默认情况下,所有 n-gram 值使用相等的权重。总之,BLEU 计算修改后的 n-gram 精度,并通过简洁度惩罚来惩罚修改后的 n-gram 精度。修改后的 n-gram 精度避免了给无意义句子(例如候选项 2)赋予潜在的高精度值。
可视化注意力模式
记得我们专门定义了一个叫做attention_visualizer的模型来生成注意力矩阵吗?在模型训练完成后,我们现在可以通过向模型输入数据来查看这些注意力模式。下面是模型的定义:
attention_visualizer = tf.keras.models.Model(inputs=[encoder.inputs, decoder_input], outputs=[attn_weights, decoder_out])
我们还将定义一个函数,以获取处理后的注意力矩阵以及标签数据,方便我们直接用于可视化:
def get_attention_matrix_for_sampled_data(attention_model, target_lookup_layer, test_xy, n_samples=5):
test_x, test_y = test_xy
rand_ids = np.random.randint(0, len(test_xy[0]),
size=(n_samples,))
results = []
for rid in rand_ids:
en_input = test_x[rid:rid+1]
de_input = test_y[rid:rid+1,:-1]
attn_weights, predictions = attention_model.predict([en_input,
de_input])
predicted_word_ids = np.argmax(predictions, axis=-1).ravel()
predicted_words = [target_lookup_layer.get_vocabulary()[wid]
for wid in predicted_word_ids]
clean_en_input = []
en_start_i = 0
for i, w in enumerate(en_input.ravel()):
if w=='<pad>':
en_start_i = i+1
continue
clean_en_input.append(w)
if w=='</s>': break
clean_predicted_words = []
for w in predicted_words:
clean_predicted_words.append(w)
if w=='</s>': break
results.append(
{
"attention_weights": attn_weights[
0,:len(clean_predicted_words),en_start_i:en_start_
i+len(clean_en_input)
],
"input_words": clean_en_input,
"predicted_words": clean_predicted_words
}
)
return results
该函数执行以下操作:
-
随机从测试数据中抽取
n_samples个索引。 -
对于每个随机索引:
-
获取该索引处数据点的输入(
en_input和de_input) -
通过将
en_input和de_input输入到attention_visualizer中(存储在predicted_words中),获取预测词 -
清理
en_input,移除任何无信息的标记(例如<pad>),并将其分配给clean_en_input -
清理
predicted_words,通过移除</s>标记之后的词(存储在clean_predicted_words中) -
仅获取与清理后的输入和预测词对应的注意力权重,来源于
attn_weights -
将
clean_en_input、clean_predicted_words和注意力权重矩阵附加到结果中
-
结果包含了我们需要的所有信息,以便可视化注意力模式。你可以在笔记本Ch09-Seq2seq-Models/ch09_seq2seq.ipynb中看到用于创建以下可视化效果的实际代码。
让我们从测试数据集中选取几个样本,并可视化模型展现的注意力模式(图 9.16):


图 9.16:可视化几个测试输入的注意力模式
总的来说,我们希望看到一个热图,具有大致对角线方向的能量激活。这是因为两种语言在语言结构的方向上具有相似之处。我们可以清楚地看到这一点,在这两个示例中都能看到。
看第一个示例中的特定单词,你可以看到模型在预测Abends时,特别关注evening,在预测Ambiente时,特别关注atmosphere,依此类推。第二个示例中,模型特别关注单词free来预测kostenlosen,这是德语中free的意思。
接下来,我们讨论如何从训练模型中推断翻译。
使用神经机器翻译(NMT)进行推理
推理过程与 NMT 的训练过程略有不同(图 9.17)。由于在推理时没有目标句子,我们需要一种方法在编码阶段结束时触发解码器。这个过程并不复杂,因为我们已经在数据中为此做好了准备。我们只需通过使用<s>作为解码器的第一个输入来启动解码器。然后,我们通过使用预测词作为下一时间步的输入,递归地调用解码器。我们以这种方式继续,直到模型:
-
输出
</s>作为预测的标记,或者 -
达到预定义的句子长度
为此,我们必须使用训练模型的现有权重定义一个新的模型。这是因为我们的训练模型设计为一次处理一系列解码器输入。我们需要一个机制来递归地调用解码器。以下是如何定义推理模型:
-
定义一个编码器模型,该模型输出编码器的隐藏状态序列和最后的编码器状态。
-
定义一个新的解码器,接受具有时间维度为 1 的解码器输入,并输入一个新的值,我们将把解码器的上一个隐藏状态值(由编码器的最后状态初始化)输入到其中。
这样,我们就可以开始输入数据,生成如下预测:
-
预处理x[s],就像在数据处理中的做法
-
将x[s]输入
,并计算编码器的状态序列和最后的状态h,条件化在x[s]上。 -
使用h初始化
![]()
-
对于初始预测步骤,通过将预测条件化在
作为第一个词和h上来预测![]()
-
对于后续时间步,当
和预测尚未达到预定义的长度阈值时,通过将预测条件化在
和h上来预测![]()
这将生成给定输入文本序列的翻译:

图 9.17:从 NMT 推断
实际代码可以在笔记本Ch09-Seq2seq-Models/ch09_seq2seq.ipynb中找到。我们将留给读者去研究代码并理解实现。我们将在这里结束关于机器翻译的讨论。接下来,我们简要地看看序列到序列学习的另一个应用。
Seq2Seq 模型的其他应用 – 聊天机器人
另一种流行的序列到序列模型应用是创建聊天机器人。聊天机器人是一种能够与人类进行真实对话的计算机程序。这类应用对于拥有庞大客户群体的公司非常有用。应对客户提出的一些显而易见的基础问题,占据了客户支持请求的很大一部分。当聊天机器人能够找到答案时,它可以为客户解答这些基础问题。此外,如果聊天机器人无法回答某个问题,用户的请求会被转发给人工操作员。聊天机器人可以节省人工操作员回答基础问题的时间,让他们专注于更难处理的任务。
训练聊天机器人
那么,我们如何使用序列到序列(sequence-to-sequence)模型来训练一个聊天机器人呢?答案其实非常直接,因为我们已经学习过机器翻译模型。唯一的区别在于源句子和目标句子对的形成方式。
在 NMT 系统中,句子对由源句子和该句子在目标语言中的相应翻译组成。然而,在训练聊天机器人时,数据是从两个人之间的对话中提取的。源句子将是 A 人物所说的句子/短语,目标句子则是 B 人物对 A 人物所作出的回复。可以用于此目的的一个数据集是电影对白数据集,包含了人们之间的对话,可以在www.cs.cornell.edu/~cristian/Cornell_Movie-Dialogs_Corpus.html找到。
这里有一些其他数据集的链接,用于训练对话型聊天机器人:
-
Reddit 评论数据集:
www.reddit.com/r/datasets/comments/3bxlg7/i_have_every_publicly_available_reddit_comment/ -
Maluuba 对话数据集:
datasets.maluuba.com/Frames -
Ubuntu 对话语料库:
dataset.cs.mcgill.ca/ubuntu-corpus-1.0/ -
NIPS 对话智能挑战:
convai.io/ -
Microsoft Research 社交媒体文本语料库:
tinyurl.com/y7ha9rc5
图 9.18 显示了聊天机器人系统与神经机器翻译系统的相似性。例如,我们使用由两人对话组成的数据集来训练聊天机器人。编码器接收一个人说的句子/短语,解码器被训练以预测另一个人的回应。通过这种方式训练后,我们可以使用聊天机器人来回答给定的问题:

图 9.18: 聊天机器人示意图
评估聊天机器人 – 图灵测试
构建聊天机器人后,评估其效果的一种方法是使用图灵测试。图灵测试由艾伦·图灵在上世纪 50 年代发明,用于衡量机器的智能程度。实验设置非常适合评估聊天机器人。实验设置如下:
有三个参与方: 一个评估者(即人类)(A), 另一个人类(B), 和一个机器人(C). 他们三个坐在三个不同的房间里,以便彼此互不见面。他们唯一的交流媒介是文本,一方将文本输入计算机,接收方在自己的计算机上看到文本。评估者与人类和机器人进行交流。在对话结束时,评估者需要区分机器人和人类。如果评估者无法做出区分,机器人被视为通过了图灵测试。这个设置如图 9.19 所示:

图 9.19: 图灵测试
这部分介绍了 Seq2Seq 模型的其他应用。我们简要讨论了创建聊天机器人的应用,这是序列模型的一种流行用途。
总结
在这一章中,我们详细讨论了 NMT 系统。机器翻译是将给定的文本语料从源语言翻译到目标语言的任务。首先,我们简要回顾了机器翻译的历史,以便培养对机器翻译发展的理解,帮助我们认识到它今天的成就。我们看到,今天表现最好的机器翻译系统实际上是 NMT 系统。接下来,我们解决了从英语到德语翻译的 NMT 任务。我们讨论了数据预处理工作,包括提取数据的重要统计信息(例如序列长度)。然后我们讲解了这些系统的基本概念,并将模型分解为嵌入层、编码器、上下文向量和解码器。我们还介绍了像教师强制(teacher forcing)和巴赫达诺注意力(Bahdanau attention)等技术,旨在提高模型性能。接着我们讨论了 NMT 系统的训练和推理过程。我们还讨论了一种名为 BLEU 的新指标,以及它是如何用来衡量机器翻译等序列到序列问题的表现的。
最后,我们简要讨论了序列到序列学习的另一个热门应用:聊天机器人。聊天机器人是能够与人类进行真实对话甚至回答问题的机器学习应用。我们看到,NMT 系统和聊天机器人工作原理相似,唯一不同的是训练数据。我们还讨论了图灵测试,这是一种可以用来评估聊天机器人的定性测试。
在下一章中,我们将介绍一种在 2016 年推出的新型模型,它在 NLP 和计算机视觉领域都处于领先地位:Transformer。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,结识志同道合的人,并与超过 1000 名成员一起学习:packt.link/nlp

第十章:Transformer
Transformer 模型改变了大多数涉及顺序数据的机器学习问题的游戏规则。与之前的 RNN 基模型相比,它们显著提高了技术水平。Transformer 模型之所以如此高效的一个主要原因是,它能够访问整个序列(例如,令牌序列),而不像 RNN 基模型那样一次只查看一个元素。Transformer 这一术语在我们的讨论中多次出现,它是一种超越其他顺序模型(如 LSTM 和 GRU)的方法。现在,我们将深入了解 Transformer 模型。
本章中,我们将首先详细学习 Transformer 模型。然后,我们将讨论 Transformer 家族中的一个特定模型,称为 双向编码器表示模型(BERT)。我们将了解如何使用该模型来完成问答任务。
具体来说,我们将涵盖以下主要内容:
-
Transformer 架构
-
理解 BERT
-
用例:使用 BERT 回答问题
Transformer 架构
Transformer 是一种 Seq2Seq 模型(在上一章中讨论过)。Transformer 模型可以处理图像和文本数据。Transformer 模型接受一系列输入并将其映射到一系列输出。
Transformer 模型最初在 Vaswani 等人提出的论文 Attention is all you need 中提出(arxiv.org/pdf/1706.03762.pdf)。与 Seq2Seq 模型类似,Transformer 包含一个编码器和一个解码器(图 10.1):

图 10.1:编码器-解码器架构
让我们通过之前学习过的机器翻译任务来理解 Transformer 模型是如何工作的。编码器接受一系列源语言的令牌并生成一系列中间输出。然后,解码器接受一系列目标语言的令牌并预测每个时间步的下一个令牌(教师强迫技术)。编码器和解码器都使用注意力机制来提高性能。例如,解码器使用注意力机制检查所有过去的编码器状态和先前的解码器输入。该注意力机制在概念上类似于我们在上一章中讨论过的 Bahdanau 注意力。
编码器和解码器
现在让我们详细讨论编码器和解码器的组成部分。它们的架构大致相同,但也有一些差异。编码器和解码器都被设计为一次性处理一个输入序列。但它们在任务中的目标不同;编码器使用输入生成潜在表示,而解码器则使用输入和编码器的输出生成目标输出。为了执行这些计算,这些输入会通过多个堆叠的层进行传播。每一层都接收一个元素序列并输出另一个元素序列。每一层也由几个子层组成,这些子层对输入的令牌序列执行不同的计算,从而生成输出序列。
Transformer 中的一个层主要由以下两个子层组成:
-
一个自注意力层
-
全连接层
自注意力层通过矩阵乘法和激活函数生成输出(这与我们稍后将讨论的全连接层类似)。自注意力层接收一系列输入并生成一系列输出。然而,自注意力层的一个特殊特点是,在每个时间步生成输出时,它可以访问该序列中的所有其他输入(**图 10.2)。这使得该层能够轻松学习和记住长序列的输入。相比之下,RNN 在记住长序列输入时会遇到困难,因为它们需要依次处理每个输入。此外,按设计,自注意力层可以根据所解决的任务,在每个时间步选择并组合不同的输入。这使得 Transformer 在序列学习任务中非常强大。
*让我们讨论一下为什么以这种方式选择性地组合不同的输入元素很重要。在 NLP 领域,自注意力层使得模型在处理某个词时能够“窥视”其他词。这意味着,当编码器在处理句子 I kicked the ball and it disappeared 中的词 it 时,模型可以关注词 ball。通过这种方式,Transformer 能够学习依赖关系并消除歧义,从而提升语言理解能力。
我们甚至可以通过一个现实世界的例子理解自注意力如何帮助我们方便地解决任务。假设你正在和另外两个人玩一个游戏:A 人和 B 人。A 人手里有一个写在板子上的问题,而你需要回答这个问题。假设 A 人每次揭示一个问题的单词,在问题的最后一个单词揭示出来后,你才回答它。对于长且复杂的问题,这会变得具有挑战性,因为你无法在物理上看到完整的问题,必须依赖记忆。这就是没有自注意力的 Transformer 执行计算时的感觉。另一方面,假设 B 人一次性将完整的问题揭示在板子上,而不是一个个字地揭示。现在,你可以一次性看到完整的问题,因此回答问题变得容易得多。如果问题很复杂,需要复杂的答案,你可以在给出不同部分的答案时查看问题的不同部分。这就是自注意力层的作用。
自注意力层后跟一个全连接层。全连接层将所有输入节点与所有输出节点连接,通常后面跟着一个非线性激活函数。它将自注意力子层产生的输出元素作为输入,生成每个输出元素的隐藏表示。与自注意力层不同,全连接层独立地处理每个序列项,按逐元素的方式进行计算。
它们在使模型更深的同时引入了非线性变换,从而使模型能够更好地执行任务:

图 10.2:自注意力子层与全连接子层的区别。自注意力子层查看序列中的所有输入,而全连接子层只查看正在处理的输入。
现在我们理解了 Transformer 层的基本构建块,接下来我们将分别看看编码器和解码器。在深入之前,我们先建立一些基础知识。编码器接收一个输入序列,解码器也接收一个输入序列(与编码器输入的序列不同)。然后解码器产生一个输出序列。我们将这些序列中的每个单项称为 token。
编码器由一堆层组成,每一层由两个子层构成:
-
自注意力层 – 为序列中的每个编码器输入标记生成潜在表示。对于每个输入标记,该层查看整个序列并选择序列中的其他标记,丰富该标记的隐藏输出(即“注意到的”表示)的语义。
-
全连接层 – 生成注意到的表示的逐元素更深隐藏表示。
解码器层由三个子层组成:
-
掩蔽自注意力层 – 对于每个解码器输入,一个令牌会查看它左侧的所有令牌。解码器需要掩蔽右侧的词语,以防止模型看到未来的词语。在预测过程中,如果能够访问到后续的词语,解码器的预测任务可能会变得非常简单。
-
注意力层 – 对于解码器中的每个输入令牌,它会查看编码器的输出和解码器的掩蔽关注输出,以生成语义丰富的隐藏输出。由于该层不仅关注解码器输入,我们将其称为注意力层。
-
全连接层 – 生成解码器关注表示的逐元素隐藏表示。
如图 10.3所示:

图 10.3:Transformer 模型如何用于将英文句子翻译成法语。该图展示了编码器和解码器中的各个层,以及编码器内部、解码器内部和编码器与解码器之间的各种连接。方框表示模型的输入和输出。矩形阴影框表示子层的临时输出。<sos>符号表示解码器输入的开始。
接下来,让我们学习自注意力层的计算机制。
计算自注意力层的输出
毫无疑问,自注意力层是 Transformer 的核心。支配自注意力机制的计算可能比较难以理解。因此,本节将详细解释自注意力技术。要理解的三个关键概念是:查询、键和值。查询和键用于生成亲和力矩阵。对于解码器的注意力层,亲和力矩阵中的位置 i,j 表示编码器状态(键)i 与解码器输入(查询)j 之间的相似度。接着,我们为每个位置创建一个加权平均的编码器状态(值),权重由亲和力矩阵给出。
为了加深我们的理解,让我们假设一个情境,解码器正在生成自注意力输出。假设我们有一个英法机器翻译任务。以句子Dogs are great为例,翻译成法语就是Les chiens sont super。假设我们处在第 2 时间步,尝试生成单词chiens。我们将每个单词用一个浮动点数表示(例如,简化版的单词嵌入表示):
Dogs -> 0.8
are -> 0.3
great -> -0.2
chiens -> 0.5
现在让我们计算亲和力矩阵(具体来说,是亲和力向量,因为我们只考虑单个解码器输入)。查询值为 0.5,键(即编码器状态序列)为[0.8, 0.3, -0.2]。如果我们进行点积运算,结果为:
[0.4, 0.15, -0.1]
让我们理解一下这个亲和矩阵所表达的含义。相对于单词chiens,单词Dogs具有最高的相似度,单词are也有较高的相似度(因为chiens是复数,指代的是英文中的are)。然而,单词great与单词chiens的相似度是负值。然后,我们可以计算该时间步的最终注意力输出,计算方式如下:
[0.4 * 0.8, 0.15 * 0.3, -0.1 * -0.2] = [0.32 + 0.45 + 0.02] = 0.385
我们最终得到的输出位于英语单词匹配的一部分,其中单词great的距离最大。这个例子展示了查询、键和值是如何发挥作用的,以计算最终的注意力输出。
现在,让我们来看一下实际在该层中发生的计算。为了计算查询、键和值,我们使用权重矩阵对实际输入进行线性投影。三个权重矩阵是:
-
查询权重矩阵 (
) -
键权重矩阵 (
) -
值权重矩阵 (
)
每个权重矩阵通过与权重矩阵相乘,为给定输入序列中某个位置的标记(位置
)产生三个输出,计算方式如下:
,
, 和 
Q,K,和V是大小为[B, T, d]的张量,其中B是批量大小,T是时间步数,d是一个超参数,用于定义潜在表示的维度。这些张量随后用于计算亲和矩阵,计算方式如下:

图 10.4:自注意力层中的计算过程。自注意力层从输入序列开始,计算查询、键和值向量序列。然后,将查询和键转换为概率矩阵,该矩阵用于计算值的加权和。
亲和矩阵P的计算方式如下:

然后,计算自注意力层的最终注意力输出,计算方式如下:

在这里,Q表示查询张量,K表示键张量,V表示值张量。这就是 Transformer 模型如此强大的原因;与 LSTM 模型不同,Transformer 模型将序列中的所有标记聚合成一个矩阵乘法,使这些模型具有很高的并行性。图 10.4还展示了自注意力层内发生的计算过程。
Transformer 中的嵌入层
词嵌入提供了一种语义保留的词语表示,基于词语使用的上下文。换句话说,如果两个词语在相同的上下文中使用,它们将具有相似的词向量。例如,cat和dog将具有相似的表示,而cat和volcano将具有截然不同的表示。
词向量最早在 Mikolov 等人发表的论文Efficient Estimation of Word Representations in Vector Space中被提出(arxiv.org/pdf/1301.3781.pdf)。它有两种变体:skip-gram 和连续词袋(CBOW)。嵌入通过首先定义一个大小为V x E的大矩阵来工作,其中V是词汇表的大小,E是嵌入的大小。E是用户定义的超参数;较大的E通常会导致更强大的词嵌入。实际上,你不需要将嵌入的大小增大到超过 300。
受原始词向量算法的启发,现代深度学习模型使用嵌入层来表示词语/标记。以下通用方法(以及后续的预训练以微调这些嵌入)用于将词嵌入整合到机器学习模型中:
-
定义一个随机初始化的词嵌入矩阵(或预训练的嵌入,可以免费下载)
-
定义使用词嵌入作为输入并产生输出的模型(例如情感分析或语言翻译)
-
对整个模型(嵌入和模型)进行端到端训练,完成任务
在 Transformer 模型中使用相同的技术。然而,在 Transformer 模型中,有两种不同的嵌入:
-
标记嵌入(为模型在输入序列中看到的每个标记提供唯一表示)
-
位置嵌入(为输入序列中的每个位置提供唯一表示)
标记嵌入为每个标记(如字符、词语和子词)提供一个唯一的嵌入向量,这取决于模型的标记化机制
位置嵌入用于指示模型一个标记出现的位置。位置嵌入的主要作用是告诉 Transformer 模型一个词语出现的位置。这是因为,与 LSTM/GRU 不同,Transformer 模型没有序列的概念,它一次性处理整个文本。此外,改变词语的位置可能会改变句子的含义/词义。例如:
Ralph loves his tennis ball. It likes to chase the ball
Ralph loves his tennis ball. Ralph likes to chase it
在上述句子中,it一词指代不同的事物,it的位置可以作为线索来识别这种差异。原始的 Transformer 论文使用以下方程来生成位置嵌入:


其中,pos 表示序列中的位置,
表示
特征维度(
)。偶数编号的特征使用正弦函数,奇数编号的特征使用余弦函数。图 10.5 展示了随着时间步和特征位置的变化,位置嵌入是如何变化的。可以看到,特征位置索引较高的位置具有较低频率的正弦波。尚不完全清楚作者是如何得出该精确方程的。
然而,他们确实提到,尽管使用上述方程与在训练过程中让模型联合学习位置嵌入之间没有明显的性能差异。

图 10.5:随着时间步和特征位置的变化,位置嵌入是如何变化的。偶数编号的特征位置使用正弦函数,奇数编号的位置使用余弦函数。此外,随着特征位置的增加,信号的频率降低。
需要注意的是,token 嵌入和位置嵌入将具有相同的维度
,这使得逐元素相加成为可能。最后,作为模型的输入,token 嵌入和位置嵌入相加,形成一个单一的混合嵌入向量(图 10.6):

图 10.6:Transformer 模型中生成的嵌入以及最终嵌入是如何计算的
现在让我们讨论 Transformer 中每一层使用的两种优化技术:残差连接和层归一化。
残差与归一化
Transformer 模型的另一个重要特性是,模型中各层之间存在残差连接和归一化层。
残差连接通过将给定层的输出加到一个或多个前面层的输出上形成。这反过来通过模型形成快捷连接,并通过减少梯度消失现象的发生来提供更强的梯度流(图 10.7)。梯度消失问题导致最接近输入层的梯度非常小,从而妨碍了这些层的训练。残差连接在深度学习模型中的应用,由 Kaiming He 等人在论文“Deep Residual Learning for Image Recognition”中推广(arxiv.org/pdf/1512.03385.pdf)

图 10.7:残差连接的工作原理
在 Transformer 模型中,每一层的残差连接是通过以下方式创建的:
-
进入自注意力子层的输入会加到自注意力子层的输出上。
-
进入全连接子层的输入会加到全连接子层的输出上。
接下来,经过残差连接强化的输出通过一个层归一化层。层归一化类似于批量归一化,它是一种减少神经网络中协变量偏移的方法,使得神经网络能够更快地训练并取得更好的性能。协变量偏移是指神经网络激活值的分布变化(由于数据分布变化引起的),这种变化会在模型训练过程中发生。这些分布的变化破坏了训练过程中的一致性,并对模型产生负面影响。该方法在 Ba 等人发表的论文Layer Normalization中被提出(arxiv.org/pdf/1607.06450.pdf)。
批量归一化通过计算激活值的均值和方差,并以批次样本的平均值为基础,从而使其性能依赖于训练模型时使用的迷你批次。
然而,层归一化以一种方式计算激活值的均值和方差(即归一化项),使得每个隐藏单元的归一化项相同。换句话说,层归一化对层中的所有隐藏单元有一个共同的均值和方差值。这与批量归一化形成对比,后者为每个隐藏单元维持单独的均值和方差值。此外,不同于批量归一化,层归一化不会对批次中的样本求均值;相反,它跳过了平均值计算,并为不同的输入提供不同的归一化项。通过为每个样本单独计算均值和方差,层归一化摆脱了对迷你批次大小的依赖。如需了解该方法的更多细节,请参阅 Ba 等人发表的原始论文。
TensorFlow 在www.tensorflow.org/api_docs/python/tf/keras/layers/LayerNormalization提供了方便的层归一化算法实现。你可以简单地在使用 TensorFlow Keras API 定义的任何模型中使用该层。
图 10.8 展示了残差连接和层归一化如何在 Transformer 模型中使用:

图 10.8:残差连接和层归一化层在 Transformer 模型中的使用方式
至此,我们结束了对 Transformer 模型组件的讨论。我们已经讨论了 Transformer 模型的所有关键组件。Transformer 模型是一个基于编码器-解码器的模型。编码器和解码器具有相同的结构,除了少数几个小差异。Transformer 使用自注意力机制,这是一种强大的并行化注意力机制,用于在每个时间步骤关注其他输入。Transformer 还使用多个嵌入层,例如词汇嵌入和位置嵌入,以注入有关词汇和其位置的信息。Transformer 还使用残差连接和层归一化,以提高模型的性能。
接下来,我们将讨论一个特定的 Transformer 模型,称为 BERT,我们将使用它来解决一个问答问题。
理解 BERT
BERT(来自 Transformer 的双向编码器表示)是近年来众多 Transformer 模型中的一个。
BERT 在 Delvin 等人发表的论文 BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding 中提出(arxiv.org/pdf/1810.04805.pdf)。Transformer 模型分为两大类:
-
基于编码器的模型
-
基于解码器(自回归)模型
换句话说,与使用 Transformer 的编码器和解码器相比,Transformer 的编码器或解码器部分为这些模型提供了基础。两者之间的主要区别在于注意力机制的使用方式。基于编码器的模型使用双向注意力,而基于解码器的模型使用自回归(即从左到右)注意力。
BERT 是一个基于编码器的 Transformer 模型。它接收一个输入序列(一组标记)并生成一个编码的输出序列。图 10.9 描述了 BERT 的高层次架构:

图 10.9:BERT 的高层次架构。它接收一组输入标记并生成通过多个隐藏层生成的隐藏表示序列。
现在,让我们讨论一些与 BERT 相关的细节,比如 BERT 消耗的输入和它设计用来解决的任务。
BERT 的输入处理
当 BERT 接收输入时,它会在输入中插入一些特殊的标记。首先,在开头,它插入一个 [CLS](分类的缩写)标记,用于生成某些任务(如序列分类)的最终隐藏表示。它代表在处理序列中所有标记后的输出。接下来,根据输入类型,它还会插入一个 [SEP](意为“分隔”)标记。[SEP] 标记用于标记输入中不同序列的开始和结束。例如,在问答中,模型将问题和可能包含答案的上下文(如段落)作为输入,[SEP] 用于问题和上下文之间。此外,还有 [PAD] 标记,可用于将短序列填充至所需长度。
[CLS] 标记会附加到输入的每个序列中,表示输入的开始。它也是输入到 BERT 上层分类头的基础,用于解决您的 NLP 任务。如您所知,BERT 会为序列中的每个输入标记生成隐藏表示。根据惯例,[CLS] 标记对应的隐藏表示将作为输入,传递给位于 BERT 之上的分类模型。
接下来,使用三种不同的嵌入空间生成最终的令牌嵌入。每个词汇表中的令牌都有一个独特的向量表示。位置嵌入编码了每个令牌的位置,如前所述。最后,段落嵌入为输入中的每个子组件提供了一个独特的表示,当输入由多个组件组成时。例如,在问答任务中,问题将拥有一个独特的向量作为其段落嵌入向量,而上下文将具有不同的嵌入向量。这是通过为输入序列中的不同组件提供
嵌入向量来实现的。根据输入中每个令牌指定的组件索引,检索相应的段落嵌入向量。需要提前指定
。
BERT 解决的任务
BERT 解决的特定任务可以分为四个不同的类别。这些类别受 通用语言理解评估 (GLUE) 基准任务套件的启发(gluebenchmark.com):
-
序列分类 – 在这里,给定一个输入序列,模型被要求为整个序列预测一个标签(例如,情感分析或垃圾邮件识别)。
-
令牌分类 – 在这里,给定一个输入序列,模型被要求为序列中的每个令牌预测一个标签(例如,命名实体识别或词性标注)。
-
问答任务 – 在这里,输入由两个序列组成:一个问题和一个上下文。问题和上下文由一个
[SEP]令牌分隔。模型被训练以预测答案所属的令牌跨度的起始和结束索引。 -
多项选择 – 在这里,输入由多个序列组成;一个问题后面跟着多个候选答案,这些候选答案可能是也可能不是问题的答案。这些多个序列由令牌
[SEP]分隔,并作为一个单一的输入序列提供给模型。模型被训练以预测该问题的正确答案(即,类别标签)。
图 10.10 描述了 BERT 如何用于解决这些不同的任务:

图 10.10:BERT 如何用于不同的 NLP 任务
BERT 的设计使其能够在不修改基础模型的情况下完成这些任务。
在涉及多个序列的任务中(例如多项选择题),你需要模型区分属于不同段落的不同输入(即,在问答任务中,哪些令牌是问题,哪些令牌是上下文)。为了做出这个区分,使用了 [SEP] 令牌。一个 [SEP] 令牌插入在不同序列之间。例如,如果你正在解决一个问答问题,输入可能如下所示:
问题:球的颜色是什么?
段落:Tippy 是一只狗。她喜欢玩她的红色球。
然后输入到 BERT 的内容可能如下所示:
[CLS] 球的颜色是什么 [SEP] Tippy 是一只狗,她喜欢玩她的红色球 [SEP]
现在我们已经讨论了 BERT 的所有元素,因此我们可以成功地使用它来解决下游的 NLP 任务,接下来让我们重述关于 BERT 的关键点:
-
BERT 是一个基于编码器的 Transformer
-
BERT 对输入序列中的每个标记输出一个隐藏表示
-
BERT 有三个嵌入空间:标记嵌入、位置嵌入和片段嵌入
-
BERT 使用特殊的标记
[CLS]来表示输入的开始,并作为下游分类模型的输入 -
BERT 被设计用来解决四种类型的 NLP 任务:序列分类、标记分类、自由文本问答和多项选择问答
-
BERT 使用特殊的标记
[SEP]来分隔序列 A 和序列 B
BERT 的强大不仅体现在其结构上。BERT 在一个庞大的文本语料库上进行预训练,使用几种不同的预训练技术。换句话说,BERT 已经具备了对语言的扎实理解,这使得下游的自然语言处理任务更容易解决。接下来,让我们讨论 BERT 是如何进行预训练的。
BERT 是如何进行预训练的
BERT 的真正价值在于它已经在一个庞大的数据语料库上进行了自监督的预训练。在预训练阶段,BERT 被训练于两个不同的任务:
-
掩码语言建模(有时缩写为 MLM)
-
下一个句子预测(有时缩写为 NSP)
现在让我们讨论上述两个任务的细节,以及它们是如何为 BERT 提供语言理解的。
掩码语言建模(MLM)
MLM 任务的灵感来源于 Cloze 任务或 Cloze 测试,学生会得到一个句子,其中有一个或多个空白,需要填充这些空白。同样地,给定一个文本语料库,句子中的词被掩码,然后模型需要预测这些被掩码的标记。例如,句子:
我去面包店买面包
可能变成:
我去 [MASK] 买面包
BERT 使用特殊的标记 [MASK] 来表示被掩码的词。然后,模型的目标词将是 bakery(面包店)。但是,这为模型引入了一个实际问题。特殊的 [MASK] 标记在实际文本中并不会出现。这意味着模型在微调阶段(即在分类问题上训练时)看到的文本与预训练时看到的文本会有所不同。这有时被称为 预训练-微调不一致。因此,BERT 的作者提出了以下方法来应对这个问题。当掩码一个词时,执行以下操作之一:
-
按照原样使用
[MASK]标记(80% 的概率) -
使用一个随机词(10% 的概率)
-
使用真实的词(10% 的概率)
换句话说,模型在某些情况下会看到实际的单词,而不总是看到 [MASK],从而缓解了这种差异。
下一句预测(NSP)
在 NSP 任务中,模型会接收到一对句子 A 和 B(按此顺序),并被要求预测 B 是否是 A 后面的下一句。这可以通过在 BERT 上拟合一个二分类器并对选定的句子对进行端到端训练来实现。
生成句子对作为模型的输入并不难,可以以无监督的方式进行:
-
通过选择相邻的两句话,生成一个标签为 TRUE 的样本。
-
通过随机选择两句不相邻的句子,生成一个标签为 FALSE 的样本。
按照这种方法,我们生成一个用于下一个句子预测任务的标注数据集。然后,BERT 和二分类器一起,使用该标注数据集进行端到端的训练,以解决下游任务。为了看到这一过程的实际应用,我们将使用 Hugging Face 的 transformers 库。
使用场景:使用 BERT 回答问题。
现在让我们学习如何实现 BERT,在一个问答数据集上训练它,并让模型回答给定的问题。
Hugging Face transformers 库简介
我们将使用由 Hugging Face 构建的 transformers 库。transformers 库是一个高层次的 API,建立在 TensorFlow、PyTorch 和 JAX 之上。它提供了便捷的访问预训练 Transformer 模型的方式,这些模型可以轻松下载并进行微调。你可以在 Hugging Face 的模型注册表中找到模型,网址为 huggingface.co/models。你可以按任务筛选模型,查看底层的深度学习框架等。
transformers 库的设计目的是提供一个非常低的入门门槛,使用复杂的 Transformer 模型。因此,使用该库时你只需要学习少数几个概念,就能快速上手。成功加载和使用模型需要三类重要的类:
-
模型类(如
TFBertModel)– 包含模型的训练权重,形式为tf.keras.models.Model或 PyTorch 等效类。 -
配置(如
BertConfig)– 存储加载模型所需的各种参数和超参数。如果你直接使用预训练模型,则不需要显式定义其配置。 -
Tokenizer(如
BertTokenizerFast)– 包含模型所需的词汇和词到 ID 的映射,用于对文本进行分词。
所有这些类都可以通过两个简单的函数来使用:
-
from_pretrained()– 提供一种从模型库或本地实例化模型/配置/分词器的方法。 -
save_pretrained()– 提供一种保存模型/配置/分词器的方法,以便以后重新加载。
TensorFlow 在 TensorFlow Hub(tfhub.dev/)托管了多种 Transformer 模型(由 TensorFlow 和第三方发布)。如果你想了解如何使用 TensorFlow Hub 和原始 TensorFlow API 实现像 BERT 这样的模型,请访问 www.tensorflow.org/text/tutorials/classify_text_with_bert。
我们很快就会看到这些类和函数如何在实际用例中使用。同样,重要的是要注意,尽管使用模型的界面非常简单易懂,但这也带来了一些副作用。由于它专门用于提供一种使用 TensorFlow、PyTorch 或 Jax 构建的 Transformer 模型的方法,你在使用时无法享受 TensorFlow 等框架所提供的模块化或灵活性。换句话说,你不能像使用 TensorFlow 构建 tf.keras.models.Model,并使用 tf.keras.layers.Layer 对象那样使用 transformers 库。
探索数据
我们将用于此任务的数据集是一个流行的问答数据集,名为 SQUAD。每个数据点由四个部分组成:
-
一个问题
-
可能包含问题答案的上下文
-
答案的起始索引
-
答案
我们可以使用 Hugging Face 的 datasets 库下载数据集,并通过传入 "squad" 参数来调用 load_dataset() 函数:
from datasets import load_dataset
dataset = load_dataset("squad")
现在让我们使用以下方法打印一些示例:
for q, a in zip(dataset["train"]["question"][:5], dataset["train"]["answers"][:5]):
print(f"{q} -> {a}")
它将输出:
To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? -> {'text': ['Saint Bernadette Soubirous'], 'answer_start': [515]}
What is in front of the Notre Dame Main Building? -> {'text': ['a copper statue of Christ'], 'answer_start': [188]}
The Basilica of the Sacred heart at Notre Dame is beside to which structure? -> {'text': ['the Main Building'], 'answer_start': [279]}
What is the Grotto at Notre Dame? -> {'text': ['a Marian place of prayer and reflection'], 'answer_start': [381]}
What sits on top of the Main Building at Notre Dame? -> {'text': ['a golden statue of the Virgin Mary'], 'answer_start': [92]}
在这里,answer_start 表示答案在提供的上下文中开始的字符索引。通过对数据集中可用内容的充分理解,我们将执行一个简单的处理步骤。在训练模型时,我们将要求模型预测答案的起始和结束索引。在其原始形式中,仅存在 answer_start。我们需要手动将 answer_end 添加到数据集中。以下函数实现了这一功能。此外,它还对数据集进行了几个检查:
def compute_end_index(answers, contexts):
""" Add end index to answers """
fixed_answers = []
for answer, context in zip(answers, contexts):
gold_text = answer['text'][0]
answer['text'] = gold_text
start_idx = answer['answer_start'][0]
answer['answer_start'] = start_idx
# Make sure the starting index is valid and there is an answer
assert start_idx >=0 and len(gold_text.strip()) > 0:
end_idx = start_idx + len(gold_text)
answer['answer_end'] = end_idx
# Make sure the corresponding context matches the actual answer
assert context[start_idx:end_idx] == gold_text
fixed_answers.append(answer)
return fixed_answers, contexts
train_questions = dataset["train"]["question"]
print("Training data corrections")
train_answers, train_contexts = compute_end_index(
dataset["train"]["answers"], dataset["train"]["context"]
)
test_questions = dataset["validation"]["question"]
print("\nValidation data correction")
test_answers, test_contexts = compute_end_index(
dataset["validation"]["answers"], dataset["validation"]["context"]
)
接下来,我们将从 Hugging Face 仓库下载一个预训练的 BERT 模型,并深入了解该模型。
实现 BERT
要使用 Hugging Face 仓库中的预训练 Transformer 模型,我们需要三个组件:
-
Tokenizer– 负责将长文本(例如句子)拆分成更小的标记 -
config– 包含模型的配置 -
Model– 接收标记,查找嵌入,并使用提供的输入生成最终输出
我们可以忽略 config,因为我们将直接使用预训练模型。但是,为了完整展示,我们还是会使用配置。
实现和使用 Tokenizer
首先,我们将查看如何下载 Tokenizer。你可以使用 transformers 库下载 Tokenizer。只需调用 PreTrainedTokenizerFast 基类提供的 from_pretrained() 函数:
from transformers import BertTokenizerFast
tokenizer = BertTokenizerFast.from_pretrained('bert-base-uncased')
我们将使用名为 bert-base-uncased 的分词器。它是为 BERT 基础模型开发的分词器,并且是不区分大小写的(即不区分大写字母和小写字母)。接下来,让我们看看分词器的实际应用:
context = "This is the context"
question = "This is the question"
token_ids = tokenizer(
text=context, text_pair=question,
padding=False, return_tensors='tf'
)
print(token_ids)
让我们来理解一下我们传递给分词器的参数:
-
text– 单个或一批文本序列,供分词器进行编码。每个文本序列都是一个字符串。 -
text_pair– 一个可选的单个或一批文本序列,供分词器进行编码。在模型接受多部分输入的情况下(例如,在问答任务中,包含问题和上下文),它非常有用。 -
padding– 表示填充策略。如果设置为True,则将填充到数据集中的最大序列长度。如果设置为max_length,则将填充到由max_length参数指定的长度。如果设置为False,则不进行填充。 -
return_tensors– 定义返回张量类型的参数。它可以是pt(PyTorch)或tf(TensorFlow)。由于我们需要使用 TensorFlow 张量,因此将其定义为'tf'。
这将输出:
{
'input_ids': <tf.Tensor: shape=(1, 11), dtype=int32, numpy=array([[ 101, 2023, 2003, 1996, 6123, 102, 2023, 2003, 1996, 3160, 102]])>,
'token_type_ids': <tf.Tensor: shape=(1, 11), dtype=int32, numpy=array([[0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]])>,
'attention_mask': <tf.Tensor: shape=(1, 11), dtype=int32, numpy=array([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]])>
}
这将输出一个 transformers.tokenization_utils_base.BatchEncoding 对象,它本质上是一个字典。它包含三个键,每个键对应一个张量值:
-
input_ids– 提供文本序列中标记的 ID。此外,它会在序列开头插入[CLS]标记的 ID,并在问题与上下文之间以及序列末尾插入两个[SEP]标记的 ID。 -
token_type_ids– 用于段落嵌入的段落 ID。 -
attention_mask– 注意力掩码表示在前向传播过程中可以被注意到的词。由于 BERT 是一个编码器模型,任何标记都可以关注任何其他标记。唯一的例外是填充的标记,它们会在注意力机制中被忽略。
我们还可以将这些标记 ID 转换为实际的标记,以了解它们代表什么。为此,我们使用 convert_ids_to_tokens() 函数:
print(tokenizer.convert_ids_to_tokens(token_ids['input_ids'].numpy()[0]))
这将打印:
['[CLS]', 'this', 'is', 'the', 'context', '[SEP]', 'this', 'is', 'the', 'question', '[SEP]']
你可以看到分词器如何将特殊标记如 [CLS] 和 [SEP] 插入到文本序列中。在理解了分词器的功能后,接下来让我们使用它来编码训练集和测试集:
# Encode train data
train_encodings = tokenizer(train_contexts, train_questions, truncation=True, padding=True, return_tensors='tf')
# Encode test data
test_encodings = tokenizer(test_contexts, test_questions, truncation=True, padding=True, return_tensors='tf')
你可以通过运行以下命令来检查训练编码的大小:
print("train_encodings.shape: {}".format(train_encodings["input_ids"].shape))
这将输出:
train_encodings.shape: (87599, 512)
我们数据集中的最大序列长度为 512。因此,我们看到序列的最大长度为 512。一旦我们将数据进行分词,我们还需要进行一步数据处理。我们的 answer_start 和 answer_end 索引是基于字符的。然而,由于我们处理的是标记,我们需要将基于字符的索引转换为基于标记的索引。我们将为此定义一个函数:
def replace_char_with_token_indices(encodings, answers):
start_positions = []
end_positions = []
n_updates = 0
# Go through all the answers
for i in range(len(answers)):
# Get the token position for both start end char positions
start_positions.append(encodings.char_to_token(i,
answers[i]['answer_start']))
end_positions.append(encodings.char_to_token(i,
answers[i]['answer_end'] - 1))
if start_positions[-1] is None or end_positions[-1] is None:
n_updates += 1
# if start position is None, the answer passage has been truncated
# In the guide, https://huggingface.co/transformers/custom_
# datasets.html#qa-squad they set it to model_max_length, but
# this will result in NaN losses as the last available label is
# model_max_length-1 (zero-indexed)
if start_positions[-1] is None:
start_positions[-1] = tokenizer.model_max_length -1
if end_positions[-1] is None:
end_positions[-1] = tokenizer.model_max_length -1
print("{}/{} had answers truncated".format(n_updates,
len(answers)))
encodings.update({'start_positions': start_positions,
'end_positions': end_positions})
该函数接收由分词器生成的一组BatchEncodings(称为encodings)和一组答案(字典列表)。然后,它通过两个新键start_positions和end_positions更新提供的编码。这两个键分别保存表示答案开始和结束的基于 token 的索引。如果没有找到答案,我们将开始和结束索引设置为最后一个 token。为了将现有的基于字符的索引转换为基于 token 的索引,我们使用一个名为char_to_token()的函数,该函数由BatchEncodings类提供。它以字符索引为输入,输出相应的 token 索引。定义好该函数后,让我们在训练和测试数据上调用它:
replace_char_with_token_indices(train_encodings, train_answers)
replace_char_with_token_indices(test_encodings, test_answers)
使用清理后的数据,我们现在将定义一个 TensorFlow 数据集。请注意,此函数会原地修改编码。
定义 TensorFlow 数据集
接下来,我们实现一个 TensorFlow 数据集,以生成模型所需的数据。我们的数据将包含两个元组:一个包含输入,另一个包含目标。输入元组包含:
-
输入 token ID – 一批填充的 token ID,大小为
[batch size, sequence length] -
注意力掩码 – 一批注意力掩码,大小为
[batch size, sequence length]
输出元组包含:
-
答案的起始索引 – 一批答案的起始索引
-
答案的结束索引 – 一批答案的结束索引
我们将首先定义一个生成器,生成这种格式的数据:
def data_gen(input_ids, attention_mask, start_positions, end_positions):
""" Generator for data """
for inps, attn, start_pos, end_pos in zip(input_ids,
attention_mask, start_positions, end_positions):
yield (inps, attn), (start_pos, end_pos)
由于我们已经处理了数据,因此只需重新组织已有的数据即可使用上面的代码返回结果。
接下来,我们将定义一个部分函数,可以在不传递任何参数的情况下直接调用:
# Define the generator as a callable
train_data_gen = partial(data_gen,
input_ids=train_encodings['input_ids'], attention_mask=train_
encodings['attention_mask'],
start_positions=train_encodings['start_positions'],
end_positions=train_encodings['end_positions']
)
然后,将此函数传递给tf.data.Dataset.from_generator()函数:
# Define the dataset
train_dataset = tf.data.Dataset.from_generator(
train_data_gen, output_types=(('int32', 'int32'), ('int32', 'int32'))
)
然后,我们将训练数据集中的数据打乱。在打乱 TensorFlow 数据集时,我们需要提供缓冲区大小。缓冲区大小定义了用于打乱的样本数量。这里我们将其设置为 1,000 个样本:
# Shuffling the data
train_dataset = train_dataset.shuffle(1000)
print('\tDone')
接下来,我们将数据集分为两部分:训练集和验证集。我们将使用前 10,000 个样本作为验证集,其余的数据作为训练集。两个数据集都将使用批量大小为 4 的批处理:
# Valid set is taken as the first 10000 samples in the shuffled set
valid_dataset = train_dataset.take(10000)
valid_dataset = valid_dataset.batch(4)
# Rest is kept as the training data
train_dataset = train_dataset.skip(10000)
train_dataset = train_dataset.batch(4)
最后,我们按照相同的过程创建测试数据集:
# Creating test data
print("Creating test data")
# Define the generator as a callable
test_data_gen = partial(data_gen,
input_ids=test_encodings['input_ids'],
attention_mask=test_encodings['attention_mask'],
start_positions=test_encodings['start_positions'],
end_positions=test_encodings['end_positions']
)
test_dataset = tf.data.Dataset.from_generator(
test_data_gen, output_types=(('int32', 'int32'), ('int32',
'int32'))
)
test_dataset = test_dataset.batch(8)
现在,让我们看看 BERT 的架构如何用于回答问题。
用于回答问题的 BERT
在预训练的 BERT 模型基础上,为了使其适应问答任务,进行了几项修改。首先,模型输入一个问题,后接一个上下文。如前所述,上下文可能包含也可能不包含问题的答案。输入格式为[CLS] <问题标记> [SEP] <上下文标记> [SEP]。然后,对于上下文中的每个标记位置,我们有两个分类头预测概率。一个头部预测每个上下文标记作为答案开始的概率,另一个头部预测每个上下文标记作为答案结束的概率。
一旦我们找到了答案的起始和结束索引,我们就可以使用这些索引从上下文中提取答案。

图 10.11:使用 BERT 进行问答。模型输入一个问题,后接一个上下文。模型有两个头部:一个预测上下文中每个标记作为答案开始的概率,另一个预测上下文中每个标记作为答案结束的概率。
定义配置和模型
在 Hugging Face 中,每种 Transformer 模型有多个变种。这些变种是基于这些模型解决的不同任务。例如,对于 BERT,我们有:
-
TFBertForPretraining– 没有任务特定头部的预训练模型 -
TFBertForSequenceClassification– 用于对文本序列进行分类 -
TFBertForTokenClassification– 用于对文本序列中的每个标记进行分类 -
TFBertForMultipleChoice– 用于回答多项选择题 -
TFBertForQuestionAnswering– 用于从给定上下文中提取问题的答案 -
TFBertForMaskedLM– 用于在掩蔽语言模型任务上预训练 BERT -
TFBertForNextSentencePrediction– 用于预训练 BERT 预测下一句
在这里,我们感兴趣的是TFBertForQuestionAnswering。让我们导入这个类以及BertConfig类,我们将从中提取重要的超参数:
from transformers import BertConfig, TFBertForQuestionAnswering
要获取预训练的config,我们调用BertConfig的from_pretrained()函数,并传入我们感兴趣的模型。在这里,我们将使用bert-base-uncased模型:
config = BertConfig.from_pretrained("bert-base-uncased", return_dict=False)
你可以打印config并查看其中的内容:
BertConfig {
"architectures": [
"BertForMaskedLM"
],
"attention_probs_dropout_prob": 0.1,
"classifier_dropout": null,
"gradient_checkpointing": false,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 768,
"initializer_range": 0.02,
"intermediate_size": 3072,
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"model_type": "bert",
"num_attention_heads": 12,
"num_hidden_layers": 12,
"pad_token_id": 0,
"position_embedding_type": "absolute",
"return_dict": false,
"transformers_version": "4.15.0",
"type_vocab_size": 2,
"use_cache": true,
"vocab_size": 30522
}
最后,我们通过调用TFBertForQuestionAnswering类中的相同函数from_pretrained()并传入我们刚刚获得的config来获取模型:
model = TFBertForQuestionAnswering.from_pretrained("bert-base-uncased", config=config)
当你运行这个时,你会收到一个警告,内容如下:
All model checkpoint layers were used when initializing TFBertForQuestionAnswering.
Some layers of TFBertForQuestionAnswering were not initialized from the model checkpoint at bert-base-uncased and are newly initialized: ['qa_outputs']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.
这是预期的,并且完全正常。它表示有些层尚未从预训练模型中初始化;模型的输出头需要作为新层引入,因此它们没有预初始化。
之后,我们将定义一个函数,将返回的模型包装为tf.keras.models.Model对象。我们需要执行这一步,因为如果我们直接使用模型,TensorFlow 会返回以下错误:
TypeError: The two structures don't have the same sequence type.
Input structure has type <class 'tuple'>, while shallow structure has type
<class 'transformers.modeling_tf_outputs.TFQuestionAnsweringModelOutput'>.
因此,我们将定义两个输入层:一个输入令牌 ID,另一个输入 attention mask 并将其传递给模型。最后,我们得到模型的输出。然后,我们使用这些输入和输出定义一个tf.keras.models.Model:
def tf_wrap_model(model):
""" Wraps the huggingface's model with in the Keras Functional API """
# Define inputs
input_ids = tf.keras.layers.Input([None,], dtype=tf.int32,
name="input_ids")
attention_mask = tf.keras.layers.Input([None,], dtype=tf.int32,
name="attention_mask")
# Define the output (TFQuestionAnsweringModelOutput)
out = model([input_ids, attention_mask])
# Get the correct attributes in the produced object to generate an
# output tuple
wrap_model = tf.keras.models.Model([input_ids, attention_mask],
outputs=(out.start_logits, out.end_logits))
return wrap_model
正如我们在学习模型结构时所了解到的,问答 BERT 有两个头:一个用于预测答案的起始索引,另一个用于预测结束索引。因此,我们需要优化来自这两个头的两个损失。这意味着我们需要将两个损失相加以获得最终的损失。当我们有一个多输出模型时,我们可以为每个输出头传递多个损失函数。在这里,我们定义了一个单一的损失函数。这意味着两个头会使用相同的损失,并将它们加起来生成最终损失:
loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
acc = tf.keras.metrics.SparseCategoricalAccuracy()
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-5)
model_v2 = tf_wrap_model(model)
model_v2.compile(optimizer=optimizer, loss=loss, metrics=[acc])
现在,我们将看到如何在问答任务中训练和评估我们的模型。
训练和评估模型
我们已经准备好了数据并定义了模型。训练模型非常简单,只需要一行代码:
model_v2.fit(
train_dataset,
validation_data=valid_dataset,
epochs=3
)
你应该会看到以下输出:
Epoch 1/2
19400/19400 [==============================] - 7175s 369ms/step
- loss: 2.7193 - tf_bert_for_question_answering_loss: 1.4153 - tf_bert_for_question_answering_1_loss: 1.3040 - tf_bert_for_question_answering_sparse_categorical_accuracy: 0.5975 - tf_bert_for_question_answering_1_sparse_categorical_accuracy: 0.6376 - val_loss: 2.1615 - val_tf_bert_for_question_answering_loss: 1.0898 - val_tf_bert_for_question_answering_1_loss: 1.0717 - val_tf_bert_for_question_answering_sparse_categorical_accuracy: 0.7120 - val_tf_bert_for_question_answering_1_sparse_categorical_accuracy: 0.7350
Epoch 2/2
19400/19400 [==============================] - 7192s 370ms/step - loss: 1.6691 - tf_bert_for_question_answering_loss: 0.8865 - tf_bert_for_question_answering_1_loss: 0.7826 - tf_bert_for_question_answering_sparse_categorical_accuracy: 0.7245 - tf_bert_for_question_answering_1_sparse_categorical_accuracy: 0.7646 - val_loss: 2.1836 - val_tf_bert_for_question_answering_loss: 1.0988 - val_tf_bert_for_question_answering_1_loss: 1.0847 - val_tf_bert_for_question_answering_sparse_categorical_accuracy: 0.7289 - val_tf_bert_for_question_answering_1_sparse_categorical_accuracy: 0.7504
It took 14366.591783046722 seconds to complete the training
你应该会看到验证集的准确率达到大约 73% 到 75% 之间。这是相当高的,考虑到我们只训练了模型两个周期。这个表现可以归功于我们下载的预训练模型已经具备了很高的语言理解能力。让我们在测试数据上评估模型:
model_v2.evaluate(test_dataset)
它应该输出以下内容:
1322/1322 [======================] - 345s 261ms/step - loss: 2.2205 - tf_bert_for_question_answering_loss: 1.1325 - tf_bert_for_question_answering_1_loss: 1.0881 - tf_bert_for_question_answering_sparse_categorical_accuracy: 0.6968 - tf_bert_for_question_answering_1_sparse_categorical_accuracy: 0.7250
我们看到它在测试数据集上的表现也相当不错。最后,我们可以保存模型。我们将保存模型的TFBertForQuestionAnswering组件。我们还将保存分词器:
import os
# Create folders
if not os.path.exists('models'):
os.makedirs('models')
if not os.path.exists('tokenizers'):
os.makedirs('tokenizers')
# Save the model
model_v2.get_layer("tf_bert_for_question_answering").save_pretrained(os.path.join('models', 'bert_qa'))
# Save the tokenizer
tokenizer.save_pretrained(os.path.join('tokenizers', 'bert_qa'))
我们已经训练并评估了模型,以确保其表现良好。确认模型表现良好后,我们最终将其保存以供将来使用。接下来,让我们讨论如何使用这个模型生成给定问题的答案。
使用 Bert 回答问题
现在,让我们编写一个简单的脚本,从训练好的模型中生成问题的答案。首先,我们定义一个示例问题来生成答案。我们还将存储输入和真实答案以进行比较:
i = 5
# Define sample question
sample_q = test_questions[i]
# Define sample context
sample_c = test_contexts[i]
# Define sample answer
sample_a = test_answers[i]
接下来,我们将定义模型的输入。模型的输入需要有一个批量维度。因此,我们使用[i:i+1]语法来确保批量维度不会被压扁:
# Get the input in the format BERT accepts
sample_input = (test_encodings["input_ids"][i:i+1],
test_encodings["attention_mask"][i:i+1])
现在,让我们定义一个简单的函数ask_bert,用于从给定的问题中找到答案。这个函数接收输入、分词器和模型作为参数。
然后,它从分词器生成令牌 ID,将它们传递给模型,输出答案的起始和结束索引,最后从上下文的文本中提取相应的答案:
def ask_bert(sample_input, tokenizer, model):
""" This function takes an input, a tokenizer, a model and returns the
prediciton """
out = model.predict(sample_input)
pred_ans_start = tf.argmax(out[0][0])
pred_ans_end = tf.argmax(out[1][0])
print("{}-{} token ids contain the answer".format(pred_ans_start,
pred_ans_end))
ans_tokens = sample_input[0][0][pred_ans_start:pred_ans_end+1]
return " ".join(tokenizer.convert_ids_to_tokens(ans_tokens))
让我们执行以下代码行来打印模型给出的答案:
print("Question")
print("\t", sample_q, "\n")
print("Context")
print("\t", sample_c, "\n")
print("Answer (char indexed)")
print("\t", sample_a, "\n")
print('='*50,'\n')
sample_pred_ans = ask_bert(sample_input, tokenizer, model_v2)
print("Answer (predicted)")
print(sample_pred_ans)
print('='*50,'\n')
它将输出:
Question
What was the theme of Super Bowl 50?
Context
Super Bowl 50 was an American football game to determine the champion of the National Football League (NFL) for the 2015 season. The American
Football Conference (AFC) champion Denver Broncos defeated the National Football Conference (NFC) champion Carolina Panthers 24–10 to earn their third Super Bowl title. The game was played on February 7, 2016, at Levi's Stadium in the San Francisco Bay Area at Santa Clara, California. As this was the 50th Super Bowl, the league emphasized the "golden anniversary" with various gold-themed initiatives, as well as temporarily suspending the tradition of naming each Super Bowl game with Roman numerals (under which the game would have been known as "Super Bowl L"), so that the logo could prominently feature the Arabic numerals 50\.
Answer (char indexed)
{'answer_start': 487, 'text': '"golden anniversary"', 'answer_end': 507}
==================================================
98-99 token ids contain the answer
Answer (predicted)
golden anniversary
==================================================
我们可以看到 BERT 已经正确回答了问题。我们学习了很多关于 Transformer 的内容,以及 BERT 特有的架构。然后,我们运用这些知识,将 BERT 调整为解决问答问题。至此,我们结束了关于 Transformer 和 BERT 的讨论。
摘要
在本章中,我们讨论了 Transformer 模型。首先,我们从微观角度看了 Transformer,以理解模型的内部工作原理。我们看到,Transformer 使用了自注意力机制,这是一种强大的技术,可以在处理一个输入时,关注文本序列中的其他输入。我们还看到,Transformer 使用位置编码来告知模型 token 在序列中的相对位置,除了 token 编码之外。我们还讨论了,Transformer 利用残差连接(即快捷连接)和层归一化,以提高模型的训练效果。
然后,我们讨论了 BERT,一个基于编码器的 Transformer 模型。我们查看了 BERT 接受的数据格式和它在输入中使用的特殊 token。接下来,我们讨论了 BERT 可以解决的四种不同任务类型:序列分类、token 分类、多选题和问答。
最后,我们查看了 BERT 如何在大量文本语料库上进行预训练。
之后,我们开始了一个使用案例:用 BERT 回答问题。为了实现这个解决方案,我们使用了 Hugging Face 的 transformers 库。这是一个非常有用的高级库,建立在 TensorFlow、PyTorch 和 Jax 等深度学习框架之上。transformers 库专为快速加载和使用预训练的 Transformer 模型而设计。在这个使用案例中,我们首先处理了数据,并创建了一个 tf.data.Dataset,用于分批流式传输数据。然后,我们在这些数据上训练了模型,并在测试集上进行了评估。最后,我们使用该模型推断给定示例问题的答案。
在下一章中,我们将更深入地了解 Transformer 及其在一个更复杂任务中的应用——图像与文本结合的任务:图像标题生成。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,与志同道合的人一起学习,超过 1000 名成员等你加入:packt.link/nlp
*
第十一章:使用 Transformer 进行图像标题生成
Transformer 模型改变了许多 NLP 问题的解决方式。与之前的主流模型 RNN 模型相比,它们通过显著的优势重新定义了当前的技术水平。我们已经研究过 Transformer,并理解了它们的工作原理。Transformer 可以访问整个序列的所有项(例如,一个 token 序列),而 RNN 模型一次只查看一个项,这使得 Transformer 更适合解决序列问题。在 NLP 领域取得成功之后,研究人员已经成功地将 Transformer 应用于计算机视觉问题。在这里,我们将学习如何使用 Transformer 来解决一个涉及图像和文本的多模态问题:图像标题生成。
自动图像标题生成,或称图像注释,具有广泛的应用场景。一个最突出的应用是搜索引擎中的图像检索。自动图像标题生成可以用于根据用户的请求,检索属于某一特定类别(例如,猫)的所有图像。另一个应用可能是在社交媒体中,当用户上传一张图像时,图像会自动生成标题,用户可以选择修改生成的标题或直接发布原始标题。
在本章中,我们将学习如何使用机器学习为图像生成标题,训练一个模型,在给定图像时生成一个 token 序列(即标题)。我们将首先了解 Transformer 模型如何在计算机视觉中应用,然后扩展我们的理解,解决为图像生成标题的问题。为了生成图像标题,我们将使用一个广泛应用于图像标题生成任务的流行数据集,称为 Microsoft Common Objects in Context(MS-COCO)。
解决这个问题需要两个 Transformer 模型:一个用于生成图像表示,另一个用于生成相关的标题。一旦图像表示生成完成,它将作为其中一个输入传递给基于文本的 Transformer 模型。基于文本的 Transformer 模型将被训练以预测给定当前标题的情况下,在特定时间步长下标题中下一个 token。
我们将生成三个数据集:训练集、验证集和测试集。我们使用训练集来训练模型,验证集用于在训练过程中监控模型表现,最后使用测试集为一组未见过的图像生成标题。
从非常高层次来看图像标题生成流程,我们有两个主要组件:
-
一个预训练的视觉 Transformer 模型,它接受图像并生成该图像的 1D 隐藏表示
-
一个基于文本的 Transformer 解码器模型,它可以将隐藏的图像表示解码成一系列 token ID
我们将使用一个预训练的 Transformer 模型来生成图像表示。这个模型被称为视觉 Transformer(ViT),它已经在 ImageNet 数据集上进行了训练,并且在 ImageNet 分类任务中取得了优异的表现。
本章将重点讨论以下主要主题:
-
了解数据
-
下载数据
-
处理和标记数据
-
定义
tf.data.Dataset -
图像字幕生成的机器学习流程
-
使用 TensorFlow 实现模型
-
训练模型
-
定量评估结果
-
评估模型
-
为测试图像生成的字幕
了解数据
首先让我们了解我们所使用的数据,既包括直接使用的,也包括间接使用的。我们将依赖两个数据集:
-
ILSVRC ImageNet 数据集 (
image-net.org/download) -
MS-COCO 数据集 (
cocodataset.org/#download)
我们不会直接使用第一个数据集,但它对字幕学习至关重要。这个数据集包含图像及其相应的类别标签(例如,猫、狗和汽车)。我们将使用一个已经在这个数据集上训练好的 CNN,因此我们无需从头开始下载和训练该数据集。接下来我们将使用 MS-COCO 数据集,它包含图像及其相应的字幕。我们将通过将图像映射到一个固定大小的特征向量,使用 Vision Transformer,然后使用基于文本的 Transformer 将该向量映射到相应的字幕(我们稍后会详细讨论这一过程)。
ILSVRC ImageNet 数据集
ImageNet 是一个包含大量图像(约 100 万张)及其相应标签的图像数据集。这些图像属于 1,000 个不同的类别。该数据集非常具有表现力,几乎包含了我们想为其生成字幕的所有图像中的对象。图 11.1 展示了 ImageNet 数据集中一些可用的类别:

图 11.1:ImageNet 数据集的一个小样本
ImageNet 是一个很好的训练数据集,用于获取生成字幕所需的图像编码。我们说我们间接使用这个数据集,因为我们将使用一个在这个数据集上预训练的 Transformer。因此,我们自己不会下载或在这个数据集上训练模型。
MS-COCO 数据集
现在我们将转向我们实际使用的数据集,它被称为MS-COCO(即Microsoft - Common Objects in Context的缩写)。我们将使用 2014 年的训练数据集和 2017 年的验证数据集。我们使用不同时期的数据集,以避免在本练习中使用大型数据集。如前所述,该数据集包含图像及其相应的描述。数据集非常庞大(例如,训练数据集包含约 120,000 个样本,大小超过 15GB)。数据集每年更新一次,并举行竞赛,表彰那些在此数据集上取得最先进成绩的团队。在目标是达到最先进的性能时,使用完整数据集很重要。然而,在我们这种情况中,我们希望学习一个合理的模型,能够一般性地推测图像中有什么。因此,我们将使用较小的数据集(约 40,000 张图像和约 200,000 个描述)来训练我们的模型。图 11.2展示了可用的一些样本:

图 11.2:MS-COCO 数据集的小样本
为了训练和测试我们的端到端图像描述生成模型,我们将使用 2017 年的验证数据集,数据集可以从官方的 MS-COCO 数据集网站获取。
注意
实际操作中,您应使用单独的数据集进行测试和验证,以避免在测试过程中数据泄露。使用相同的数据进行验证和测试可能导致模型错误地表示其在现实世界中的泛化能力。
在图 11.3中,我们可以看到验证集中一些图像的样本。这些是从验证集中精心挑选的例子,代表了各种不同的物体和场景:

图 11.3:我们将用来测试算法生成图像描述能力的未见图像
下载数据
我们将使用的 MS-COCO 数据集相当大。因此,我们将手动下载这些数据集。为此,请按照以下说明操作:
-
在
Ch11-Image-Caption-Generation文件夹中创建一个名为data的文件夹 -
下载 2014 年的训练图像集(
images.cocodataset.org/zips/train2014.zip),该集包含 83K 张图像(train2014.zip) -
下载 2017 年的验证图像集(
images.cocodataset.org/zips/val2017.zip),该集包含 5K 张图像(val2017.zip) -
下载 2014 年(
annotations_trainval2014.zip)(images.cocodataset.org/annotations/annotations_trainval2014.zip)和 2017 年(annotations_trainval2017.zip)(images.cocodataset.org/annotations/annotations_trainval2017.zip)的注释集 -
将下载的压缩文件复制到
Ch11-Image-Caption-Generation/data文件夹中 -
使用 Extract to 选项解压缩 zip 文件,使其在子文件夹内解压缩内容
完成上述步骤后,你应该会有以下子文件夹:
-
data/train2014– 包含训练图像 -
data/annotations_trainval2014– 包含训练图像的标题 -
data/val2017– 包含验证图像 -
data/annotations_trainval2017– 包含验证图像的标题
数据处理和标记化
在数据下载并放入正确的文件夹后,让我们定义包含所需数据的目录:
trainval_image_dir = os.path.join('data', 'train2014', 'train2014')
trainval_captions_dir = os.path.join('data', 'annotations_trainval2014', 'annotations')
test_image_dir = os.path.join('data', 'val2017', 'val2017')
test_captions_dir = os.path.join('data', 'annotations_trainval2017', 'annotations')
trainval_captions_filepath = os.path.join(trainval_captions_dir, 'captions_train2014.json')
test_captions_filepath = os.path.join(test_captions_dir, 'captions_val2017.json')
这里我们定义了包含训练和测试图像的目录,以及包含训练和测试图像标题的 JSON 文件路径。
数据预处理
接下来的步骤是将训练集分割为训练集和验证集。我们将使用原始数据集的 80% 作为训练数据,20% 作为验证数据(随机选择):
all_filepaths = np.array([os.path.join(trainval_image_dir, f) for f in os.listdir(trainval_image_dir)])
rand_indices = np.arange(len(all_filepaths))
np.random.shuffle(rand_indices)
split = int(len(all_filepaths)*0.8)
train_filepaths, valid_filepaths = all_filepaths[rand_indices[:split]], all_filepaths[rand_indices[split:]]
我们可以打印数据集的大小,看看我们得到了什么:
print(f"Train dataset size: {len(train_filepaths)}")
print(f"Valid dataset size: {len(valid_filepaths)}")
这将打印:
Train dataset size: 66226
Valid dataset size: 16557
现在,让我们读取标题并使用它们创建一个 pandas DataFrame。我们的 DataFrame 将包含四个重要的列:
-
image_id– 标识图像(用于生成文件路径) -
image_filepath– 由image_id标识的图像文件位置 -
caption– 原始标题 -
preprocessed_caption– 经简单预处理后的标题
首先,我们将加载 JSON 文件中的数据,并将其导入到 DataFrame 中:
with open(trainval_captions_filepath, 'r') as f:
trainval_data = json.load(f)
trainval_captions_df = pd.json_normalize(trainval_data, "annotations")
我们在文件中寻找的数据位于一个名为 "annotations" 的键下。在 "annotations" 下,我们有一个字典列表,每个字典包含 image_id、id 和 caption。函数 pd.json_normalize() 接受加载的数据并将其转换为 pd.DataFrame。
然后,我们通过将根目录路径前缀加到 image_id 上,并附加扩展名 .jpg 来创建名为 image_filepath 的列。
我们只保留 image_filepath 值在我们存储在 train_filepaths 中的训练图像中的数据点:
trainval_captions_df["image_filepath"] = trainval_captions_df["image_id"].apply(
lambda x: os.path.join(trainval_image_dir,
'COCO_train2014_'+format(x, '012d')+'.jpg')
)
train_captions_df = trainval_captions_df[trainval_captions_df["image_filepath"].isin(train_filepaths)]
我们现在定义一个名为 preprocess_captions() 的函数,用来处理原始标题:
def preprocess_captions(image_captions_df):
""" Preprocessing the captions """
image_captions_df["preprocessed_caption"] = "[START] " +
image_captions_df["caption"].str.lower().str.replace('[^\w\s]','')
+ " [END]"
return image_captions_df
在上面的代码中,我们:
-
添加了两个特殊标记
[START]和[END],分别表示每个标题的开始和结束 -
将标题转换为小写
-
移除所有非单词、字符或空格的内容
然后我们在训练数据集上调用这个函数:
train_captions_df = preprocess_captions(train_captions_df)
然后我们对验证数据和测试数据执行类似的过程:
valid_captions_df = trainval_captions_df[
trainval_captions_df[
"image_filepath"
].isin(valid_filepaths)
]
valid_captions_df = preprocess_captions(valid_captions_df)
with open(test_captions_filepath, 'r') as f:
test_data = json.load(f)
test_captions_df = pd.json_normalize(test_data, "annotations")
test_captions_df["image_filepath"] = test_captions_df["image_id"].apply(
lambda x: os.path.join(test_image_dir, format(x, '012d')+'.jpg')
)
test_captions_df = preprocess_captions(test_captions_df)
让我们查看 training_captions_df 中的数据(图 11.4):

图 11.4:training_captions_df 中的数据
这些数据展示了重要信息,例如图像在文件结构中的位置、原始标题和预处理后的标题。
让我们也分析一些关于图像的统计信息。我们将从训练数据集中取出前 1,000 张图像的小样本,并查看图像的大小:
n_samples = 1000
train_image_stats_df = train_captions_df.loc[:n_samples, "image_filepath"].apply(lambda x: Image.open(x).size)
train_image_stats_df = pd.DataFrame(train_image_stats_df.tolist(), index=train_image_stats_df.index)
train_image_stats_df.describe()
这将产生 图 11.5:

图 11.5:训练数据集中图像大小的统计信息
我们可以看到大多数图像的分辨率为 640x640。稍后我们需要将图像调整为 224x224,以匹配模型的输入要求。我们还可以查看词汇表大小:
train_vocabulary = train_captions_df["preprocessed_caption"].str.split(" ").explode().value_counts()
print(len(train_vocabulary[train_vocabulary>=25]))
这将打印:
3629
这告诉我们,有 3,629 个词在训练数据集中至少出现了 25 次。我们将这个作为词汇表的大小。
分词数据
由于我们正在开发 Transformer 模型,我们需要一个强大的分词器,类似于 BERT 等流行模型使用的分词器。Hugging Face 的tokenizers库为我们提供了一系列易于使用的分词器。让我们了解如何使用这些分词器之一来满足我们的需求。你可以通过以下方式导入它:
from tokenizers import BertWordPieceTokenizer
接下来,让我们定义BertWordPieceTokenizer。我们在定义时将传递以下参数:
-
unk_token– 定义一个用于表示词汇表外(OOV)词汇的标记 -
clean_text– 是否进行简单的预处理步骤以清理文本 -
lowercase– 是否将文本转换为小写
以下是这些参数:
# Initialize an empty BERT tokenizer
tokenizer = BertWordPieceTokenizer(
unk_token="[UNK]",
clean_text=False,
lowercase=False,
)
定义好分词器后,我们可以调用train_from_iterator()函数来在我们的数据集上训练分词器:
tokenizer.train_from_iterator(
train_captions_df["preprocessed_caption"].tolist(),
vocab_size=4000,
special_tokens=["[PAD]", "[UNK]", "[START]", "[END]"]
)
train_from_iterator()函数接受多个参数:
-
iterator– 一个可迭代对象,每次产生一个字符串(包含标题)。 -
vocab_size– 词汇表的大小。 -
special_tokens– 我们数据中将使用的特殊标记。具体来说,我们使用[PAD](表示填充),[UNK](表示 OOV 标记),[START](表示开始)和[END](表示结束)。这些标记将从 0 开始分配较低的 ID。
一旦分词器训练完成,我们可以使用它将文本字符串转换为标记序列。让我们使用训练好的分词器将几个示例句子转换为标记序列:
# Encoding a sentence
example_captions = valid_captions_df["preprocessed_caption"].iloc[:10].tolist()
example_tokenized_captions = tokenizer.encode_batch(example_captions)
for caption, tokenized_cap in zip(example_captions, example_tokenized_captions):
print(f"{caption} -> {tokenized_cap.tokens}")
这将输出:
[START] an empty kitchen with white and black appliances [END] -> ['[START]', 'an', 'empty', 'kitchen', 'with', 'white', 'and', 'black', 'appliances', '[END]']
[START] a white square kitchen with tile floor that needs repairs [END] -> ['[START]', 'a', 'white', 'square', 'kitchen', 'with', 'tile', 'floor', 'that', 'need', '##s', 'rep', '##air', '##s', '[END]']
[START] a few people sit on a dim transportation system [END] -> ['[START]', 'a', 'few', 'people', 'sit', 'on', 'a', 'dim', 'transport', '##ation', 'system', '[END]']
[START] a person protected from the rain by their umbrella walks down the road [END] -> ['[START]', 'a', 'person', 'prote', '##cted', 'from',
'the', 'rain', 'by', 'their', 'umbrella', 'walks', 'down', 'the', 'road', '[END]']
[START] a white kitchen in a home with the light on [END] -> ['[START]', 'a', 'white', 'kitchen', 'in', 'a', 'home', 'with', 'the', 'light', 'on', '[END]']
你可以看到分词器如何学习自己的词汇,并且正在对字符串句子进行分词。前面带有##的词汇表示它们必须与前面的标记(无空格)结合,才能得到最终结果。例如,来自标记'image','cap'和'##tion'的最终字符串是'image caption'。让我们看看我们定义的特殊标记映射到哪些 ID:
vocab = tokenizer.get_vocab()
for token in ["[UNK]", "[PAD]", "[START]", "[END]"]:
print(f"{token} -> {vocab[token]}")
这将输出:
[UNK] -> 1
[PAD] -> 0
[START] -> 2
[END] -> 3
现在让我们看看如何使用处理过的数据定义一个 TensorFlow 数据管道。
定义一个 tf.data.Dataset
现在让我们看看如何使用数据创建tf.data.Dataset。我们将首先编写一些辅助函数。也就是说,我们将定义:
-
parse_image()用来加载和处理来自filepath的图像 -
使用
generate_tokenizer()来生成一个基于传入数据训练的分词器
首先让我们讨论一下parse_image()函数。它需要三个参数:
-
filepath– 图像的位置 -
resize_height– 调整图像高度的目标值 -
resize_width– 调整图像宽度的目标值
该函数定义如下:
def parse_image(filepath, resize_height, resize_width):
""" Reading an image from a given filepath """
# Reading the image
image = tf.io.read_file(filepath)
# Decode the JPEG, make sure there are 3 channels in the output
image = tf.io.decode_jpeg(image, channels=3)
image = tf.image.convert_image_dtype(image, tf.float32)
# Resize the image to 224x224
image = tf.image.resize(image, [resize_height, resize_width])
# Bring pixel values to [-1, 1]
image = image*2.0 - 1.0
return image
我们主要依赖tf.image函数来加载和处理图像。这个函数具体来说:
-
从
filepath读取图像 -
解码 JPEG 图像中的字节为
uint8张量,并转换为float32 dtype张量。
在这些步骤结束后,我们将得到一个像素值介于 0 和 1 之间的图像。接下来,我们:
-
将图像调整为给定的高度和宽度
-
最后,将图像归一化,使得像素值介于-1 和 1 之间(这符合我们将要使用的 ViT 模型的要求)
基于此,我们定义了第二个辅助函数。这个函数封装了我们之前讨论过的BertWordPieceTokenizer的功能:
def generate_tokenizer(captions_df, n_vocab):
""" Generate the tokenizer with given captions """
# Define the tokenizer
tokenizer = BertWordPieceTokenizer(
unk_token="[UNK]",
clean_text=False,
lowercase=False,
)
# Train the tokenizer
tokenizer.train_from_iterator(
captions_df["preprocessed_caption"].tolist(),
vocab_size=n_vocab,
special_tokens=["[PAD]", "[UNK]", "[START]", "[END]"]
)
return tokenizer
有了这些,我们可以定义我们的主数据函数,以生成 TensorFlow 数据管道:
def generate_tf_dataset(
image_captions_df, tokenizer=None, n_vocab=5000, pad_length=33, batch_size=32, training=False
):
""" Generate the tf.data.Dataset"""
# If the tokenizer is not available, create one
if not tokenizer:
tokenizer = generate_tokenizer(image_captions_df, n_vocab)
# Get the caption IDs using the tokenizer
image_captions_df["caption_token_ids"] = [enc.ids for enc in
tokenizer.encode_batch(image_captions_df["preprocessed_caption"])]
vocab = tokenizer.get_vocab()
# Add the padding to short sentences and truncate long ones
image_captions_df["caption_token_ids"] =
image_captions_df["caption_token_ids"].apply(
lambda x: x+[vocab["[PAD]"]]*(pad_length - len(x) + 2) if
pad_length + 2 >= len(x) else x[:pad_length + 1] + [x[-1]]
)
# Create a dataset with images and captions
dataset = tf.data.Dataset.from_tensor_slices({
"image_filepath": image_captions_df["image_filepath"],
"caption_token_ids":
np.array(image_captions_df["caption_token_ids"].tolist())
})
# Each sample in our dataset consists of (image, caption token
# IDs, position IDs), (caption token IDs offset by 1)
dataset = dataset.map(
lambda x: (
(parse_image(x["image_filepath"], 224, 224),
x["caption_token_ids"][:-1], tf.range(pad_length+1,
dtype='float32')), x["caption_token_ids"]
)
)
# Shuffle and batch data in the training mode
if training:
dataset = dataset.shuffle(buffer_size=batch_size*10)
dataset = dataset.batch(batch_size)
return dataset, tokenizer
该函数接受以下参数:
-
image_captions_df– 一个包含图像文件路径和处理过的标题的 pandas DataFrame -
tokenizer– 可选的分词器,用于对标题进行分词 -
n_vocab– 词汇表大小 -
pad_length– 填充标题的长度 -
batch_size– 批处理数据时的批量大小 -
training– 数据管道是否应该以训练模式运行。在训练模式下,我们会打乱数据,反之则不会
首先,如果没有传递新的分词器,该函数会生成一个分词器。接下来,我们在我们的 DataFrame 中创建一个名为“caption_token_ids”的列,这个列是通过调用分词器的encode_batch()函数对preprocessed_caption列进行编码而生成的。然后我们对caption_token_ids列进行填充。如果标题的长度小于pad_length,我们将添加[PAD]令牌 ID;如果标题长度超过pad_length,则进行截断。然后我们使用from_tensor_slices()函数创建一个tf.data.Dataset。
这个数据集中的每个样本将是一个字典,字典的键为image_filepath和caption_token_ids,值为相应的值。一旦我们完成这个步骤,就拥有了获取实际数据的基础。我们将调用tf.data.Dataset.map()函数来:
-
对每个
image_filepath调用parse_image()以生成实际图像 -
返回所有标题令牌 ID,除了最后一个,作为输入
-
范围从 0 到令牌的数量,表示每个输入令牌 ID 的位置(用于获取 Transformer 的位置信息嵌入)
-
返回所有的标题令牌 ID 作为目标
让我们通过一个例子来理解输入和输出的样子。假设你有一个标题 a brown bear。下面是我们 Transformer 解码器中输入和输出的样子(图 11.6):

图 11.6:模型输入和目标的组织方式
最后,如果处于训练模式,我们使用batch_size的 10 倍作为buffer_size来打乱数据集。然后我们使用在调用函数时提供的batch_size来批处理数据集。让我们在我们的训练数据集上调用这个函数,看看得到的结果:
n_vocab=4000
batch_size=2
sample_dataset, sample_tokenizer = generate_tf_dataset(train_captions_df, n_vocab=n_vocab, pad_length=10, batch_size=batch_size, training=True)
for i in sample_dataset.take(1):
print(i)
它将输出:
(
(
<tf.Tensor: shape=(2, 224, 224, 3), dtype=float32, numpy=
array([[[[-0.2051357 , -0.22082198, -0.31493968],
[-0.2015593 , -0.21724558, -0.31136328],
[-0.17017174, -0.18585801, -0.2799757 ],
...,
[-0.29620153, -0.437378 , -0.6155298 ],
[-0.28843057, -0.41392076, -0.6178423 ],
[-0.29654706, -0.43772352, -0.62483776]],
[[-0.8097613 , -0.6725868 , -0.55734015],
[-0.7580646 , -0.6420185 , -0.55782473],
[-0.77606916, -0.67418844, -0.5419755 ],
...,
[-0.6400192 , -0.4753132 , -0.24786222],
[-0.70908225, -0.5426947 , -0.31580424],
[-0.7206869 , -0.5324516 , -0.3128438 ]]]], dtype=float32)>,
<tf.Tensor: shape=(2, 11), dtype=int32, numpy=
array([[ 2, 24, 356, 114, 488, 1171, 1037, 2820, 566, 445, 116],
[ 2, 24, 1357, 2142, 63, 1473, 495, 282, 116, 24, 301]])>,
<tf.Tensor: shape=(2, 11), dtype=float32, numpy=
array([[ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10.],
[ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10.]],
dtype=float32)>
),
<tf.Tensor: shape=(2, 12), dtype=int32, numpy=
array([[ 2, 24, 356, 114, 488, 1171, 1037, 2820, 566, 445, 116,
3],
[ 2, 24, 1357, 2142, 63, 1473, 495, 282, 116, 24, 301,
3]])>
)
在这里,我们可以看到输入和输出被组织成一个嵌套的元组。它的格式是((image, input caption token IDs, position IDs), target caption token IDs)。例如,我们已经生成了一个批量大小为 2、填充长度为 10、词汇表大小为 4,000 的数据管道。我们可以看到,图像批次的形状是[2, 224, 224, 3],输入标题的 token ID 和位置 ID 的形状是[2, 11],最后,目标标题的 token ID 的形状是[2, 12]。需要注意的是,我们使用了一个额外的缓冲区来处理填充长度,以包含[START]和[END]标签。因此,最终得到的张量使用了标题长度为 12(即 10+2)。这里最重要的是注意输入和目标标题的长度。输入标题比目标标题少一个项目,正如长度所示。这是因为输入标题中的第一个项目是图像特征向量。这使得输入 tokens 的长度等于目标 tokens 的长度。
在解决了数据管道之后,我们将讨论我们将使用的模型的机制。
图像标题生成的机器学习流程
在这里,我们将从一个非常高层次的角度来看图像标题生成流程,然后逐步分析,直到我们拥有完整的模型。图像标题生成框架由两个主要部分组成:
-
一个预训练的视觉 Transformer 模型,用于生成图像表示。
-
一个基于文本的解码器模型,可以将图像表示解码为一系列的 token ID。该模型使用文本分词器将 tokens 转换为 token ID,反之亦然。
尽管 Transformer 模型最初是用于基于文本的 NLP 问题,但它们已经超越了文本数据领域,应用于图像数据和音频数据等其他领域。
在这里,我们将使用一个可以处理图像数据的 Transformer 模型和一个可以处理文本数据的 Transformer 模型。
视觉 Transformer(ViT)
首先,让我们来看一下生成图像编码向量表示的 Transformer 模型。我们将使用一个预训练的视觉 Transformer(ViT)来实现这一点。该模型已经在我们之前讨论过的 ImageNet 数据集上进行了训练。接下来,让我们了解一下该模型的架构。
最初,ViT 是由 Dosovitskiy 等人在论文《An Image is Worth 16X16 Words: Transformers for Image Recognition at Scale》中提出的(arxiv.org/pdf/2010.11929.pdf)。这可以被视为将 Transformer 应用于计算机视觉问题的第一个重要步骤。这个模型被称为视觉 Transformer 模型。
该方法的思路是将图像分解为 16x16 的小块,并将每一块视为一个独立的标记。每个图像块被展平为一个 1D 向量,并通过类似于原始 Transformer 的位置信息编码机制对其位置进行编码。但图像是二维结构;仅使用 1D 位置信息而没有 2D 位置信息是否足够呢?作者认为,1D 位置信息足够,而 2D 位置信息并未带来显著的提升。一旦图像被分解为 16x16 的块并展平,每张图像就可以像文本输入序列一样表示为一系列标记(图 11.7)。
然后,模型以自监督的方式进行预训练,使用名为 JFT-300M 的视觉数据集(paperswithcode.com/dataset/jft-300m)。该论文提出了一种优雅的方式,使用图像数据对 ViT 进行半监督训练。类似于 NLP 问题中将文本单元表示为标记的方式,图像的一个“标记”是图像的一块(即一系列连续的值,这些值是标准化的像素)。然后,ViT 被预训练以预测给定图像块的平均 3 位 RGB 颜色。每个通道(即红色、绿色和蓝色)用 3 位表示(每个位的值为 0 或 1),这提供了 512 种可能性或类别。换句话说,对于给定的图像,图像块(类似于 NLP 中的标记)会被随机遮蔽(采用与 BERT 相同的方法),然后模型被要求预测该图像块的平均 3 位 RGB 颜色。
经过预训练后,模型可以通过在 ViT 上添加分类头或回归头进行任务特定问题的微调,就像 BERT 一样。ViT 在序列的开头也有[CLS]标记,它将作为下游视觉模型的输入表示,这些视觉模型被接入 ViT 之上。
图 11.7展示了 ViT 的机制:

图 11.7:视觉 Transformer 模型
我们将在这里使用的模型源自 Steiner 等人的论文如何训练你的 ViT?视觉 Transformer 中的数据、增强和正则化(arxiv.org/pdf/2106.10270.pdf)。该论文提出了几种 ViT 模型的变体。具体来说,我们将使用 ViT-S/16 架构。ViT-S 是第二小的 ViT 模型,包含 12 层,隐藏输出维度为 384;总共拥有 22.2M 个参数。这里的数字 16 意味着该模型是在 16x16 的图像块上进行训练的。该模型已通过我们之前讨论的 ImageNet 数据集进行了微调。我们将使用模型的特征提取部分进行图像字幕生成。
基于文本的解码器 Transformer
基于文本的解码器的主要目的是根据前面的标记预测序列中的下一个标记。这个解码器与我们在上一章使用的 BERT 大致相同。让我们回顾一下 Transformer 模型的组成部分。Transformer 由多个堆叠的层组成。每一层都有:
-
自注意力层 – 通过接收输入标记并关注序列中其他位置的标记,为每个标记位置生成一个隐藏表示
-
一个全连接子网络 – 通过将自注意力层的输出通过两个全连接层传播,生成逐元素的非线性隐藏表示
除了这些,网络还使用残差连接和层归一化技术来提高性能。谈到输入时,模型使用两种类型的输入嵌入来告知模型:
-
标记嵌入 – 每个标记都用一个与模型共同训练的嵌入向量表示
-
位置嵌入 – 每个标记位置通过一个 ID 和该位置的相应嵌入来表示
与我们在上一章使用的 BERT 相比,我们模型中的一个关键区别是自注意力机制的使用方式。使用 BERT 时,自注意力层能够以双向方式进行注意(即同时关注当前输入两侧的标记)。然而,在基于解码器的模型中,它只能关注当前标记左侧的标记。换句话说,注意力机制只能访问当前输入之前看到的输入。
将所有内容整合在一起
现在我们来学习如何将这两个模型结合起来。我们将使用以下程序来训练端到端模型:
-
我们通过 ViT 模型生成图像编码。它为图像生成 384 个项目的单一表示。
-
这个表示连同所有标题标记(除了最后一个)一起作为输入送入解码器。
-
给定当前输入标记,解码器预测下一个标记。在这个过程结束时,我们将得到完整的图像标题。
将 ViT 和文本解码器模型连接的另一个替代方法是通过提供直接访问 ViT 完整序列的编码器输出作为解码器注意力机制的一部分。在这项工作中,为了不让讨论过于复杂,我们仅使用 ViT 模型的一个输出作为解码器的输入。
使用 TensorFlow 实现模型
我们现在将实现刚才学习的模型。首先让我们导入一些内容:
import tensorflow_hub as hub
import tensorflow as tf
import tensorflow.keras.backend as K
实现 ViT 模型
接下来,我们将从 TensorFlow Hub 下载预训练的 ViT 模型。我们将使用 Sayak Paul 提交的模型。该模型可在tfhub.dev/sayakpaul/vit_s16_fe/1找到。你还可以查看其他 Vision Transformer 模型,网址是tfhub.dev/sayakpaul/collections/vision_transformer/1。
image_encoder = hub.KerasLayer("https://tfhub.dev/sayakpaul/vit_s16_fe/1", trainable=False)
然后我们定义一个输入层来输入图像,并将其传递给image_encoder以获取该图像的最终特征向量:
image_input = tf.keras.layers.Input(shape=(224, 224, 3))
image_features = image_encoder(image_input)
你可以通过运行以下代码来查看最终图像表示的大小:
print(f"Final representation shape: {image_features.shape}")
该代码将输出:
Final representation shape: (None, 384)
接下来我们将详细了解如何实现基于文本的 Transformer 模型,它将输入图像表示以生成图像描述。
实现基于文本的解码器
在这里,我们将从头开始实现一个 Transformer 解码器模型。这与我们之前使用 Transformer 模型的方式不同,当时我们下载了一个预训练模型并使用它。
在我们实现模型之前,我们将实现两个自定义的 Keras 层:一个用于自注意力机制,另一个用于捕获 Transformer 模型中单层的功能。我们从自注意力层开始。
定义自注意力层
在这里,我们使用 Keras 子类化 API 定义自注意力层:
class SelfAttentionLayer(tf.keras.layers.Layer):
""" Defines the computations in the self attention layer """
def __init__(self, d):
super(SelfAttentionLayer, self).__init__()
# Feature dimensionality of the output
self.d = d
def build(self, input_shape):
# Query weight matrix
self.Wq = self.add_weight(
shape=(input_shape[-1], self.d),
initializer='glorot_uniform',
trainable=True, dtype='float32'
)
# Key weight matrix
self.Wk = self.add_weight(
shape=(input_shape[-1], self.d),
initializer='glorot_uniform',
trainable=True, dtype='float32'
)
# Value weight matrix
self.Wv = self.add_weight(
shape=(input_shape[-1], self.d),
initializer='glorot_uniform',
trainable=True, dtype='float32'
)
def call(self, q_x, k_x, v_x, mask=None):
q = tf.matmul(q_x,self.Wq) #[None, t, d]
k = tf.matmul(k_x,self.Wk) #[None, t, d]
v = tf.matmul(v_x,self.Wv) #[None, t, d]
# Computing the final output
h = tf.keras.layers.Attention(causal=True)([
q, #q
v, #v
k, #k
], mask=[None, mask])
# [None, t, t] . [None, t, d] => [None, t, d]
return h
在这里,我们必须填充三个函数的逻辑:
-
__init__()和__build__()– 定义特定于层初始化的各种超参数和逻辑 -
call()– 在调用该层时需要进行的计算
我们可以看到,我们将注意力输出的维度d作为参数传递给__init__()方法。接着,在__build__()方法中,我们定义了三个权重矩阵,Wq、Wk和Wv。如果你记得我们在上一章的讨论,这些分别代表查询、键和值的权重。
最后,在call方法中我们有逻辑。它接受四个输入:查询、键、值输入和一个可选的值掩码。然后,我们通过与相应的权重矩阵Wq、Wk和Wv相乘来计算潜在的q、k和v。为了计算注意力,我们将使用现成的层tf.keras.layers.Attention。我们在第九章《序列到序列学习——神经机器翻译*》中使用了类似的层来计算 Bahdanau 注意力机制。tf.keras.layers.Attention()层有几个参数,其中一个我们关心的是设置causal=True。
这样做的目的是指示该层将当前令牌右侧的令牌进行屏蔽。这本质上防止了解码器泄漏关于未来令牌的信息。接下来,层在调用过程中会接受以下参数:
-
inputs– 包含查询、值和键的输入列表,按此顺序排列 -
mask– 包含查询和值的掩码的两个项的列表
最后返回注意力层的输出 h。接下来,我们将实现 Transformer 层的计算。
定义 Transformer 层
使用自注意力层,我们可以捕捉单个 Transformer 层中的计算过程。它使用自注意力、全连接层和其他优化技术来计算输出:
class TransformerDecoderLayer(tf.keras.layers.Layer):
""" The Decoder layer """
def __init__(self, d, n_heads):
super(TransformerDecoderLayer, self).__init__()
# Feature dimensionality
self.d = d
# Dimensionality of a head
self.d_head = int(d/n_heads)
# Number of heads
self.n_heads = n_heads
# Actual attention heads
self.attn_heads = [SelfAttentionLayer(self.d_head) for i in
range(self.n_heads)]
# Fully connected layers
self.fc1_layer = tf.keras.layers.Dense(512, activation='relu')
self.fc2_layer = tf.keras.layers.Dense(d)
self.add_layer = tf.keras.layers.Add()
self.norm1_layer = tf.keras.layers.LayerNormalization()
self.norm2_layer = tf.keras.layers.LayerNormalization()
def _compute_multihead_output(self, x):
""" Computing the multi head attention output"""
outputs = [head(x, x, x) for head in self.attn_heads]
outputs = tf.concat(outputs, axis=-1)
return outputs
def call(self, x):
# Multi head attention layer output
h1 = self._compute_multihead_output(x)
h1_add = self.add_layer([x, h1])
h1_norm = self.norm1_layer(h1_add)
# Fully connected outputs
h2_1 = self.fc1_layer(h1_norm)
h2_2 = self.fc2_layer(h2_1)
h2_add = self.add_layer([h1, h2_2])
h2_norm = self.norm2_layer(h2_add)
return h2_norm
TransformerDecoderLayer 执行以下步骤:
-
使用给定的输入,该层计算多头注意力输出。多头注意力输出通过计算几个较小头的注意力输出并将这些输出连接到单个输出 (
h1)。 -
接下来我们将原始输入
x加到h1中形成残差连接 (h1_add)。 -
接着进行层归一化步骤来归一化 (
h1_norm)。 -
h1_norm经过全连接层生成h2_1。 -
h2_1经过另一个全连接层生成h2_2。 -
然后我们通过将
h1和h2_2相加创建另一个残差连接来产生h2_add。 -
最后,我们执行层归一化以生成
h2_norm,这是此自定义层的最终输出。
定义完整的解码器
当所有实用程序层实现后,我们可以实现文本解码器。我们将定义两个输入层。第一个接受一个令牌序列作为输入,第二个接受一个序列位置(基于 0 索引),以表示每个令牌的位置。您可以看到,两个层都被定义为能够接受任意长度的序列作为输入。这将在推理过程中起重要作用:。
caption_input = tf.keras.layers.Input(shape=(None,))
position_input = tf.keras.layers.Input(shape=(None,))
接下来我们定义嵌入。我们的嵌入向量长度为 384,以匹配 ViT 模型的输出维度。我们定义了两个嵌入层:token 嵌入层和位置嵌入层:
d_model = 384
# Token embeddings
input_embedding = tf.keras.layers.Embedding(len(tokenizer.get_vocab()), d_model, mask_zero=True)
令牌嵌入层的工作方式与我们多次见过的一样。它为序列中的每个令牌生成一个嵌入向量。我们用 ID 0 掩盖输入,因为它们表示填充的令牌。接下来让我们了解如何实现位置嵌入:
position_embedding = tf.keras.layers.Lambda(
lambda x: tf.where(
tf.math.mod(tf.repeat(tf.expand_dims(x, axis=-1), d_model,
axis=-1), 2)==0,
tf.math.sin(
tf.expand_dims(x, axis=-1) /
10000**(2*tf.reshape(tf.range(d_model,
dtype='float32'),[1,1, -1])/d_model)
),
tf.math.cos(
tf.expand_dims(x, axis=-1) /
10000**(2*tf.reshape(tf.range(d_model,
dtype='float32'),[1,1, -1])/d_model)
)
)
)
我们已经讨论了如何计算位置嵌入。原始 Transformer 论文使用以下方程式生成位置嵌入:


这里 pos 表示序列中的位置,i 表示第 i^(th) 个特征维度 (0< i<d_model)。偶数特征使用正弦函数,奇数特征使用余弦函数。计算这一层需要一些工作。让我们慢慢分解这个逻辑。首先我们计算以下两个张量(为了方便我们用 x 和 y 表示):
x = PE(pos, i) = sin(pos/10000**(2i/d))
y = PE(pos, i) = cos(pos/10000**(2i/d))
我们使用tf.where(cond, x, y)函数,根据与cond的布尔矩阵的大小相同,按元素从x和y中选择值。对于某一位置,如果cond为True,选择x;如果cond为False,选择y。这里我们使用条件pos%2 == 0,这会对偶数位置返回True,对奇数位置返回False。
为了确保我们生成形状正确的张量,我们利用了 TensorFlow 的广播能力。
让我们稍微了解一下广播是如何帮助的。来看一下计算:
tf.math.sin(
tf.expand_dims(x, axis=-1) /
10000**(2*tf.reshape(tf.range(d_model,
dtype='float32'),[1,1, -1])/d_model)
)
在这里,我们需要一个[batch size, time steps, d_model]大小的输出。tf.expand_dims(x, axis=-1)会生成一个[batch size, time steps, 1]大小的输出。10000**(2*tf.reshape(tf.range(d_model, dtype='float32'),[1,1, -1])/d_model)会生成一个[1, 1, d_model]大小的输出。将第一个输出除以第二个输出,得到一个大小为[batch size, time steps, d_model]的张量。这是因为 TensorFlow 的广播能力允许它在任意大小的维度和大小为 1 的维度之间执行操作。你可以想象 TensorFlow 将大小为 1 的维度复制 n 次,以执行与 n 大小维度的操作。但实际上,它这样做得更高效。
一旦令牌和位置嵌入被计算出来,我们将它们按元素相加,得到最终的嵌入:
embed_out = input_embedding(caption_input) + position_embedding(position_input)
如果你还记得,解码器的第一个输入是图像特征向量,后面跟着字幕令牌。因此,我们需要将image_features(由 ViT 产生)与embed_out拼接起来,得到完整的输入序列:
image_caption_embed_out = tf.keras.layers.Concatenate(axis=1)([tf.expand_dims(image_features,axis=1), embed_out])
然后我们定义四个 Transformer 解码器层,并计算这些层的隐藏输出:
out = image_caption_embed_out
for l in range(4):
out = TransformerDecoderLayer(d_model, 64)(out)
我们使用一个Dense层,具有n_vocab个输出节点,并采用softmax激活函数来计算最终输出:
final_out = tf.keras.layers.Dense(n_vocab, activation='softmax')(out)
最后,我们定义完整的模型。它接受以下输入:
-
image_input– 一批 224x224x3 大小的图像 -
caption_input– 字幕的令牌 ID(不包括最后一个令牌) -
position_input– 表示每个令牌位置的一批位置 ID
并将final_out作为输出:
full_model = tf.keras.models.Model(inputs=[image_input, caption_input, position_input], outputs=final_out)
full_model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics='accuracy')
full_model.summary()
现在我们已经定义了完整的模型(图 11.8):

图 11.8:代码参考叠加在完整模型的插图上
训练模型
现在数据管道和模型已定义,训练它就非常容易了。首先定义一些参数:
n_vocab = 4000
batch_size=96
train_fraction = 0.6
valid_fraction = 0.2
我们使用 4,000 的词汇量和 96 的批量大小。为了加速训练,我们只使用 60%的训练数据和 20%的验证数据。然而,你可以增加这些数据以获得更好的结果。然后我们得到在完整训练数据集上训练的分词器:
tokenizer = generate_tokenizer(
train_captions_df, n_vocab=n_vocab
)
接下来我们定义 BLEU 指标。这与第九章《序列到序列学习——神经机器翻译*中的 BLEU 计算相同,仅有一些小的差异。因此,我们在这里不再重复讨论。
bleu_metric = BLEUMetric(tokenizer=tokenizer)
在训练循环外采样较小的验证数据集,以保持数据集的恒定:
sampled_validation_captions_df = valid_captions_df.sample(frac=valid_fraction)
接下来,我们训练模型 5 个周期:
for e in range(5):
print(f"Epoch: {e+1}")
train_dataset, _ = generate_tf_dataset(
train_captions_df.sample(frac=train_fraction),
tokenizer=tokenizer, n_vocab=n_vocab, batch_size=batch_size,
training=True
)
valid_dataset, _ = generate_tf_dataset(
sampled_validation_captions_df, tokenizer=tokenizer,
n_vocab=n_vocab, batch_size=batch_size, training=False
)
full_model.fit(
train_dataset,
epochs=1
)
valid_loss, valid_accuracy, valid_bleu = [], [], []
for vi, v_batch in enumerate(valid_dataset):
print(f"{vi+1} batches processed", end='\r')
loss, accuracy = full_model.test_on_batch(v_batch[0],
v_batch[1])
batch_predicted = full_model(v_batch[0])
bleu_score =
bleu_metric.calculate_bleu_from_predictions(v_batch[1],
batch_predicted)
valid_loss.append(loss)
valid_accuracy.append(accuracy)
valid_bleu.append(bleu_score)
print(
f"\nvalid_loss: {np.mean(valid_loss)} - valid_accuracy:
{np.mean(valid_accuracy)} - valid_bleu: {np.mean(valid_bleu)}"
)
在每次迭代中,我们会生成train_dataset和valid_dataset。注意,训练集在每个周期内会随机采样,导致不同的数据点,而验证集是固定的。还要注意,我们将先前生成的 tokenizer 作为参数传递给数据管道函数。我们在循环中使用full_model.fit()函数,并用训练数据集对其进行单次训练。最后,我们遍历验证数据集的批次,计算每个批次的损失、准确率和 BLEU 值。然后,我们输出这些批次指标的平均值。输出结果如下所示:
Epoch: 1
2071/2071 [==============================] - 1945s 903ms/step - loss: 1.3344 - accuracy: 0.7625
173 batches processed
valid_loss: 1.1388846477332142 - valid_accuracy: 0.7819634135058849 - valid_bleu: 0.09385878526196685
Epoch: 2
2071/2071 [==============================] - 1854s 894ms/step - loss: 1.0860 - accuracy: 0.7878
173 batches processed
valid_loss: 1.090059520192229 - valid_accuracy: 0.7879036186058397 - valid_bleu: 0.10231472779803133
Epoch: 3
2071/2071 [==============================] - 1855s 895ms/step - loss:
1.0610 - accuracy: 0.7897
173 batches processed
valid_loss: 1.0627685799075 - valid_accuracy: 0.7899546606003205 - valid_bleu: 0.10398145099074609
Epoch: 4
2071/2071 [==============================] - 1937s 935ms/step - loss: 1.0479 - accuracy: 0.7910
173 batches processed
valid_loss: 1.0817485169179177 - valid_accuracy: 0.7879597275932401 - valid_bleu: 0.10308500219058511
Epoch: 5
2071/2071 [==============================] - 1864s 899ms/step - loss: 1.0244 - accuracy: 0.7937
173 batches processed
valid_loss: 1.0498641329693656 - valid_accuracy: 0.79208166544148 - valid_bleu: 0.10667336005789202
让我们看看结果。我们可以看到,训练损失和验证损失大致持续下降。我们的训练和验证准确率大约为 80%。最后,valid_bleu得分约为 0.10。你可以在这里看到一些模型的最新技术:paperswithcode.com/sota/image-captioning-on-coco。可以看到,UNIMO 模型达到了 39 的 BLEU-4 分数。值得注意的是,实际上我们的 BLEU 得分比这里报告的要高。这是因为每张图片有多个描述。在计算多个参考的 BLEU 得分时,你需要对每个描述计算 BLEU,并取最大值。而我们在计算 BLEU 得分时只考虑了每张图片的一个描述。此外,我们的模型要简单得多,且只在一小部分可用数据上进行了训练。如果你希望提高模型性能,可以尝试使用完整的训练集,并实验更大的 ViT 模型和数据增强技术来提高表现。
接下来我们讨论在图像描述的上下文中,衡量序列质量的不同指标。
定量评估结果
评估生成的描述质量和相关性有许多不同的技术。我们将简要讨论几种可用于评估描述的指标。我们将讨论四个指标:BLEU、ROUGE、METEOR 和 CIDEr。
所有这些指标共享一个关键目标,即衡量生成文本的适应性(生成文本的意义)和流畅性(文本的语法正确性)。为了计算这些指标,我们将使用候选句子和参考句子,其中候选句子是我们算法预测的句子/短语,而参考句子是我们要与之比较的真实句子/短语。
BLEU
双语评估替代法 (BLEU) 是由 Papineni 等人在 BLEU: A Method for Automatic Evaluation of Machine Translation 中提出的,《第 40 届计算语言学协会年会论文集(ACL)》,费城,2002 年 7 月:311-318。它通过一种与位置无关的方式来度量参考句子和候选句子之间的 n-gram 相似度。这意味着候选句子中的某个 n-gram 出现在参考句子的任何位置都被认为是匹配的。BLEU 计算 n-gram 相似度时使用精确度。BLEU 有多个变种(BLEU-1、BLEU-2、BLEU-3 等),表示 n-gram 中 n 的值。

这里,Count(n-gram) 是候选句子中给定 n-gram 的总出现次数。Count[clip] (n-gram) 是一种度量方法,用于计算给定 n-gram 的 Count(n-gram) 值,并根据最大值进行裁剪。n-gram 的最大值是通过该 n-gram 在参考句子中的出现次数来计算的。例如,考虑以下两句话:
-
候选短语:the the the the the the the
-
参考文献:the cat sat on the mat
Count(“the”) = 7
Count [clip] (“the”)=2
注意,实体
是精确度的一种表现形式。事实上,它被称为修正后的 n-gram 精确度。当存在多个参考文献时,BLEU 被认为是最大值:

然而,修正后的 n-gram 精确度对于较小的候选短语倾向于较高,因为这个实体是由候选短语中的 n-gram 数量来划分的。这意味着这种度量会使模型倾向于生成较短的短语。为了避免这种情况,增加了一个惩罚项 BP,它对短候选短语也进行惩罚。BLEU 存在一些局限性,比如在计算分数时忽略了同义词,且没有考虑召回率,而召回率也是衡量准确性的一个重要指标。此外,BLEU 对某些语言来说似乎不是一个理想选择。然而,这是一个简单的度量方法,通常在大多数情况下与人工评判具有较好的相关性。
ROUGE
面向召回的摘要评估替代法 (ROUGE),由 Chin-Yew Lin 在 ROUGE: A Package for Automatic Evaluation of Summaries 中提出,《文本摘要分支扩展研讨会论文集(2004)》,可以被视为 BLEU 的一种变体,且使用召回率作为基本的性能评估标准。ROUGE 度量公式如下:

这里,
是候选词组中出现在参考文献中的 n-gram 数量,而
是参考文献中出现的总 n-gram 数量。如果存在多个参考文献,ROUGE-N 的计算公式如下:

这里,ref[i]是来自可用参考集合中的一个单一参考。ROUGE 度量有多个变种,针对标准 ROUGE 度量进行改进。ROUGE-L 通过计算候选句子与参考句子对之间找到的最长公共子序列来得分。需要注意的是,在这种情况下,最长公共子序列不需要是连续的。接下来,ROUGE-W 根据最长公共子序列进行计算,并根据子序列中的碎片化程度进行惩罚。ROUGE 也存在一些局限性,比如在得分计算中没有考虑精度。
METEOR
翻译评估度量标准(Explicit Ordering Translation Evaluation Metric)(METEOR),由 Michael Denkowski 和 Alon Lavie 提出,见于Meteor Universal: Language Specific Translation Evaluation for Any Target Language,第九届统计机器翻译研讨会论文集(2014):376-380,是一种更先进的评估度量标准,它对候选句子和参考句子进行对齐。METEOR 与 BLEU 和 ROUGE 的不同之处在于,METEOR 考虑了单词的顺序。在计算候选句子与参考句子之间的相似性时,以下情况被视为匹配:
-
完全匹配:候选句子中的单词与参考句子中的单词完全匹配
-
词干:一个词干化后的词(例如,walk是词walked的词干)与参考句子中的单词匹配
-
同义词:候选句子中的单词是参考句子中单词的同义词
为了计算 METEOR 得分,可以通过表格展示参考句子与候选句子之间的匹配情况,如图 11.10所示。然后,基于候选句子和参考句子中匹配项的数量,计算精度(P)和召回率(R)值。最后,使用P和R的调和均值来计算 METEOR 得分:

在这里,
、
和
是可调参数,frag会惩罚碎片化的匹配,以偏好那些匹配中间隙较少且单词顺序与参考句子接近的候选句子。frag是通过观察最终单一词映射中的交叉数来计算的(图 11.9):

图 11.9:两串字符串的不同对齐方式
例如,我们可以看到左侧有 7 个交叉,而右侧有 10 个交叉,这意味着右侧的对齐会比左侧更受惩罚。

图 11.10:METEOR 单词匹配表
你可以看到,我们用圆圈和椭圆表示候选句子与参考句子之间的匹配。例如,我们用实心黑色圆圈表示完全匹配,用虚线空心圆圈表示同义词匹配,用点状圆圈表示词干匹配。
METEOR 在计算上更复杂,但通常被发现与人工评判的相关性高于 BLEU,表明 METEOR 是比 BLEU 更好的评估指标。
CIDEr
基于共识的图像描述评估(CIDEr),由 Ramakrishna Vedantam 等人在《CIDEr: 基于共识的图像描述评估》,IEEE 计算机视觉与模式识别会议(CVPR),2015中提出,是另一种评估候选句子与给定参考句子集合共识度的衡量标准。CIDEr 旨在衡量候选句子的语法正确性、显著性和准确性(即精度和召回率)。
首先,CIDEr 通过 TF-IDF 加权候选句子和参考句子中出现的每个 n-gram,因此更常见的 n-gram(例如,单词a和the)的权重较小,而稀有单词的权重较大。最后,CIDEr 通过计算候选句子和参考句子中 TF-IDF 加权 n-gram 向量之间的余弦相似度来得出:

在这里,cand是候选句子,ref是参考句子的集合,ref[j]是ref的第j^(个)句子,m是给定候选句子的参考句子数量。最重要的是,
是为候选句子中的所有 n-gram 计算的 TF-IDF 值,并将其作为向量。
是参考句子ref[i]的相同向量。
表示该向量的大小。
总的来说,应该注意的是,没有一个明确的赢家能够在自然语言处理中的所有任务上表现出色。这些指标在很大程度上依赖于任务,并应根据具体任务谨慎选择。在这里,我们将使用 BLEU 分数来评估我们的模型。
评估模型
训练好模型后,让我们在未见过的测试数据集上测试模型。测试逻辑与我们在模型训练过程中讨论的验证逻辑几乎相同。因此,我们不会在这里重复讨论。
bleu_metric = BLEUMetric(tokenizer=tokenizer)
test_dataset, _ = generate_tf_dataset(
test_captions_df, tokenizer=tokenizer, n_vocab=n_vocab, batch_size=batch_size, training=False
)
test_loss, test_accuracy, test_bleu = [], [], []
for ti, t_batch in enumerate(test_dataset):
print(f"{ti+1} batches processed", end='\r')
loss, accuracy = full_model.test_on_batch(t_batch[0], t_batch[1])
batch_predicted = full_model.predict_on_batch(t_batch[0])
bleu_score = bleu_metric.calculate_bleu_from_predictions(t_batch[1], batch_predicted)
test_loss.append(loss)
test_accuracy.append(accuracy)
test_bleu.append(bleu_score)
print(
f"\ntest_loss: {np.mean(test_loss)} - test_accuracy: {np.mean(test_accuracy)} - test_bleu: {np.mean(test_bleu)}"
)
这将输出:
261 batches processed
test_loss: 1.057080413646625 - test_accuracy: 0.7914185857407434 - test_bleu: 0.10505496256163914
很好,我们可以看到模型在测试数据上的表现与在验证数据上的表现相似。这意味着我们的模型没有过拟合,并且在现实世界中应该表现得相当不错。现在,让我们为一些示例图像生成描述。
为测试图像生成的描述
通过使用诸如准确率和 BLEU 之类的度量标准,我们确保了我们的模型表现良好。但训练好的模型最重要的任务之一是为新数据生成输出。我们将学习如何使用模型生成实际的标题。首先,让我们从概念上理解如何生成标题。通过使用图像来生成图像表示是非常直接的。棘手的部分是调整文本解码器以生成标题。正如你所想,解码器推理需要在与训练不同的环境下工作。这是因为在推理时,我们没有标题令牌可以输入模型。
我们使用模型进行预测的方式是从图像和一个包含单一令牌[START]的起始标题开始。我们将这两个输入传递给模型,以生成下一个令牌。然后,我们将新令牌与当前输入结合,预测下一个令牌。我们会一直这么进行,直到达到一定的步数,或者模型输出[END](图 11.11)。如果你还记得,我们以这样一种方式开发了模型,使得它能够接受任意长度的令牌序列。这在推理过程中非常有用,因为在每个时间步长,序列的长度都会增加。

图 11.11:训练好的模型的解码器如何生成给定图像的新标题
我们将从测试数据集中选择一个包含 10 个样本的小数据集,并生成标题:
n_samples = 10
test_dataset, _ = generate_tf_dataset(
test_captions_df.sample(n=n_samples), tokenizer=tokenizer,
n_vocab=n_vocab, batch_size=n_samples, training=False
)
接下来,让我们定义一个名为generate_captions()的函数。这个函数接收:
-
model– 训练好的模型 -
image_input– 一批输入图像 -
tokenizer– 训练好的分词器 -
n_samples– 批次中的样本数量
如下所示:
def generate_caption(model, image_input, tokenizer, n_samples):
# 2 -> [START]
batch_tokens = np.repeat(np.array([[2]]), n_samples, axis=0)
for i in range(30):
if np.all(batch_tokens[:,-1] == 3):
break
position_input = tf.repeat(tf.reshape(tf.range(i+1),[1,-1]),
n_samples, axis=0)
probs = full_model((image_input, batch_tokens,
position_input)).numpy()
batch_tokens = np.argmax(probs, axis=-1)
predicted_text = []
for sample_tokens in batch_tokens:
sample_predicted_token_ids = sample_tokens.ravel()
sample_predicted_tokens = []
for wid in sample_predicted_token_ids:
sample_predicted_tokens.append(tokenizer.id_to_token(wid))
if wid == 3:
break
sample_predicted_text = " ".join([tok for tok in
sample_predicted_tokens])
sample_predicted_text = sample_predicted_text.replace(" ##",
"")
predicted_text.append(sample_predicted_text)
return predicted_text
这个函数以一个单一的标题令牌 ID 开始。ID 2 映射到令牌[START]。我们预测 30 步,或者当最后一个令牌是[END](映射到令牌 ID 3)时停止。我们通过创建一个从 0 到 i 的范围序列,并在批次维度上重复 n_sample 次,为数据批次生成位置输入。然后,我们将输入传递给模型,以预测令牌的概率。
我们现在可以使用这个函数生成标题:
for batch in test_dataset.take(1):
(batch_image_input, _, _), batch_true_caption = batch
batch_predicted_text = generate_caption(full_model, batch_image_input, tokenizer, n_samples)
现在,让我们将标题与图像输入并排显示。另外,我们还将展示真实的标题:
fig, axes = plt.subplots(n_samples, 2, figsize=(8,30))
for i,(sample_image_input, sample_true_caption, sample_predicated_caption) in enumerate(zip(batch_image_input, batch_true_caption, batch_predicted_text)):
sample_true_caption_tokens = [tokenizer.id_to_token(wid) for wid in
sample_true_caption.numpy().ravel()]
sample_true_text = []
for tok in sample_true_caption_tokens:
sample_true_text.append(tok)
if tok == '[END]':
break
sample_true_text = " ".join(sample_true_text).replace(" ##", "")
axes[i][0].imshow(((sample_image_input.numpy()+1.0)/2.0))
axes[i][0].axis('off')
true_annotation = f"TRUE: {sample_true_text}"
predicted_annotation = f"PRED: {sample_predicated_caption}"
axes[i][1].text(0, 0.75, true_annotation, fontsize=18)
axes[i][1].text(0, 0.25, predicted_annotation, fontsize=18)
axes[i][1].axis('off')
你将得到一个类似于下图的图表。每次运行时,采样的图像会被随机采样。此次运行的结果可以在图 11.12中看到:

图 11.12:在测试数据样本上生成的标题
我们可以看到我们的模型在生成标题方面表现得很好。总的来说,我们可以看到模型能够识别图像中展示的物体和活动。同样需要记住的是,每张图像都有多个与之关联的标题。因此,预测的标题不一定需要与图像中的真实标题完全匹配。
总结
在本章中,我们专注于一个非常有趣的任务——为给定的图像生成描述。我们的图像描述模型是本书中最复杂的模型之一,包含以下内容:
-
一个生成图像表示的视觉 Transformer 模型
-
一个基于文本的 Transformer 解码器
在开始模型之前,我们分析了数据集,以理解各种特征,例如图像大小和词汇量大小。接着我们了解了如何使用分词器对描述字符串进行分词。然后,我们利用这些知识构建了一个 TensorFlow 数据管道。
我们详细讨论了每个组件。视觉 Transformer (ViT) 接收一张图像并生成该图像的隐藏表示。具体来说,ViT 将图像拆分成一系列 16x16 像素的小块。之后,它将每个小块作为一个 token 嵌入传递给 Transformer(包括位置编码信息),以生成每个小块的表示。它还在开头加入了[CLS] token,用来提供图像的整体表示。
接下来,文本解码器将图像表示和描述 token 作为输入。解码器的目标是在每个时间步预测下一个 token。我们在验证数据集上达到了 BLEU-4 得分略高于 0.10。
随后,我们讨论了几种不同的评价指标(BLEU、ROUGE、METEOR 和 CIDEr),这些指标可以用来定量评估生成的图像描述,并且我们看到,在将算法应用于训练数据时,BLEU-4 得分随着时间的推移而增加。此外,我们还通过视觉检查生成的描述,发现我们的机器学习管道在逐渐提高图像描述的准确性。
接下来,我们在测试数据集上评估了我们的模型,并验证了其在测试数据上的表现与预期相符。最后,我们学习了如何使用训练好的模型为未见过的图像生成描述。
本书已经结束。我们涵盖了许多自然语言处理的不同主题,并讨论了有助于解决问题的先进模型和技术。
在附录中,我们将讨论与机器学习相关的一些数学概念,并解释如何使用可视化工具 TensorBoard 来可视化词向量。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,结识志同道合的人,与超过 1000 名成员一起学习,访问链接:packt.link/nlp

附录 A:数学基础与高级 TensorFlow
在这里,我们将讨论一些概念,这些概念将帮助你理解本书中提供的某些细节。首先,我们将讨论书中常见的几种数学数据结构,然后介绍对这些数据结构执行的各种操作。接下来,我们将讨论概率的概念。概率在机器学习中起着至关重要的作用,因为它通常提供有关模型对其预测的不确定性的见解。最后,我们将以如何使用 TensorBoard 作为词嵌入的可视化工具的指南结束本附录。
基本数据结构
标量
标量是一个单一的数字,不像矩阵或向量。例如,1.3 是一个标量。标量可以在数学上表示如下:
。
这里,R 是实数空间。
向量
向量是一个数字数组。与集合不同,集合中的元素没有顺序,而向量的元素是有顺序的。一个示例向量是 [1.0, 2.0, 1.4, 2.3]。在数学上,它可以表示为:


这里,R 是实数空间,n 是向量中的元素个数。
矩阵
矩阵可以被看作是一组标量的二维排列。换句话说,矩阵可以被看作是一个向量的向量。一个示例矩阵如下所示:

一个更一般的矩阵,其大小为
,可以在数学上定义如下:

并且:

这里,m 是矩阵的行数,n 是矩阵的列数,R 是实数空间。
矩阵索引
我们将使用零索引表示法(即,索引从 0 开始)。
要从矩阵中索引单个元素,位于 (i, j)^(th) 位置,我们使用以下表示法:

参考之前定义的矩阵,我们得到如下结果:

我们像这样从 A 中索引一个元素:

我们表示任何矩阵 A 的一行,如下所示:

对于我们的示例矩阵,我们可以表示矩阵的第二行(索引为 1),如下所示:

我们表示从矩阵 A 的 (i, k)^(th) 索引到 (j, l)^(th) 索引的切片,如下所示:

在我们的示例矩阵中,我们可以表示从第一行第三列到第二行第四列的切片,如下所示:

特殊类型的矩阵
单位矩阵
单位矩阵是一个方阵,其中对角线上的值为 1,其他位置的值为 0。在数学上,它可以表示为:

这看起来如下所示:

这里,
。
单位矩阵与另一个矩阵 A 相乘时,具有以下良好的性质:

方阵对角矩阵
方阵对角矩阵是单位矩阵的一个更一般的情况,其中对角线上的值可以取任意值,而非对角线上的值为零:

张量
一个 n 维矩阵被称为 张量。换句话说,一个具有任意维数的矩阵被称为张量。例如,一个四维张量可以表示如下:

这里,R 是实数空间。
张量/矩阵操作
转置
转置是一个重要的操作,适用于矩阵或张量。对于矩阵,转置定义如下:

这里,A^T 表示 A 的转置。
转置操作的一个例子可以如下说明:

经过转置操作后:

对于张量,转置可以看作是对维度顺序的重新排列。例如,我们定义一个张量 S,如下所示:

现在可以定义一个转置操作(多次转置中的一种),如下所示:

矩阵乘法
矩阵乘法是另一个在线性代数中非常常见的重要操作。
给定矩阵
和
,A 和 B 的乘法定义如下:

这里,
。
考虑这个例子:


这给出了
,并且 C 的值如下:

元素级乘法
元素级矩阵乘法(或 Hadamard 乘积)是对形状相同的两个矩阵进行计算的。给定矩阵
和
,A 和 B 的元素级乘法定义如下:

这里,
。
考虑这个例子:

这给出了
,并且 C 的值如下:

逆
矩阵 A 的逆矩阵表示为 A^(-1),它满足以下条件:

逆矩阵在我们试图解线性方程组时非常有用。考虑这个例子:

我们可以通过如下方式解出
:

这可以写作
,利用结合律——即,
。
接下来,我们将得到,其中
是单位矩阵。
最后,
,因为
。
例如,多项式回归是回归技术之一,使用线性方程组来解决回归问题。回归类似于分类,但与分类输出一个类别不同,回归模型输出一个连续值。让我们看一个示例问题:给定一所房子的卧室数量,我们将计算这所房产的价值。形式上,一个多项式回归问题可以写成如下:

这里,
是第i个数据输入,其中
是输入,
是标签,
是数据中的噪声。在我们的例子中,
是卧室的数量,
是房子的价格。这可以写成如下的线性方程组:

然而,并非所有的A都存在逆。为了矩阵有逆,需要满足一定的条件。例如,为了定义逆矩阵,A需要是一个方阵(即,
)。即使逆矩阵存在,我们也并不总能以封闭形式找到它;有时它只能通过有限精度计算机进行近似。如果逆矩阵存在,那么有几种算法可以找到它,我们将在接下来的内容中讨论。
注意
当我们说要矩阵是方阵才能有逆时,指的是标准的逆运算。也存在逆运算的变种(例如,摩尔-彭若斯逆,也称为伪逆),它可以对一般的
矩阵进行矩阵求逆操作。
求解矩阵逆——奇异值分解(SVD)
现在让我们看看如何使用 SVD 求解矩阵A的逆。SVD 将A分解为三个不同的矩阵,如下所示:

这里,U的列被称为左奇异向量,V的列被称为右奇异向量,D(一个对角矩阵)的对角值被称为奇异值。左奇异向量是矩阵
的特征向量,右奇异向量是矩阵
的特征向量。最后,奇异值是矩阵
和
的特征值的平方根。矩阵A的特征向量
及其对应的特征值
满足以下条件:

然后,如果 SVD 存在,矩阵A的逆由以下公式给出:

由于D是对角矩阵,D^(-1)只是D中非零元素的逐元素倒数。SVD(奇异值分解)是机器学习中一个重要的矩阵分解技术。例如,SVD 被用于计算主成分分析(PCA),这是一种流行的数据降维技术(其目的类似于我们在第四章,先进的词向量算法中看到的 t-SNE)。SVD 在自然语言处理(NLP)中的另一个应用是文档排名。即,当你想获取最相关的文档(并根据与某个术语的相关性对它们进行排名,例如足球),可以使用 SVD 来实现这一目标。想要了解更多关于 SVD 的信息,可以参考这篇博客文章,它提供了 SVD 的几何直觉,并展示了它在 PCA 中的应用:gregorygundersen.com/blog/2018/12/10/svd/。
范数
范数用作衡量向量大小(即向量中的值)的标准。p^(th)范数的计算和表示如下所示:

例如,L2范数是这样的:

行列式
方阵的行列式表示为
。行列式在许多方面都非常有用。例如,A仅当且仅当行列式不为零时才是可逆的。行列式也可以被解释为矩阵所有特征值的乘积。2x2矩阵A的行列式表示为

如下所示

计算方法为

以下方程展示了3x3矩阵行列式的计算:



概率
接下来,我们将讨论与概率论相关的术语。概率论是机器学习的一个重要部分,因为使用概率模型建模数据可以帮助我们得出关于模型在某些预测上不确定性的结论。以情感分析的使用案例为例,我们想为给定的电影评论输出一个预测(正面/负面)。尽管模型对于我们输入的每一个样本都输出一个介于 0 和 1 之间的值(0 表示负面,1 表示正面),但模型并不知道它对其答案的不确定性有多大。
让我们理解不确定性如何帮助我们做出更好的预测。例如,一个确定性模型(即输出确切值而非值的分布的模型)可能会错误地说评论 “我从未失去兴趣” 的正向概率是 0.25(也就是说,它更可能是负面评论)。然而,概率模型将为预测提供一个均值和一个标准差。例如,它可能会说,这个预测的均值为 0.25,标准差为 0.5。在第二种模型下,我们知道由于标准差较大,预测很可能是错误的。然而,在确定性模型中,我们没有这种奢侈的选择。这一特性对于关键的机器系统(例如,恐怖主义风险评估模型)尤其有价值。
为了开发这样的概率机器学习模型(例如,贝叶斯逻辑回归、贝叶斯神经网络或高斯过程),你应该熟悉基本的概率理论。因此,我们将在这里提供一些基本的概率信息。
随机变量
随机变量是一个可以随机取值的变量。此外,随机变量通常表示为 x[1]、x[2] 等。随机变量可以分为两种类型:离散型和连续型。
离散随机变量
离散随机变量是指可以取离散随机值的变量。例如,掷硬币的试验可以被建模为一个随机变量;即,硬币掷出的正面或反面是一个离散变量,因为结果只能是正面或反面。另外,掷骰子的结果也是离散的,因为其值只能来自集合 {1,2,3,4,5,6}。
连续随机变量
连续随机变量是一个可以取任何实数值的变量,也就是说,如果 x 是一个连续随机变量:

这里,R 表示实数空间。
例如,一个人的身高是一个连续随机变量,因为它可以取任何实数值。
概率质量/密度函数
概率质量函数(PMF)或概率密度函数(PDF)是一种展示随机变量在不同值上概率分布的方式。对于离散变量,定义了 PMF;对于连续变量,定义了 PDF。图 A.1 显示了一个 PMF 的例子:

A.1: 概率质量函数(PMF)离散型
上述的概率质量函数(PMF)可能是通过一个偏骰子实现的。在这张图中,我们可以看到,掷这个骰子时,出现 3 的概率很高。这样的图形可以通过进行多次试验(比如 100 次)并统计每个面朝上的次数得到。最后,你需要将每个计数除以试验次数,以获得标准化后的概率。请注意,所有的概率总和应为 1,正如这里所示:

相同的概念被扩展到连续随机变量,以获得一个 PDF。假设我们试图建模给定人群的某个身高的概率。与离散情况不同,我们没有个别的值来计算概率,而是一个连续的值范围(在本例中,它从 0 到 2.4 m)。如果我们像 图 A.1 中的示例一样绘制图表,我们需要以无穷小区间来考虑它。例如,我们找出一个人身高在 0.0 m-0.01 m, 0.01-0.02 m, ..., 1.8 m-1.81 m, … 等范围内的概率密度。概率密度可以使用以下公式计算:

然后,我们将这些条形图画得靠近彼此,从而获得一个连续的曲线,如图 A.2所示。请注意,给定的区间的概率密度可以大于 1(因为它是密度),但是曲线下的面积必须为 1:

图 A.2:概率密度函数(PDF)连续
在图 A.2中显示的形状被称为正态分布(或高斯分布)。它也被称为钟形曲线。我们之前给出的是关于如何理解连续概率密度函数的直观解释。
更正式地说,正态分布的连续 PDF 有一个公式,定义如下。假设连续随机变量 X 具有均值
和标准差
的正态分布。对于任何 x 的值,X = x 的概率由以下公式给出:

如果你对所有可能的无穷小 dx 值进行积分,应该得到区域(有效的 PDF 需要为 1),如以下公式所示:

任意 a 和 b 值的正态分布积分通过以下公式给出:

使用这个公式,我们可以得到正态分布的积分,其中
和
:

这给出了所有 x 值的概率值的累积,并给出了一个值为 1 的结果。
你可以在 mathworld.wolfram.com/GaussianIntegral.html 查找更多信息,或者参考 en.wikipedia.org/wiki/Gaussian_integral 进行更简单的讨论。
条件概率
条件概率表示在一个事件发生的前提下,另一个事件发生的概率。例如,给定两个随机变量,X 和 Y,在 Y = y 的条件下,X = x 的条件概率可以用以下公式表示:

这种概率的一个实际例子如下所示:

联合概率
给定两个随机变量X和Y,我们将X = x和Y = y的概率称为X = x和Y = y的联合概率。其公式表示如下:

如果X和Y是互斥事件,则此表达式将简化为:

一个现实世界中的例子如下:

边际概率
边际概率分布是给定所有变量的联合概率分布时,某一随机变量子集的概率分布。例如,假设存在两个随机变量X和Y,且我们已经知道
,我们想要计算P(x):

直观地说,我们正在对所有可能的Y值求和,实际上是在计算Y = 1的概率。
贝叶斯定理
贝叶斯定理为我们提供了一种计算
的方法,前提是我们已经知道
和
。我们可以通过以下方式轻松推导出贝叶斯定理:

现在让我们来看中间和右边的部分:


这就是贝叶斯定理。简单来说,就是这样:

使用 TensorBoard 可视化词嵌入
当我们在第三章“Word2vec——学习词嵌入”中想要可视化词嵌入时,我们是通过手动实现 t-SNE 算法来进行可视化的。然而,你也可以使用 TensorBoard 来可视化词嵌入。TensorBoard 是 TensorFlow 提供的一个可视化工具。你可以用 TensorBoard 来可视化程序中的 TensorFlow 变量。这让你可以看到不同变量随着时间的变化(例如,模型的损失/准确度),从而帮助你识别模型中的潜在问题。
TensorBoard 使你能够可视化标量值(例如,训练迭代中的损失值)和向量作为直方图(例如,模型层节点的激活)。除此之外,TensorBoard 还允许你可视化词嵌入。因此,如果你需要分析嵌入的样子,TensorBoard 为你提供了所有所需的代码实现。接下来,我们将看到如何使用 TensorBoard 来可视化词嵌入。本练习的代码在Appendix文件夹中的tensorboard_word_embeddings.ipynb里提供。
启动 TensorBoard
首先,我们将列出启动 TensorBoard 的步骤。TensorBoard 作为一个服务运行,并使用特定的端口(默认情况下是6006)。要启动 TensorBoard,你需要按照以下步骤操作:
-
打开命令提示符(Windows)或终端(Ubuntu/macOS)。
-
进入项目的主目录。
-
如果你使用的是 python 的
virtualenv,请激活你已安装 TensorFlow 的虚拟环境。 -
确保你能通过 Python 看到 TensorFlow 库。为此,按照以下步骤操作:
-
输入
python3;你将看到一个类似>>>的提示符。 -
尝试
import tensorflow as tf -
如果你能够成功运行此操作,那么你就没问题了
-
通过输入
exit()退出python提示符(即>>>)
-
-
输入
tensorboard --logdir=models:-
--logdir选项指向你将创建数据以供可视化的目录 -
可选地,你可以使用
--port=<port_you_like>来更改 TensorBoard 运行的端口
-
-
你现在应该能看到以下消息:
TensorBoard 1.6.0 at <url>;:6006 (Press CTRL+C to quit) -
在网页浏览器中输入
<url>:6006。此时,你应该能够看到一个橙色的仪表盘。由于我们还没有生成任何数据,所以不会显示任何内容。
保存词嵌入并通过 TensorBoard 进行可视化
首先,我们将从nlp.stanford.edu/projects/glove/下载并加载 50 维的 GloVe 词向量文件(glove.6B.zip),并将其放入Appendix文件夹中。我们将加载文件中的前 50,000 个词向量,稍后将这些词向量用于初始化 TensorFlow 变量。同时,我们还将记录每个词的字符串,因为稍后我们会将这些字符串作为标签,在 TensorBoard 中显示每个点:
vocabulary_size = 50000
embedding_df = []
index = []
# Open the zip file
with zipfile.ZipFile('glove.6B.zip') as glovezip:
# Read the file with 50 dimensional embeddings
with glovezip.open('glove.6B.50d.txt') as glovefile:
# Read line by line
for li, line in enumerate(glovefile):
# Print progress
if (li+1)%10000==0: print('.',end='')
# Get the word and the corresponding vector
line_tokens = line.decode('utf-8').split(' ')
word = line_tokens[0]
vector = [float(v) for v in line_tokens[1:]]
assert len(vector)==50
index.append(word)
# Update the embedding matrix
embedding_df.append(np.array(vector))
# If the first 50000 words being read, finish
if li >= vocabulary_size-1:
break
embedding_df = pd.DataFrame(embedding_df, index=index)
我们已将嵌入定义为一个 pandas DataFrame。它将词向量作为列,将词作为索引。

图 A.3:以 pandas DataFrame 形式呈现的 GloVe 向量
我们还需要定义与 TensorFlow 相关的变量和操作。在此之前,我们将创建一个名为embeddings的目录,用于存储这些变量:
# Create a directory to save our model
log_dir = 'embeddings'
os.makedirs(log_dir, exist_ok=True)
然后,我们将定义一个变量,该变量将用我们之前从文本文件中复制的词嵌入进行初始化:
# Save the weights we want to analyse as a variable.
embeddings = tf.Variable(embedding_df.values)
print(f"weights.shape: {embeddings.shape}")
# Create a checkpoint from embedding
checkpoint = tf.train.Checkpoint(embedding=embeddings)
checkpoint.save(os.path.join(log_dir, "embedding.ckpt"))
我们还需要保存一个元数据文件。元数据文件包含与词嵌入相关的标签/图像或其他类型的信息,以便当你悬停在嵌入可视化上时,相应的点将显示它们所代表的词/标签。元数据文件应为.tsv(制表符分隔值)格式,且应包含vocabulary_size行,其中每行包含一个词,按它们在词嵌入矩阵中出现的顺序排列:
with open(os.path.join(log_dir, 'metadata.tsv'), 'w', encoding='utf-8') as f:
for w in embedding_df.index:
f.write(w+'\n')
然后,我们需要告诉 TensorFlow 它在哪里可以找到我们保存到磁盘的嵌入数据的元数据。为此,我们需要创建一个ProjectorConfig对象,该对象保存有关我们要显示的嵌入的各种配置信息。存储在ProjectorConfig文件夹中的详细信息将保存在models目录中的projector_config.pbtxt文件中:
config = projector.ProjectorConfig()
在这里,我们将填写我们创建的ProjectorConfig对象的必填字段。首先,我们将告诉它我们感兴趣的变量名称。然后,我们将告诉它在哪里可以找到与该变量对应的元数据:
config = projector.ProjectorConfig()
# You can add multiple embeddings. Here we add only one.
embedding_config = config.embeddings.add()
embedding_config.tensor_name = "embedding/.ATTRIBUTES/VARIABLE_VALUE"
# Link this tensor to its metadata file (e.g. labels).
embedding_config.metadata_path = 'metadata.tsv'
# TensorBoard will read this file during startup.
projector.visualize_embeddings(log_dir, config)
请注意,我们在embedding名称后添加了后缀/.ATTRIBUTES/VARIABLE_VALUE。这是 TensorBoard 找到此张量所必需的。TensorBoard 将在启动时读取必要的文件:
projector.visualize_embeddings(log_dir, config)
现在,如果你加载 TensorBoard,你应该能看到类似图 A.4的内容:

图 A.4:TensorBoard 可视化的嵌入
当您将鼠标悬停在显示的点云上时,系统会显示您当前悬停的单词标签,因为我们在 metadata.tsv 文件中提供了这些信息。此外,您还有几个选项。第一个选项(如虚线框所示,标记为 1)允许您选择嵌入空间的一个子集。您可以在感兴趣的嵌入空间区域画出一个边界框,效果如 图 A.5 所示。我选择了可视化中右侧的嵌入。您可以在右侧看到选定单词的完整列表:

图 A.5:选择嵌入空间的一个子集
另一种选择是查看单词本身,而非点。您可以通过选择 图 A.4 中的第二个选项(标记为 2 的实心框)来实现。这将显示如 图 A.6 所示的效果。此外,您可以根据需要平移/缩放/旋转视图。如果点击帮助按钮(如 图 A.6 中标记为 1 的实心框所示),将显示一个控制视图的指南:

图 A.6:以单词形式显示的嵌入向量,而非点
最后,您可以通过左侧面板更改可视化算法(如 图 A.4 中所示,标记为 3 的虚线框)。
摘要
在这里,我们讨论了一些数学背景知识,以及我们在其他章节中没有涉及的一些实现。首先,我们讨论了标量、向量、矩阵和张量的数学符号。接着,我们讨论了对这些数据结构进行的各种操作,例如矩阵乘法和矩阵求逆。之后,我们讨论了一些有助于理解概率机器学习的术语,如概率密度函数、联合概率、边际概率和贝叶斯规则。最后,我们在附录中以如何使用 TensorFlow 附带的可视化平台 TensorBoard 来可视化词嵌入的指南结束。

packt.com
订阅我们的在线数字图书馆,您将可以访问超过 7,000 本书籍和视频,此外还可以使用行业领先的工具帮助您规划个人发展并推动职业进步。欲了解更多信息,请访问我们的网站。
为什么订阅?
-
花更少的时间学习,花更多的时间编码,利用来自超过 4,000 名行业专业人士的实用电子书和视频
-
通过为您量身定制的技能计划提高您的学习效率
-
每月获取免费的电子书或视频
-
完全可搜索,方便快速访问重要信息
-
复制和粘贴、打印和收藏内容
在 www.packt.com,您还可以阅读一系列免费的技术文章,订阅各种免费的电子邮件通讯,并获得 Packt 图书和电子书的独家折扣和优惠。
其他您可能喜欢的书籍
如果您喜欢本书,您可能对 Packt 出版的其他书籍感兴趣:
自然语言处理的变压器模型(第二版)
丹尼斯·罗斯曼(Denis Rothman)
ISBN:9781803247335
-
了解 ViT 和 CLIP 如何为图像(包括模糊图像!)打标签,并使用 DALL-E 根据句子生成图像
-
探索新的技术以研究复杂的语言问题
-
对比和分析 GPT-3 与 T5、GPT-2 和 BERT 模型的结果
-
使用 TensorFlow、PyTorch 和 GPT-3 进行情感分析、文本摘要、口语分析、机器翻译等任务
-
衡量关键变压器的生产力,以定义它们的范围、潜力和生产限制
使用 PyTorch 和 Scikit-Learn 进行机器学习
塞巴斯蒂安·拉施卡(Sebastian Raschka)
刘宇熙(Hayden Liu)
瓦希德·米尔贾利利(Vahid Mirjalili)
ISBN:9781801819312
-
探索框架、模型和技术,让机器从数据中“学习”
-
使用 scikit-learn 进行机器学习,使用 PyTorch 进行深度学习
-
在图像、文本等数据上训练机器学习分类器
-
构建和训练神经网络、变压器模型和提升算法
-
发现评估和调优模型的最佳实践
-
使用回归分析预测连续的目标结果
-
深入挖掘文本和社交媒体数据,使用情感分析
Packt 正在寻找像您这样的作者
如果您有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天就申请。我们与成千上万的开发者和技术专家合作,帮助他们与全球技术社区分享见解。您可以提交一般申请,申请我们正在招聘作者的特定热门话题,或者提交您自己的创意。
分享您的想法
现在您已经完成了使用 TensorFlow 进行自然语言处理(第二版),我们很想听听您的想法!如果您从 Amazon 购买了本书,请点击这里直接进入 Amazon 的书评页面,分享您的反馈或在您购买的站点上留下评论。
您的评价对我们以及技术社区非常重要,将帮助我们确保提供优质的内容。


和 N 是文本中的单词数。让我们使用以下句子,设定上下文窗口大小(m)为 1:
,并计算编码器的状态序列和最后的状态h,条件化在x[s]上。
作为第一个词和h上来预测
和预测尚未达到预定义的长度阈值时,通过将预测条件化在
和h上来预测
)
)
)
浙公网安备 33010602011771号