原文:
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中,我们提供了一个分层分类法,将不同的自然语言处理任务分为几种不同的类型。将一个自然语言处理任务归类为某一单一类别是一个困难的任务。因此,您可以看到有些任务跨越了多个类别。我们将这些类别分为两大类:基于语言的(浅色背景,黑色文字)和基于问题的(深色背景,白色文字)。语言学分类有两类:句法(基于结构)和语义(基于意义)。基于问题的分类则有三类:预处理任务(在输入模型之前对文本数据进行处理的任务)、判别性任务(我们试图将输入文本分配到一个或多个预定义类别的任务)和生成性任务(我们试图生成新的文本输出的任务)。当然,这只是其中一种分类方法,但它展示了将一个具体的自然语言处理任务归入特定类别的难度。
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_01_01.png
图 1.1:自然语言处理中常见任务的分类,按更广泛的类别进行划分
在理解了自然语言处理的各种任务后,我们接下来将讨论如何借助机器来解决这些任务。我们将讨论传统方法和基于深度学习的方法。
自然语言处理的传统方法
传统或经典的 NLP 解决方法是一个包含几个关键步骤的顺序流程,并且它是一种统计方法。当我们仔细观察传统的 NLP 学习模型时,会发现一系列明确的任务在进行,比如通过去除不需要的数据来预处理数据,进行特征工程以获得文本数据的良好数值表示,使用训练数据学习机器学习算法,并为新颖的、未见过的数据进行预测。在这些任务中,特征工程是获取良好性能的最耗时和最关键的步骤。
了解传统方法
传统的 NLP 任务解决方法包括一系列不同的子任务。首先,文本语料库需要经过预处理,重点是减少词汇量和干扰。
我所说的干扰是指那些使算法无法捕捉到任务所需的关键信息的事物(例如,标点符号和停用词的去除)。
接下来是几个特征工程步骤。特征工程的主要目标是使算法的学习更加轻松。通常,特征是手动设计的,并且倾向于基于人类对语言的理解。特征工程对于经典的 NLP 算法至关重要,因此,表现最好的系统通常具有最精心设计的特征。例如,对于情感分类任务,你可以用一个语法树来表示一个句子,并为树中的每个节点/子树分配正面、负面或中立标签,从而将句子分类为正面或负面。此外,特征工程阶段还可以使用外部资源,如 WordNet(一个词汇数据库,可以提供关于不同单词如何相互关联的见解——例如,同义词),来开发更好的特征。我们很快会看到一种简单的特征工程技术,称为词袋模型。
接下来,学习算法使用获得的特征以及可选的外部资源来在给定任务上表现良好。例如,对于文本摘要任务,一个包含常见短语和简洁释义的平行语料库将是一个很好的外部资源。最后,进行预测。预测过程是直接的,你只需输入新数据,并通过学习模型将输入传递以获得预测标签。传统方法的整个过程如图 1.2所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_01_02.png
图 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所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_01_03.png
图 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中,我们概述了上述分类。你现在不需要完全理解这些不同的深度学习模型,但它们展示了深度学习模型的多样性:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_01_04.png
图 1.4:常用深度学习方法的一般分类,分为几类
现在,让我们迈出第一步,理解神经网络的内部工作原理。
理解一个简单的深度模型——一个全连接神经网络
现在,让我们更仔细地看一下深度神经网络,以便更好地理解。虽然深度模型有许多不同的变体,但我们先来看其中最早的模型之一(可以追溯到 1950-60 年代),即全连接神经网络(FCNN),有时也叫做多层感知器。图 1.5展示了一个标准的三层 FCNN。
FCNN 的目标是将输入(例如,图像或句子)映射到某个标签或注释(例如,图像的物体类别)。这是通过使用输入 x 来计算 h —— x 的隐藏表示 —— 来实现的,使用如 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_01_011.png 这样的变换;这里,W 和 b 分别是 FCNN 的权重和偏置,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_01_021.png 是 sigmoid 激活函数。神经网络在每一层使用非线性激活函数。sigmoid 激活就是一种这样的激活函数。它是对一层输出的逐元素变换,其中 x 的 sigmoid 输出由 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_01_031.png 给出。接下来,在 FCNN 的顶部放置一个分类器,它可以利用隐藏层中学习到的特征来对输入进行分类。分类器是 FCNN 的一部分,实际上是另一个隐藏层,具有一些权重 W[s] 和偏置 b[s]。另外,我们可以计算 FCNN 的最终输出为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_01_041.png。例如,可以使用 softmax 分类器来处理多标签分类问题。它提供了分类器层输出分数的归一化表示。也就是说,它将为分类器层中的各个类别生成一个有效的概率分布。标签被视为具有最高 softmax 值的输出节点。然后,通过此方法,我们可以定义一个分类损失,该损失通过预测的输出标签与实际输出标签之间的差异来计算。一个这样的损失函数的例子是均方损失。如果你不理解损失函数的具体细节也没关系。我们将在后续章节中讨论其中的许多内容。接下来,神经网络的参数 W、b、W[s] 和 b[s] 将通过标准的随机优化器(例如,随机梯度下降)进行优化,以减少所有输入的分类损失。图 1.5 展示了这一段中解释的过程,适用于三层 FCNN。我们将在 第三章《Word2vec——学习词嵌入》中,逐步讲解如何将这样的模型应用于 NLP 任务。
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_01_05.png
图 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.2
Conda 环境,请运行activate packt.nlp.2
-
运行命令:
jupyter notebook
应该会打开一个看起来像图 1.6的新浏览器窗口:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_01_06.png
图 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.2
Conda 环境。 -
输入
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 框架中的一些基本组件。我们来编写一个执行以下计算的示例,这是神经网络中非常常见的操作:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_002.png
这个计算涵盖了全连接神经网络中单个层发生的操作。这里 W
和 x
是矩阵,b
是向量。然后,“.
”表示点积。sigmoid 是一个非线性变换,给定以下方程:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_001.png
我们将逐步讨论如何通过 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
:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_01.png
图 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
是一个张量:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_01.png
图 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中得到了展示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_03.png
图 2.3:TensorFlow 客户端的通用执行。TensorFlow 客户端从一个图开始,图被发送到分布式主节点。主节点启动工作进程来执行实际任务和参数更新。
一旦计算完成,会话将从参数服务器中将更新后的数据返回给客户端。TensorFlow 的架构如图 2.4所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_04.png
图 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中展示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_05.png
图 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.data
API 创建输入管道
接下来,我们将讨论几种不同的方式,你可以将数据传递给 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中可视化:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_06.png
图 2.6:sigmoid(左)和 ReLU(右)激活的函数形式
接下来,我们将讨论卷积操作。
卷积操作
卷积操作是一种广泛使用的信号处理技术。对于图像,卷积用于产生不同的效果(如模糊),或从图像中提取特征(如边缘)。使用卷积进行边缘检测的例子如图 2.7所示。其实现方式是将卷积滤波器移动到图像上,每个位置产生不同的输出(稍后在本节中会看到图 2.8)。具体而言,在每个位置,我们对卷积滤波器中的元素与图像块(与卷积滤波器大小相同)进行逐元素相乘,并将乘积求和:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_07.png
图 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:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_08.png
图 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.]]]]
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_09.png
图 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):
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_02_10.png
图 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的二维画布上展示。如果仔细观察,你会发现相似的事物被放置得很近(例如,中间聚集的数字):
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_01.png
图 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],以下概率应该很高:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_001.png
为了得到等式的右边,我们需要假设,在给定目标单词(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所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_02.png
图 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:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_03.png
图 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所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_04.png
图 3.4:单词“rich”,“was”,“king”,“he”,“she”和“queen”的词向量位置
接下来,我们只有以下元组:
queen --> beautiful, she
这里找到了单词beautiful。它应该与单词queen和she保持大致相同的距离。让我们使用以下表示:
beautiful --> [0.25,0]
现在,我们有了以下图表,描绘了词之间的关系。当我们观察图 3.6时,它似乎是对单词含义的非常直观的表示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_05.png
图 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]), …];其中,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_002.png 和 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]的未归一化分数
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_06.png
图 3.6: 跳字模型的概念
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_07.png
图 3.7: 跳字模型的实现
使用现有和派生的实体,我们现在可以使用交叉熵损失函数来计算给定数据点*[(w*[i], w[j]), y[i]*]*的损失。
对于二元标签,单个样本的交叉熵损失为https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_003.png:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_004.png
其中https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_005.png是https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_006.png的预测标签。对于多类分类问题,我们通过计算每个类别的项https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_007.png来推广损失:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_008.png
其中 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_009.png 表示 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_010.png 索引的值,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_011.png 是表示数据点标签的一维独热编码向量。
通常,在训练神经网络时,这个损失值是针对给定批次中的每个样本计算的,然后取平均以计算批次的损失值。最后,批次的损失值在数据集中的所有批次上取平均,以计算最终的损失值。
为什么原始的词嵌入论文使用了两个嵌入层?
原始论文(由 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] 给出如下公式:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_013.png
这里,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]的采样概率由以下公式给出:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_014.png
这里,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所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_03_08.png
图 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