原文:
annas-archive.org/md5/fc366c3f6d3023ea2889a905de68763e
译者:飞龙
第十章:Transformer
Transformer 模型改变了大多数涉及顺序数据的机器学习问题的游戏规则。与之前的 RNN 基模型相比,它们显著提高了技术水平。Transformer 模型之所以如此高效的一个主要原因是,它能够访问整个序列(例如,令牌序列),而不像 RNN 基模型那样一次只查看一个元素。Transformer 这一术语在我们的讨论中多次出现,它是一种超越其他顺序模型(如 LSTM 和 GRU)的方法。现在,我们将深入了解 Transformer 模型。
本章中,我们将首先详细学习 Transformer 模型。然后,我们将讨论 Transformer 家族中的一个特定模型,称为 双向编码器表示模型(BERT)。我们将了解如何使用该模型来完成问答任务。
具体来说,我们将涵盖以下主要内容:
-
Transformer 架构
-
理解 BERT
-
用例:使用 BERT 回答问题
Transformer 架构
Transformer 是一种 Seq2Seq 模型(在上一章中讨论过)。Transformer 模型可以处理图像和文本数据。Transformer 模型接受一系列输入并将其映射到一系列输出。
Transformer 模型最初在 Vaswani 等人提出的论文 Attention is all you need 中提出(arxiv.org/pdf/1706.03762.pdf
)。与 Seq2Seq 模型类似,Transformer 包含一个编码器和一个解码器(图 10.1):
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_01.png
图 10.1:编码器-解码器架构
让我们通过之前学习过的机器翻译任务来理解 Transformer 模型是如何工作的。编码器接受一系列源语言的令牌并生成一系列中间输出。然后,解码器接受一系列目标语言的令牌并预测每个时间步的下一个令牌(教师强迫技术)。编码器和解码器都使用注意力机制来提高性能。例如,解码器使用注意力机制检查所有过去的编码器状态和先前的解码器输入。该注意力机制在概念上类似于我们在上一章中讨论过的 Bahdanau 注意力。
编码器和解码器
现在让我们详细讨论编码器和解码器的组成部分。它们的架构大致相同,但也有一些差异。编码器和解码器都被设计为一次性处理一个输入序列。但它们在任务中的目标不同;编码器使用输入生成潜在表示,而解码器则使用输入和编码器的输出生成目标输出。为了执行这些计算,这些输入会通过多个堆叠的层进行传播。每一层都接收一个元素序列并输出另一个元素序列。每一层也由几个子层组成,这些子层对输入的令牌序列执行不同的计算,从而生成输出序列。
Transformer 中的一个层主要由以下两个子层组成:
-
一个自注意力层
-
全连接层
自注意力层通过矩阵乘法和激活函数生成输出(这与我们稍后将讨论的全连接层类似)。自注意力层接收一系列输入并生成一系列输出。然而,自注意力层的一个特殊特点是,在每个时间步生成输出时,它可以访问该序列中的所有其他输入(图 10.2)。这使得该层能够轻松学习和记住长序列的输入。相比之下,RNN 在记住长序列输入时会遇到困难,因为它们需要依次处理每个输入。此外,按设计,自注意力层可以根据所解决的任务,在每个时间步选择并组合不同的输入。这使得 Transformer 在序列学习任务中非常强大。
*让我们讨论一下为什么以这种方式选择性地组合不同的输入元素很重要。在 NLP 领域,自注意力层使得模型在处理某个词时能够“窥视”其他词。这意味着,当编码器在处理句子 I kicked the ball and it disappeared 中的词 it 时,模型可以关注词 ball。通过这种方式,Transformer 能够学习依赖关系并消除歧义,从而提升语言理解能力。
我们甚至可以通过一个现实世界的例子理解自注意力如何帮助我们方便地解决任务。假设你正在和另外两个人玩一个游戏:A 人和 B 人。A 人手里有一个写在板子上的问题,而你需要回答这个问题。假设 A 人每次揭示一个问题的单词,在问题的最后一个单词揭示出来后,你才回答它。对于长且复杂的问题,这会变得具有挑战性,因为你无法在物理上看到完整的问题,必须依赖记忆。这就是没有自注意力的 Transformer 执行计算时的感觉。另一方面,假设 B 人一次性将完整的问题揭示在板子上,而不是一个个字地揭示。现在,你可以一次性看到完整的问题,因此回答问题变得容易得多。如果问题很复杂,需要复杂的答案,你可以在给出不同部分的答案时查看问题的不同部分。这就是自注意力层的作用。
自注意力层后跟一个全连接层。全连接层将所有输入节点与所有输出节点连接,通常后面跟着一个非线性激活函数。它将自注意力子层产生的输出元素作为输入,生成每个输出元素的隐藏表示。与自注意力层不同,全连接层独立地处理每个序列项,按逐元素的方式进行计算。
它们在使模型更深的同时引入了非线性变换,从而使模型能够更好地执行任务:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_02.png
图 10.2:自注意力子层与全连接子层的区别。自注意力子层查看序列中的所有输入,而全连接子层只查看正在处理的输入。
现在我们理解了 Transformer 层的基本构建块,接下来我们将分别看看编码器和解码器。在深入之前,我们先建立一些基础知识。编码器接收一个输入序列,解码器也接收一个输入序列(与编码器输入的序列不同)。然后解码器产生一个输出序列。我们将这些序列中的每个单项称为 token。
编码器由一堆层组成,每一层由两个子层构成:
-
自注意力层 – 为序列中的每个编码器输入标记生成潜在表示。对于每个输入标记,该层查看整个序列并选择序列中的其他标记,丰富该标记的隐藏输出(即“注意到的”表示)的语义。
-
全连接层 – 生成注意到的表示的逐元素更深隐藏表示。
解码器层由三个子层组成:
-
掩蔽自注意力层 – 对于每个解码器输入,一个令牌会查看它左侧的所有令牌。解码器需要掩蔽右侧的词语,以防止模型看到未来的词语。在预测过程中,如果能够访问到后续的词语,解码器的预测任务可能会变得非常简单。
-
注意力层 – 对于解码器中的每个输入令牌,它会查看编码器的输出和解码器的掩蔽关注输出,以生成语义丰富的隐藏输出。由于该层不仅关注解码器输入,我们将其称为注意力层。
-
全连接层 – 生成解码器关注表示的逐元素隐藏表示。
如图 10.3所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_03.png
图 10.3:Transformer 模型如何用于将英文句子翻译成法语。该图展示了编码器和解码器中的各个层,以及编码器内部、解码器内部和编码器与解码器之间的各种连接。方框表示模型的输入和输出。矩形阴影框表示子层的临时输出。<sos>
符号表示解码器输入的开始。
接下来,让我们学习自注意力层的计算机制。
计算自注意力层的输出
毫无疑问,自注意力层是 Transformer 的核心。支配自注意力机制的计算可能比较难以理解。因此,本节将详细解释自注意力技术。要理解的三个关键概念是:查询、键和值。查询和键用于生成亲和力矩阵。对于解码器的注意力层,亲和力矩阵中的位置 i,j 表示编码器状态(键)i 与解码器输入(查询)j 之间的相似度。接着,我们为每个位置创建一个加权平均的编码器状态(值),权重由亲和力矩阵给出。
为了加深我们的理解,让我们假设一个情境,解码器正在生成自注意力输出。假设我们有一个英法机器翻译任务。以句子Dogs are great为例,翻译成法语就是Les chiens sont super。假设我们处在第 2 时间步,尝试生成单词chiens。我们将每个单词用一个浮动点数表示(例如,简化版的单词嵌入表示):
Dogs -> 0.8
are -> 0.3
great -> -0.2
chiens -> 0.5
现在让我们计算亲和力矩阵(具体来说,是亲和力向量,因为我们只考虑单个解码器输入)。查询值为 0.5,键(即编码器状态序列)为[0.8, 0.3, -0.2]
。如果我们进行点积运算,结果为:
[0.4, 0.15, -0.1]
让我们理解一下这个亲和矩阵所表达的含义。相对于单词chiens,单词Dogs具有最高的相似度,单词are也有较高的相似度(因为chiens是复数,指代的是英文中的are)。然而,单词great与单词chiens的相似度是负值。然后,我们可以计算该时间步的最终注意力输出,计算方式如下:
[0.4 * 0.8, 0.15 * 0.3, -0.1 * -0.2] = [0.32 + 0.45 + 0.02] = 0.385
我们最终得到的输出位于英语单词匹配的一部分,其中单词great的距离最大。这个例子展示了查询、键和值是如何发挥作用的,以计算最终的注意力输出。
现在,让我们来看一下实际在该层中发生的计算。为了计算查询、键和值,我们使用权重矩阵对实际输入进行线性投影。三个权重矩阵是:
-
查询权重矩阵 (https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_001.png)
-
键权重矩阵 (https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_002.png)
-
值权重矩阵 (https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_003.png)
每个权重矩阵通过与权重矩阵相乘,为给定输入序列中某个位置的标记(位置 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_004.png)产生三个输出,计算方式如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_005.png, https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_006.png, 和 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_007.png
Q,K,和V是大小为*[B, T, d]的张量,其中B*是批量大小,T是时间步数,d是一个超参数,用于定义潜在表示的维度。这些张量随后用于计算亲和矩阵,计算方式如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_04.png
图 10.4:自注意力层中的计算过程。自注意力层从输入序列开始,计算查询、键和值向量序列。然后,将查询和键转换为概率矩阵,该矩阵用于计算值的加权和。
亲和矩阵P的计算方式如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_008.png
然后,计算自注意力层的最终注意力输出,计算方式如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_009.png
在这里,Q表示查询张量,K表示键张量,V表示值张量。这就是 Transformer 模型如此强大的原因;与 LSTM 模型不同,Transformer 模型将序列中的所有标记聚合成一个矩阵乘法,使这些模型具有很高的并行性。图 10.4还展示了自注意力层内发生的计算过程。
Transformer 中的嵌入层
词嵌入提供了一种语义保留的词语表示,基于词语使用的上下文。换句话说,如果两个词语在相同的上下文中使用,它们将具有相似的词向量。例如,cat和dog将具有相似的表示,而cat和volcano将具有截然不同的表示。
词向量最早在 Mikolov 等人发表的论文Efficient Estimation of Word Representations in Vector Space中被提出(arxiv.org/pdf/1301.3781.pdf
)。它有两种变体:skip-gram 和连续词袋(CBOW)。嵌入通过首先定义一个大小为V x E的大矩阵来工作,其中V是词汇表的大小,E是嵌入的大小。E是用户定义的超参数;较大的E通常会导致更强大的词嵌入。实际上,你不需要将嵌入的大小增大到超过 300。
受原始词向量算法的启发,现代深度学习模型使用嵌入层来表示词语/标记。以下通用方法(以及后续的预训练以微调这些嵌入)用于将词嵌入整合到机器学习模型中:
-
定义一个随机初始化的词嵌入矩阵(或预训练的嵌入,可以免费下载)
-
定义使用词嵌入作为输入并产生输出的模型(例如情感分析或语言翻译)
-
对整个模型(嵌入和模型)进行端到端训练,完成任务
在 Transformer 模型中使用相同的技术。然而,在 Transformer 模型中,有两种不同的嵌入:
-
标记嵌入(为模型在输入序列中看到的每个标记提供唯一表示)
-
位置嵌入(为输入序列中的每个位置提供唯一表示)
标记嵌入为每个标记(如字符、词语和子词)提供一个唯一的嵌入向量,这取决于模型的标记化机制
位置嵌入用于指示模型一个标记出现的位置。位置嵌入的主要作用是告诉 Transformer 模型一个词语出现的位置。这是因为,与 LSTM/GRU 不同,Transformer 模型没有序列的概念,它一次性处理整个文本。此外,改变词语的位置可能会改变句子的含义/词义。例如:
Ralph loves his tennis ball. It likes to chase the ball
Ralph loves his tennis ball. Ralph likes to chase it
在上述句子中,it一词指代不同的事物,it的位置可以作为线索来识别这种差异。原始的 Transformer 论文使用以下方程来生成位置嵌入:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_010.png
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_011.png
其中,pos 表示序列中的位置,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_012.png 表示 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_013.png 特征维度(https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_014.png)。偶数编号的特征使用正弦函数,奇数编号的特征使用余弦函数。图 10.5 展示了随着时间步和特征位置的变化,位置嵌入是如何变化的。可以看到,特征位置索引较高的位置具有较低频率的正弦波。尚不完全清楚作者是如何得出该精确方程的。
然而,他们确实提到,尽管使用上述方程与在训练过程中让模型联合学习位置嵌入之间没有明显的性能差异。
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_05.png
图 10.5:随着时间步和特征位置的变化,位置嵌入是如何变化的。偶数编号的特征位置使用正弦函数,奇数编号的位置使用余弦函数。此外,随着特征位置的增加,信号的频率降低。
需要注意的是,token 嵌入和位置嵌入将具有相同的维度 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_015.png,这使得逐元素相加成为可能。最后,作为模型的输入,token 嵌入和位置嵌入相加,形成一个单一的混合嵌入向量(图 10.6):
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_06.png
图 10.6:Transformer 模型中生成的嵌入以及最终嵌入是如何计算的
现在让我们讨论 Transformer 中每一层使用的两种优化技术:残差连接和层归一化。
残差与归一化
Transformer 模型的另一个重要特性是,模型中各层之间存在残差连接和归一化层。
残差连接通过将给定层的输出加到一个或多个前面层的输出上形成。这反过来通过模型形成快捷连接,并通过减少梯度消失现象的发生来提供更强的梯度流(图 10.7)。梯度消失问题导致最接近输入层的梯度非常小,从而妨碍了这些层的训练。残差连接在深度学习模型中的应用,由 Kaiming He 等人在论文“Deep Residual Learning for Image Recognition”中推广(arxiv.org/pdf/1512.03385.pdf
)
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_07.png
图 10.7:残差连接的工作原理
在 Transformer 模型中,每一层的残差连接是通过以下方式创建的:
-
进入自注意力子层的输入会加到自注意力子层的输出上。
-
进入全连接子层的输入会加到全连接子层的输出上。
接下来,经过残差连接强化的输出通过一个层归一化层。层归一化类似于批量归一化,它是一种减少神经网络中协变量偏移的方法,使得神经网络能够更快地训练并取得更好的性能。协变量偏移是指神经网络激活值的分布变化(由于数据分布变化引起的),这种变化会在模型训练过程中发生。这些分布的变化破坏了训练过程中的一致性,并对模型产生负面影响。该方法在 Ba 等人发表的论文Layer Normalization中被提出(arxiv.org/pdf/1607.06450.pdf
)。
批量归一化通过计算激活值的均值和方差,并以批次样本的平均值为基础,从而使其性能依赖于训练模型时使用的迷你批次。
然而,层归一化以一种方式计算激活值的均值和方差(即归一化项),使得每个隐藏单元的归一化项相同。换句话说,层归一化对层中的所有隐藏单元有一个共同的均值和方差值。这与批量归一化形成对比,后者为每个隐藏单元维持单独的均值和方差值。此外,不同于批量归一化,层归一化不会对批次中的样本求均值;相反,它跳过了平均值计算,并为不同的输入提供不同的归一化项。通过为每个样本单独计算均值和方差,层归一化摆脱了对迷你批次大小的依赖。如需了解该方法的更多细节,请参阅 Ba 等人发表的原始论文。
TensorFlow 在www.tensorflow.org/api_docs/python/tf/keras/layers/LayerNormalization
提供了方便的层归一化算法实现。你可以简单地在使用 TensorFlow Keras API 定义的任何模型中使用该层。
图 10.8 展示了残差连接和层归一化如何在 Transformer 模型中使用:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_08.png
图 10.8:残差连接和层归一化层在 Transformer 模型中的使用方式
至此,我们结束了对 Transformer 模型组件的讨论。我们已经讨论了 Transformer 模型的所有关键组件。Transformer 模型是一个基于编码器-解码器的模型。编码器和解码器具有相同的结构,除了少数几个小差异。Transformer 使用自注意力机制,这是一种强大的并行化注意力机制,用于在每个时间步骤关注其他输入。Transformer 还使用多个嵌入层,例如词汇嵌入和位置嵌入,以注入有关词汇和其位置的信息。Transformer 还使用残差连接和层归一化,以提高模型的性能。
接下来,我们将讨论一个特定的 Transformer 模型,称为 BERT,我们将使用它来解决一个问答问题。
理解 BERT
BERT(来自 Transformer 的双向编码器表示)是近年来众多 Transformer 模型中的一个。
BERT 在 Delvin 等人发表的论文 BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding 中提出(arxiv.org/pdf/1810.04805.pdf
)。Transformer 模型分为两大类:
-
基于编码器的模型
-
基于解码器(自回归)模型
换句话说,与使用 Transformer 的编码器和解码器相比,Transformer 的编码器或解码器部分为这些模型提供了基础。两者之间的主要区别在于注意力机制的使用方式。基于编码器的模型使用双向注意力,而基于解码器的模型使用自回归(即从左到右)注意力。
BERT 是一个基于编码器的 Transformer 模型。它接收一个输入序列(一组标记)并生成一个编码的输出序列。图 10.9 描述了 BERT 的高层次架构:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_09.png
图 10.9:BERT 的高层次架构。它接收一组输入标记并生成通过多个隐藏层生成的隐藏表示序列。
现在,让我们讨论一些与 BERT 相关的细节,比如 BERT 消耗的输入和它设计用来解决的任务。
BERT 的输入处理
当 BERT 接收输入时,它会在输入中插入一些特殊的标记。首先,在开头,它插入一个 [CLS]
(分类的缩写)标记,用于生成某些任务(如序列分类)的最终隐藏表示。它代表在处理序列中所有标记后的输出。接下来,根据输入类型,它还会插入一个 [SEP]
(意为“分隔”)标记。[SEP]
标记用于标记输入中不同序列的开始和结束。例如,在问答中,模型将问题和可能包含答案的上下文(如段落)作为输入,[SEP]
用于问题和上下文之间。此外,还有 [PAD]
标记,可用于将短序列填充至所需长度。
[CLS]
标记会附加到输入的每个序列中,表示输入的开始。它也是输入到 BERT 上层分类头的基础,用于解决您的 NLP 任务。如您所知,BERT 会为序列中的每个输入标记生成隐藏表示。根据惯例,[CLS]
标记对应的隐藏表示将作为输入,传递给位于 BERT 之上的分类模型。
接下来,使用三种不同的嵌入空间生成最终的令牌嵌入。每个词汇表中的令牌都有一个独特的向量表示。位置嵌入编码了每个令牌的位置,如前所述。最后,段落嵌入为输入中的每个子组件提供了一个独特的表示,当输入由多个组件组成时。例如,在问答任务中,问题将拥有一个独特的向量作为其段落嵌入向量,而上下文将具有不同的嵌入向量。这是通过为输入序列中的不同组件提供https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_016.png嵌入向量来实现的。根据输入中每个令牌指定的组件索引,检索相应的段落嵌入向量。需要提前指定https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_018.png。
BERT 解决的任务
BERT 解决的特定任务可以分为四个不同的类别。这些类别受 通用语言理解评估 (GLUE) 基准任务套件的启发(gluebenchmark.com
):
-
序列分类 – 在这里,给定一个输入序列,模型被要求为整个序列预测一个标签(例如,情感分析或垃圾邮件识别)。
-
令牌分类 – 在这里,给定一个输入序列,模型被要求为序列中的每个令牌预测一个标签(例如,命名实体识别或词性标注)。
-
问答任务 – 在这里,输入由两个序列组成:一个问题和一个上下文。问题和上下文由一个
[SEP]
令牌分隔。模型被训练以预测答案所属的令牌跨度的起始和结束索引。 -
多项选择 – 在这里,输入由多个序列组成;一个问题后面跟着多个候选答案,这些候选答案可能是也可能不是问题的答案。这些多个序列由令牌
[SEP]
分隔,并作为一个单一的输入序列提供给模型。模型被训练以预测该问题的正确答案(即,类别标签)。
图 10.10 描述了 BERT 如何用于解决这些不同的任务:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_10.png
图 10.10:BERT 如何用于不同的 NLP 任务
BERT 的设计使其能够在不修改基础模型的情况下完成这些任务。
在涉及多个序列的任务中(例如多项选择题),你需要模型区分属于不同段落的不同输入(即,在问答任务中,哪些令牌是问题,哪些令牌是上下文)。为了做出这个区分,使用了 [SEP]
令牌。一个 [SEP]
令牌插入在不同序列之间。例如,如果你正在解决一个问答问题,输入可能如下所示:
问题:球的颜色是什么?
段落:Tippy 是一只狗。她喜欢玩她的红色球。
然后输入到 BERT 的内容可能如下所示:
[CLS]
球的颜色是什么 [SEP]
Tippy 是一只狗,她喜欢玩她的红色球 [SEP]
现在我们已经讨论了 BERT 的所有元素,因此我们可以成功地使用它来解决下游的 NLP 任务,接下来让我们重述关于 BERT 的关键点:
-
BERT 是一个基于编码器的 Transformer
-
BERT 对输入序列中的每个标记输出一个隐藏表示
-
BERT 有三个嵌入空间:标记嵌入、位置嵌入和片段嵌入
-
BERT 使用特殊的标记
[CLS]
来表示输入的开始,并作为下游分类模型的输入 -
BERT 被设计用来解决四种类型的 NLP 任务:序列分类、标记分类、自由文本问答和多项选择问答
-
BERT 使用特殊的标记
[SEP]
来分隔序列 A 和序列 B
BERT 的强大不仅体现在其结构上。BERT 在一个庞大的文本语料库上进行预训练,使用几种不同的预训练技术。换句话说,BERT 已经具备了对语言的扎实理解,这使得下游的自然语言处理任务更容易解决。接下来,让我们讨论 BERT 是如何进行预训练的。
BERT 是如何进行预训练的
BERT 的真正价值在于它已经在一个庞大的数据语料库上进行了自监督的预训练。在预训练阶段,BERT 被训练于两个不同的任务:
-
掩码语言建模(有时缩写为 MLM)
-
下一个句子预测(有时缩写为 NSP)
现在让我们讨论上述两个任务的细节,以及它们是如何为 BERT 提供语言理解的。
掩码语言建模(MLM)
MLM 任务的灵感来源于 Cloze 任务或 Cloze 测试,学生会得到一个句子,其中有一个或多个空白,需要填充这些空白。同样地,给定一个文本语料库,句子中的词被掩码,然后模型需要预测这些被掩码的标记。例如,句子:
我去面包店买面包
可能变成:
我去 [MASK]
买面包
BERT 使用特殊的标记 [MASK]
来表示被掩码的词。然后,模型的目标词将是 bakery(面包店)。但是,这为模型引入了一个实际问题。特殊的 [MASK]
标记在实际文本中并不会出现。这意味着模型在微调阶段(即在分类问题上训练时)看到的文本与预训练时看到的文本会有所不同。这有时被称为 预训练-微调不一致。因此,BERT 的作者提出了以下方法来应对这个问题。当掩码一个词时,执行以下操作之一:
-
按照原样使用
[MASK]
标记(80% 的概率) -
使用一个随机词(10% 的概率)
-
使用真实的词(10% 的概率)
换句话说,模型在某些情况下会看到实际的单词,而不总是看到 [MASK]
,从而缓解了这种差异。
下一句预测(NSP)
在 NSP 任务中,模型会接收到一对句子 A 和 B(按此顺序),并被要求预测 B 是否是 A 后面的下一句。这可以通过在 BERT 上拟合一个二分类器并对选定的句子对进行端到端训练来实现。
生成句子对作为模型的输入并不难,可以以无监督的方式进行:
-
通过选择相邻的两句话,生成一个标签为 TRUE 的样本。
-
通过随机选择两句不相邻的句子,生成一个标签为 FALSE 的样本。
按照这种方法,我们生成一个用于下一个句子预测任务的标注数据集。然后,BERT 和二分类器一起,使用该标注数据集进行端到端的训练,以解决下游任务。为了看到这一过程的实际应用,我们将使用 Hugging Face 的 transformers
库。
使用场景:使用 BERT 回答问题。
现在让我们学习如何实现 BERT,在一个问答数据集上训练它,并让模型回答给定的问题。
Hugging Face transformers
库简介
我们将使用由 Hugging Face 构建的 transformers
库。transformers
库是一个高层次的 API,建立在 TensorFlow、PyTorch 和 JAX 之上。它提供了便捷的访问预训练 Transformer 模型的方式,这些模型可以轻松下载并进行微调。你可以在 Hugging Face 的模型注册表中找到模型,网址为 huggingface.co/models
。你可以按任务筛选模型,查看底层的深度学习框架等。
transformers
库的设计目的是提供一个非常低的入门门槛,使用复杂的 Transformer 模型。因此,使用该库时你只需要学习少数几个概念,就能快速上手。成功加载和使用模型需要三类重要的类:
-
模型类(如
TFBertModel
)– 包含模型的训练权重,形式为tf.keras.models.Model
或 PyTorch 等效类。 -
配置(如
BertConfig
)– 存储加载模型所需的各种参数和超参数。如果你直接使用预训练模型,则不需要显式定义其配置。 -
Tokenizer(如
BertTokenizerFast
)– 包含模型所需的词汇和词到 ID 的映射,用于对文本进行分词。
所有这些类都可以通过两个简单的函数来使用:
-
from_pretrained()
– 提供一种从模型库或本地实例化模型/配置/分词器的方法。 -
save_pretrained()
– 提供一种保存模型/配置/分词器的方法,以便以后重新加载。
TensorFlow 在 TensorFlow Hub(tfhub.dev/
)托管了多种 Transformer 模型(由 TensorFlow 和第三方发布)。如果你想了解如何使用 TensorFlow Hub 和原始 TensorFlow API 实现像 BERT 这样的模型,请访问 www.tensorflow.org/text/tutorials/classify_text_with_bert
。
我们很快就会看到这些类和函数如何在实际用例中使用。同样,重要的是要注意,尽管使用模型的界面非常简单易懂,但这也带来了一些副作用。由于它专门用于提供一种使用 TensorFlow、PyTorch 或 Jax 构建的 Transformer 模型的方法,你在使用时无法享受 TensorFlow 等框架所提供的模块化或灵活性。换句话说,你不能像使用 TensorFlow 构建 tf.keras.models.Model
,并使用 tf.keras.layers.Layer
对象那样使用 transformers
库。
探索数据
我们将用于此任务的数据集是一个流行的问答数据集,名为 SQUAD。每个数据点由四个部分组成:
-
一个问题
-
可能包含问题答案的上下文
-
答案的起始索引
-
答案
我们可以使用 Hugging Face 的 datasets
库下载数据集,并通过传入 "squad"
参数来调用 load_dataset()
函数:
from datasets import load_dataset
dataset = load_dataset("squad")
现在让我们使用以下方法打印一些示例:
for q, a in zip(dataset["train"]["question"][:5], dataset["train"]["answers"][:5]):
print(f"{q} -> {a}")
它将输出:
To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? -> {'text': ['Saint Bernadette Soubirous'], 'answer_start': [515]}
What is in front of the Notre Dame Main Building? -> {'text': ['a copper statue of Christ'], 'answer_start': [188]}
The Basilica of the Sacred heart at Notre Dame is beside to which structure? -> {'text': ['the Main Building'], 'answer_start': [279]}
What is the Grotto at Notre Dame? -> {'text': ['a Marian place of prayer and reflection'], 'answer_start': [381]}
What sits on top of the Main Building at Notre Dame? -> {'text': ['a golden statue of the Virgin Mary'], 'answer_start': [92]}
在这里,answer_start
表示答案在提供的上下文中开始的字符索引。通过对数据集中可用内容的充分理解,我们将执行一个简单的处理步骤。在训练模型时,我们将要求模型预测答案的起始和结束索引。在其原始形式中,仅存在 answer_start
。我们需要手动将 answer_end
添加到数据集中。以下函数实现了这一功能。此外,它还对数据集进行了几个检查:
def compute_end_index(answers, contexts):
""" Add end index to answers """
fixed_answers = []
for answer, context in zip(answers, contexts):
gold_text = answer['text'][0]
answer['text'] = gold_text
start_idx = answer['answer_start'][0]
answer['answer_start'] = start_idx
# Make sure the starting index is valid and there is an answer
assert start_idx >=0 and len(gold_text.strip()) > 0:
end_idx = start_idx + len(gold_text)
answer['answer_end'] = end_idx
# Make sure the corresponding context matches the actual answer
assert context[start_idx:end_idx] == gold_text
fixed_answers.append(answer)
return fixed_answers, contexts
train_questions = dataset["train"]["question"]
print("Training data corrections")
train_answers, train_contexts = compute_end_index(
dataset["train"]["answers"], dataset["train"]["context"]
)
test_questions = dataset["validation"]["question"]
print("\nValidation data correction")
test_answers, test_contexts = compute_end_index(
dataset["validation"]["answers"], dataset["validation"]["context"]
)
接下来,我们将从 Hugging Face 仓库下载一个预训练的 BERT 模型,并深入了解该模型。
实现 BERT
要使用 Hugging Face 仓库中的预训练 Transformer 模型,我们需要三个组件:
-
Tokenizer
– 负责将长文本(例如句子)拆分成更小的标记 -
config
– 包含模型的配置 -
Model
– 接收标记,查找嵌入,并使用提供的输入生成最终输出
我们可以忽略 config
,因为我们将直接使用预训练模型。但是,为了完整展示,我们还是会使用配置。
实现和使用 Tokenizer
首先,我们将查看如何下载 Tokenizer。你可以使用 transformers
库下载 Tokenizer。只需调用 PreTrainedTokenizerFast
基类提供的 from_pretrained()
函数:
from transformers import BertTokenizerFast
tokenizer = BertTokenizerFast.from_pretrained('bert-base-uncased')
我们将使用名为 bert-base-uncased
的分词器。它是为 BERT 基础模型开发的分词器,并且是不区分大小写的(即不区分大写字母和小写字母)。接下来,让我们看看分词器的实际应用:
context = "This is the context"
question = "This is the question"
token_ids = tokenizer(
text=context, text_pair=question,
padding=False, return_tensors='tf'
)
print(token_ids)
让我们来理解一下我们传递给分词器的参数:
-
text
– 单个或一批文本序列,供分词器进行编码。每个文本序列都是一个字符串。 -
text_pair
– 一个可选的单个或一批文本序列,供分词器进行编码。在模型接受多部分输入的情况下(例如,在问答任务中,包含问题和上下文),它非常有用。 -
padding
– 表示填充策略。如果设置为True
,则将填充到数据集中的最大序列长度。如果设置为max_length
,则将填充到由max_length
参数指定的长度。如果设置为False
,则不进行填充。 -
return_tensors
– 定义返回张量类型的参数。它可以是pt
(PyTorch)或tf
(TensorFlow)。由于我们需要使用 TensorFlow 张量,因此将其定义为'tf'
。
这将输出:
{
'input_ids': <tf.Tensor: shape=(1, 11), dtype=int32, numpy=array([[ 101, 2023, 2003, 1996, 6123, 102, 2023, 2003, 1996, 3160, 102]])>,
'token_type_ids': <tf.Tensor: shape=(1, 11), dtype=int32, numpy=array([[0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]])>,
'attention_mask': <tf.Tensor: shape=(1, 11), dtype=int32, numpy=array([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]])>
}
这将输出一个 transformers.tokenization_utils_base.BatchEncoding
对象,它本质上是一个字典。它包含三个键,每个键对应一个张量值:
-
input_ids
– 提供文本序列中标记的 ID。此外,它会在序列开头插入[CLS]
标记的 ID,并在问题与上下文之间以及序列末尾插入两个[SEP]
标记的 ID。 -
token_type_ids
– 用于段落嵌入的段落 ID。 -
attention_mask
– 注意力掩码表示在前向传播过程中可以被注意到的词。由于 BERT 是一个编码器模型,任何标记都可以关注任何其他标记。唯一的例外是填充的标记,它们会在注意力机制中被忽略。
我们还可以将这些标记 ID 转换为实际的标记,以了解它们代表什么。为此,我们使用 convert_ids_to_tokens()
函数:
print(tokenizer.convert_ids_to_tokens(token_ids['input_ids'].numpy()[0]))
这将打印:
['[CLS]', 'this', 'is', 'the', 'context', '[SEP]', 'this', 'is', 'the', 'question', '[SEP]']
你可以看到分词器如何将特殊标记如 [CLS]
和 [SEP]
插入到文本序列中。在理解了分词器的功能后,接下来让我们使用它来编码训练集和测试集:
# Encode train data
train_encodings = tokenizer(train_contexts, train_questions, truncation=True, padding=True, return_tensors='tf')
# Encode test data
test_encodings = tokenizer(test_contexts, test_questions, truncation=True, padding=True, return_tensors='tf')
你可以通过运行以下命令来检查训练编码的大小:
print("train_encodings.shape: {}".format(train_encodings["input_ids"].shape))
这将输出:
train_encodings.shape: (87599, 512)
我们数据集中的最大序列长度为 512。因此,我们看到序列的最大长度为 512。一旦我们将数据进行分词,我们还需要进行一步数据处理。我们的 answer_start
和 answer_end
索引是基于字符的。然而,由于我们处理的是标记,我们需要将基于字符的索引转换为基于标记的索引。我们将为此定义一个函数:
def replace_char_with_token_indices(encodings, answers):
start_positions = []
end_positions = []
n_updates = 0
# Go through all the answers
for i in range(len(answers)):
# Get the token position for both start end char positions
start_positions.append(encodings.char_to_token(i,
answers[i]['answer_start']))
end_positions.append(encodings.char_to_token(i,
answers[i]['answer_end'] - 1))
if start_positions[-1] is None or end_positions[-1] is None:
n_updates += 1
# if start position is None, the answer passage has been truncated
# In the guide, https://huggingface.co/transformers/custom_
# datasets.html#qa-squad they set it to model_max_length, but
# this will result in NaN losses as the last available label is
# model_max_length-1 (zero-indexed)
if start_positions[-1] is None:
start_positions[-1] = tokenizer.model_max_length -1
if end_positions[-1] is None:
end_positions[-1] = tokenizer.model_max_length -1
print("{}/{} had answers truncated".format(n_updates,
len(answers)))
encodings.update({'start_positions': start_positions,
'end_positions': end_positions})
该函数接收由分词器生成的一组BatchEncodings
(称为encodings
)和一组答案(字典列表)。然后,它通过两个新键start_positions
和end_positions
更新提供的编码。这两个键分别保存表示答案开始和结束的基于 token 的索引。如果没有找到答案,我们将开始和结束索引设置为最后一个 token。为了将现有的基于字符的索引转换为基于 token 的索引,我们使用一个名为char_to_token()
的函数,该函数由BatchEncodings
类提供。它以字符索引为输入,输出相应的 token 索引。定义好该函数后,让我们在训练和测试数据上调用它:
replace_char_with_token_indices(train_encodings, train_answers)
replace_char_with_token_indices(test_encodings, test_answers)
使用清理后的数据,我们现在将定义一个 TensorFlow 数据集。请注意,此函数会原地修改编码。
定义 TensorFlow 数据集
接下来,我们实现一个 TensorFlow 数据集,以生成模型所需的数据。我们的数据将包含两个元组:一个包含输入,另一个包含目标。输入元组包含:
-
输入 token ID – 一批填充的 token ID,大小为
[batch size, sequence length]
-
注意力掩码 – 一批注意力掩码,大小为
[batch size, sequence length]
输出元组包含:
-
答案的起始索引 – 一批答案的起始索引
-
答案的结束索引 – 一批答案的结束索引
我们将首先定义一个生成器,生成这种格式的数据:
def data_gen(input_ids, attention_mask, start_positions, end_positions):
""" Generator for data """
for inps, attn, start_pos, end_pos in zip(input_ids,
attention_mask, start_positions, end_positions):
yield (inps, attn), (start_pos, end_pos)
由于我们已经处理了数据,因此只需重新组织已有的数据即可使用上面的代码返回结果。
接下来,我们将定义一个部分函数,可以在不传递任何参数的情况下直接调用:
# Define the generator as a callable
train_data_gen = partial(data_gen,
input_ids=train_encodings['input_ids'], attention_mask=train_
encodings['attention_mask'],
start_positions=train_encodings['start_positions'],
end_positions=train_encodings['end_positions']
)
然后,将此函数传递给tf.data.Dataset.from_generator()
函数:
# Define the dataset
train_dataset = tf.data.Dataset.from_generator(
train_data_gen, output_types=(('int32', 'int32'), ('int32', 'int32'))
)
然后,我们将训练数据集中的数据打乱。在打乱 TensorFlow 数据集时,我们需要提供缓冲区大小。缓冲区大小定义了用于打乱的样本数量。这里我们将其设置为 1,000 个样本:
# Shuffling the data
train_dataset = train_dataset.shuffle(1000)
print('\tDone')
接下来,我们将数据集分为两部分:训练集和验证集。我们将使用前 10,000 个样本作为验证集,其余的数据作为训练集。两个数据集都将使用批量大小为 4 的批处理:
# Valid set is taken as the first 10000 samples in the shuffled set
valid_dataset = train_dataset.take(10000)
valid_dataset = valid_dataset.batch(4)
# Rest is kept as the training data
train_dataset = train_dataset.skip(10000)
train_dataset = train_dataset.batch(4)
最后,我们按照相同的过程创建测试数据集:
# Creating test data
print("Creating test data")
# Define the generator as a callable
test_data_gen = partial(data_gen,
input_ids=test_encodings['input_ids'],
attention_mask=test_encodings['attention_mask'],
start_positions=test_encodings['start_positions'],
end_positions=test_encodings['end_positions']
)
test_dataset = tf.data.Dataset.from_generator(
test_data_gen, output_types=(('int32', 'int32'), ('int32',
'int32'))
)
test_dataset = test_dataset.batch(8)
现在,让我们看看 BERT 的架构如何用于回答问题。
用于回答问题的 BERT
在预训练的 BERT 模型基础上,为了使其适应问答任务,进行了几项修改。首先,模型输入一个问题,后接一个上下文。如前所述,上下文可能包含也可能不包含问题的答案。输入格式为[CLS] <问题标记> [SEP] <上下文标记> [SEP]
。然后,对于上下文中的每个标记位置,我们有两个分类头预测概率。一个头部预测每个上下文标记作为答案开始的概率,另一个头部预测每个上下文标记作为答案结束的概率。
一旦我们找到了答案的起始和结束索引,我们就可以使用这些索引从上下文中提取答案。
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_10_11.png
图 10.11:使用 BERT 进行问答。模型输入一个问题,后接一个上下文。模型有两个头部:一个预测上下文中每个标记作为答案开始的概率,另一个预测上下文中每个标记作为答案结束的概率。
定义配置和模型
在 Hugging Face 中,每种 Transformer 模型有多个变种。这些变种是基于这些模型解决的不同任务。例如,对于 BERT,我们有:
-
TFBertForPretraining
– 没有任务特定头部的预训练模型 -
TFBertForSequenceClassification
– 用于对文本序列进行分类 -
TFBertForTokenClassification
– 用于对文本序列中的每个标记进行分类 -
TFBertForMultipleChoice
– 用于回答多项选择题 -
TFBertForQuestionAnswering
– 用于从给定上下文中提取问题的答案 -
TFBertForMaskedLM
– 用于在掩蔽语言模型任务上预训练 BERT -
TFBertForNextSentencePrediction
– 用于预训练 BERT 预测下一句
在这里,我们感兴趣的是TFBertForQuestionAnswering
。让我们导入这个类以及BertConfig
类,我们将从中提取重要的超参数:
from transformers import BertConfig, TFBertForQuestionAnswering
要获取预训练的config
,我们调用BertConfig
的from_pretrained()
函数,并传入我们感兴趣的模型。在这里,我们将使用bert-base-uncased
模型:
config = BertConfig.from_pretrained("bert-base-uncased", return_dict=False)
你可以打印config
并查看其中的内容:
BertConfig {
"architectures": [
"BertForMaskedLM"
],
"attention_probs_dropout_prob": 0.1,
"classifier_dropout": null,
"gradient_checkpointing": false,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 768,
"initializer_range": 0.02,
"intermediate_size": 3072,
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"model_type": "bert",
"num_attention_heads": 12,
"num_hidden_layers": 12,
"pad_token_id": 0,
"position_embedding_type": "absolute",
"return_dict": false,
"transformers_version": "4.15.0",
"type_vocab_size": 2,
"use_cache": true,
"vocab_size": 30522
}
最后,我们通过调用TFBertForQuestionAnswering
类中的相同函数from_pretrained()
并传入我们刚刚获得的config
来获取模型:
model = TFBertForQuestionAnswering.from_pretrained("bert-base-uncased", config=config)
当你运行这个时,你会收到一个警告,内容如下:
All model checkpoint layers were used when initializing TFBertForQuestionAnswering.
Some layers of TFBertForQuestionAnswering were not initialized from the model checkpoint at bert-base-uncased and are newly initialized: ['qa_outputs']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.
这是预期的,并且完全正常。它表示有些层尚未从预训练模型中初始化;模型的输出头需要作为新层引入,因此它们没有预初始化。
之后,我们将定义一个函数,将返回的模型包装为tf.keras.models.Model
对象。我们需要执行这一步,因为如果我们直接使用模型,TensorFlow 会返回以下错误:
TypeError: The two structures don't have the same sequence type.
Input structure has type <class 'tuple'>, while shallow structure has type
<class 'transformers.modeling_tf_outputs.TFQuestionAnsweringModelOutput'>.
因此,我们将定义两个输入层:一个输入令牌 ID,另一个输入 attention mask 并将其传递给模型。最后,我们得到模型的输出。然后,我们使用这些输入和输出定义一个tf.keras.models.Model
:
def tf_wrap_model(model):
""" Wraps the huggingface's model with in the Keras Functional API """
# Define inputs
input_ids = tf.keras.layers.Input([None,], dtype=tf.int32,
name="input_ids")
attention_mask = tf.keras.layers.Input([None,], dtype=tf.int32,
name="attention_mask")
# Define the output (TFQuestionAnsweringModelOutput)
out = model([input_ids, attention_mask])
# Get the correct attributes in the produced object to generate an
# output tuple
wrap_model = tf.keras.models.Model([input_ids, attention_mask],
outputs=(out.start_logits, out.end_logits))
return wrap_model
正如我们在学习模型结构时所了解到的,问答 BERT 有两个头:一个用于预测答案的起始索引,另一个用于预测结束索引。因此,我们需要优化来自这两个头的两个损失。这意味着我们需要将两个损失相加以获得最终的损失。当我们有一个多输出模型时,我们可以为每个输出头传递多个损失函数。在这里,我们定义了一个单一的损失函数。这意味着两个头会使用相同的损失,并将它们加起来生成最终损失:
loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
acc = tf.keras.metrics.SparseCategoricalAccuracy()
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-5)
model_v2 = tf_wrap_model(model)
model_v2.compile(optimizer=optimizer, loss=loss, metrics=[acc])
现在,我们将看到如何在问答任务中训练和评估我们的模型。
训练和评估模型
我们已经准备好了数据并定义了模型。训练模型非常简单,只需要一行代码:
model_v2.fit(
train_dataset,
validation_data=valid_dataset,
epochs=3
)
你应该会看到以下输出:
Epoch 1/2
19400/19400 [==============================] - 7175s 369ms/step
- loss: 2.7193 - tf_bert_for_question_answering_loss: 1.4153 - tf_bert_for_question_answering_1_loss: 1.3040 - tf_bert_for_question_answering_sparse_categorical_accuracy: 0.5975 - tf_bert_for_question_answering_1_sparse_categorical_accuracy: 0.6376 - val_loss: 2.1615 - val_tf_bert_for_question_answering_loss: 1.0898 - val_tf_bert_for_question_answering_1_loss: 1.0717 - val_tf_bert_for_question_answering_sparse_categorical_accuracy: 0.7120 - val_tf_bert_for_question_answering_1_sparse_categorical_accuracy: 0.7350
Epoch 2/2
19400/19400 [==============================] - 7192s 370ms/step - loss: 1.6691 - tf_bert_for_question_answering_loss: 0.8865 - tf_bert_for_question_answering_1_loss: 0.7826 - tf_bert_for_question_answering_sparse_categorical_accuracy: 0.7245 - tf_bert_for_question_answering_1_sparse_categorical_accuracy: 0.7646 - val_loss: 2.1836 - val_tf_bert_for_question_answering_loss: 1.0988 - val_tf_bert_for_question_answering_1_loss: 1.0847 - val_tf_bert_for_question_answering_sparse_categorical_accuracy: 0.7289 - val_tf_bert_for_question_answering_1_sparse_categorical_accuracy: 0.7504
It took 14366.591783046722 seconds to complete the training
你应该会看到验证集的准确率达到大约 73% 到 75% 之间。这是相当高的,考虑到我们只训练了模型两个周期。这个表现可以归功于我们下载的预训练模型已经具备了很高的语言理解能力。让我们在测试数据上评估模型:
model_v2.evaluate(test_dataset)
它应该输出以下内容:
1322/1322 [======================] - 345s 261ms/step - loss: 2.2205 - tf_bert_for_question_answering_loss: 1.1325 - tf_bert_for_question_answering_1_loss: 1.0881 - tf_bert_for_question_answering_sparse_categorical_accuracy: 0.6968 - tf_bert_for_question_answering_1_sparse_categorical_accuracy: 0.7250
我们看到它在测试数据集上的表现也相当不错。最后,我们可以保存模型。我们将保存模型的TFBertForQuestionAnswering
组件。我们还将保存分词器:
import os
# Create folders
if not os.path.exists('models'):
os.makedirs('models')
if not os.path.exists('tokenizers'):
os.makedirs('tokenizers')
# Save the model
model_v2.get_layer("tf_bert_for_question_answering").save_pretrained(os.path.join('models', 'bert_qa'))
# Save the tokenizer
tokenizer.save_pretrained(os.path.join('tokenizers', 'bert_qa'))
我们已经训练并评估了模型,以确保其表现良好。确认模型表现良好后,我们最终将其保存以供将来使用。接下来,让我们讨论如何使用这个模型生成给定问题的答案。
使用 Bert 回答问题
现在,让我们编写一个简单的脚本,从训练好的模型中生成问题的答案。首先,我们定义一个示例问题来生成答案。我们还将存储输入和真实答案以进行比较:
i = 5
# Define sample question
sample_q = test_questions[i]
# Define sample context
sample_c = test_contexts[i]
# Define sample answer
sample_a = test_answers[i]
接下来,我们将定义模型的输入。模型的输入需要有一个批量维度。因此,我们使用[i:i+1]
语法来确保批量维度不会被压扁:
# Get the input in the format BERT accepts
sample_input = (test_encodings["input_ids"][i:i+1],
test_encodings["attention_mask"][i:i+1])
现在,让我们定义一个简单的函数ask_bert
,用于从给定的问题中找到答案。这个函数接收输入、分词器和模型作为参数。
然后,它从分词器生成令牌 ID,将它们传递给模型,输出答案的起始和结束索引,最后从上下文的文本中提取相应的答案:
def ask_bert(sample_input, tokenizer, model):
""" This function takes an input, a tokenizer, a model and returns the
prediciton """
out = model.predict(sample_input)
pred_ans_start = tf.argmax(out[0][0])
pred_ans_end = tf.argmax(out[1][0])
print("{}-{} token ids contain the answer".format(pred_ans_start,
pred_ans_end))
ans_tokens = sample_input[0][0][pred_ans_start:pred_ans_end+1]
return " ".join(tokenizer.convert_ids_to_tokens(ans_tokens))
让我们执行以下代码行来打印模型给出的答案:
print("Question")
print("\t", sample_q, "\n")
print("Context")
print("\t", sample_c, "\n")
print("Answer (char indexed)")
print("\t", sample_a, "\n")
print('='*50,'\n')
sample_pred_ans = ask_bert(sample_input, tokenizer, model_v2)
print("Answer (predicted)")
print(sample_pred_ans)
print('='*50,'\n')
它将输出:
Question
What was the theme of Super Bowl 50?
Context
Super Bowl 50 was an American football game to determine the champion of the National Football League (NFL) for the 2015 season. The American
Football Conference (AFC) champion Denver Broncos defeated the National Football Conference (NFC) champion Carolina Panthers 24–10 to earn their third Super Bowl title. The game was played on February 7, 2016, at Levi's Stadium in the San Francisco Bay Area at Santa Clara, California. As this was the 50th Super Bowl, the league emphasized the "golden anniversary" with various gold-themed initiatives, as well as temporarily suspending the tradition of naming each Super Bowl game with Roman numerals (under which the game would have been known as "Super Bowl L"), so that the logo could prominently feature the Arabic numerals 50\.
Answer (char indexed)
{'answer_start': 487, 'text': '"golden anniversary"', 'answer_end': 507}
==================================================
98-99 token ids contain the answer
Answer (predicted)
golden anniversary
==================================================
我们可以看到 BERT 已经正确回答了问题。我们学习了很多关于 Transformer 的内容,以及 BERT 特有的架构。然后,我们运用这些知识,将 BERT 调整为解决问答问题。至此,我们结束了关于 Transformer 和 BERT 的讨论。
摘要
在本章中,我们讨论了 Transformer 模型。首先,我们从微观角度看了 Transformer,以理解模型的内部工作原理。我们看到,Transformer 使用了自注意力机制,这是一种强大的技术,可以在处理一个输入时,关注文本序列中的其他输入。我们还看到,Transformer 使用位置编码来告知模型 token 在序列中的相对位置,除了 token 编码之外。我们还讨论了,Transformer 利用残差连接(即快捷连接)和层归一化,以提高模型的训练效果。
然后,我们讨论了 BERT,一个基于编码器的 Transformer 模型。我们查看了 BERT 接受的数据格式和它在输入中使用的特殊 token。接下来,我们讨论了 BERT 可以解决的四种不同任务类型:序列分类、token 分类、多选题和问答。
最后,我们查看了 BERT 如何在大量文本语料库上进行预训练。
之后,我们开始了一个使用案例:用 BERT 回答问题。为了实现这个解决方案,我们使用了 Hugging Face 的 transformers
库。这是一个非常有用的高级库,建立在 TensorFlow、PyTorch 和 Jax 等深度学习框架之上。transformers
库专为快速加载和使用预训练的 Transformer 模型而设计。在这个使用案例中,我们首先处理了数据,并创建了一个 tf.data.Dataset
,用于分批流式传输数据。然后,我们在这些数据上训练了模型,并在测试集上进行了评估。最后,我们使用该模型推断给定示例问题的答案。
在下一章中,我们将更深入地了解 Transformer 及其在一个更复杂任务中的应用——图像与文本结合的任务:图像标题生成。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,与志同道合的人一起学习,超过 1000 名成员等你加入:packt.link/nlp
第十一章:使用 Transformer 进行图像标题生成
Transformer 模型改变了许多 NLP 问题的解决方式。与之前的主流模型 RNN 模型相比,它们通过显著的优势重新定义了当前的技术水平。我们已经研究过 Transformer,并理解了它们的工作原理。Transformer 可以访问整个序列的所有项(例如,一个 token 序列),而 RNN 模型一次只查看一个项,这使得 Transformer 更适合解决序列问题。在 NLP 领域取得成功之后,研究人员已经成功地将 Transformer 应用于计算机视觉问题。在这里,我们将学习如何使用 Transformer 来解决一个涉及图像和文本的多模态问题:图像标题生成。
自动图像标题生成,或称图像注释,具有广泛的应用场景。一个最突出的应用是搜索引擎中的图像检索。自动图像标题生成可以用于根据用户的请求,检索属于某一特定类别(例如,猫)的所有图像。另一个应用可能是在社交媒体中,当用户上传一张图像时,图像会自动生成标题,用户可以选择修改生成的标题或直接发布原始标题。
在本章中,我们将学习如何使用机器学习为图像生成标题,训练一个模型,在给定图像时生成一个 token 序列(即标题)。我们将首先了解 Transformer 模型如何在计算机视觉中应用,然后扩展我们的理解,解决为图像生成标题的问题。为了生成图像标题,我们将使用一个广泛应用于图像标题生成任务的流行数据集,称为 Microsoft Common Objects in Context(MS-COCO)。
解决这个问题需要两个 Transformer 模型:一个用于生成图像表示,另一个用于生成相关的标题。一旦图像表示生成完成,它将作为其中一个输入传递给基于文本的 Transformer 模型。基于文本的 Transformer 模型将被训练以预测给定当前标题的情况下,在特定时间步长下标题中下一个 token。
我们将生成三个数据集:训练集、验证集和测试集。我们使用训练集来训练模型,验证集用于在训练过程中监控模型表现,最后使用测试集为一组未见过的图像生成标题。
从非常高层次来看图像标题生成流程,我们有两个主要组件:
-
一个预训练的视觉 Transformer 模型,它接受图像并生成该图像的 1D 隐藏表示
-
一个基于文本的 Transformer 解码器模型,它可以将隐藏的图像表示解码成一系列 token ID
我们将使用一个预训练的 Transformer 模型来生成图像表示。这个模型被称为视觉 Transformer(ViT),它已经在 ImageNet 数据集上进行了训练,并且在 ImageNet 分类任务中取得了优异的表现。
本章将重点讨论以下主要主题:
-
了解数据
-
下载数据
-
处理和标记数据
-
定义
tf.data.Dataset
-
图像字幕生成的机器学习流程
-
使用 TensorFlow 实现模型
-
训练模型
-
定量评估结果
-
评估模型
-
为测试图像生成的字幕
了解数据
首先让我们了解我们所使用的数据,既包括直接使用的,也包括间接使用的。我们将依赖两个数据集:
-
ILSVRC ImageNet 数据集 (
image-net.org/download
) -
MS-COCO 数据集 (
cocodataset.org/#download
)
我们不会直接使用第一个数据集,但它对字幕学习至关重要。这个数据集包含图像及其相应的类别标签(例如,猫、狗和汽车)。我们将使用一个已经在这个数据集上训练好的 CNN,因此我们无需从头开始下载和训练该数据集。接下来我们将使用 MS-COCO 数据集,它包含图像及其相应的字幕。我们将通过将图像映射到一个固定大小的特征向量,使用 Vision Transformer,然后使用基于文本的 Transformer 将该向量映射到相应的字幕(我们稍后会详细讨论这一过程)。
ILSVRC ImageNet 数据集
ImageNet 是一个包含大量图像(约 100 万张)及其相应标签的图像数据集。这些图像属于 1,000 个不同的类别。该数据集非常具有表现力,几乎包含了我们想为其生成字幕的所有图像中的对象。图 11.1 展示了 ImageNet 数据集中一些可用的类别:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_01.png
图 11.1:ImageNet 数据集的一个小样本
ImageNet 是一个很好的训练数据集,用于获取生成字幕所需的图像编码。我们说我们间接使用这个数据集,因为我们将使用一个在这个数据集上预训练的 Transformer。因此,我们自己不会下载或在这个数据集上训练模型。
MS-COCO 数据集
现在我们将转向我们实际使用的数据集,它被称为MS-COCO(即Microsoft - Common Objects in Context的缩写)。我们将使用 2014 年的训练数据集和 2017 年的验证数据集。我们使用不同时期的数据集,以避免在本练习中使用大型数据集。如前所述,该数据集包含图像及其相应的描述。数据集非常庞大(例如,训练数据集包含约 120,000 个样本,大小超过 15GB)。数据集每年更新一次,并举行竞赛,表彰那些在此数据集上取得最先进成绩的团队。在目标是达到最先进的性能时,使用完整数据集很重要。然而,在我们这种情况中,我们希望学习一个合理的模型,能够一般性地推测图像中有什么。因此,我们将使用较小的数据集(约 40,000 张图像和约 200,000 个描述)来训练我们的模型。图 11.2展示了可用的一些样本:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_02.png
图 11.2:MS-COCO 数据集的小样本
为了训练和测试我们的端到端图像描述生成模型,我们将使用 2017 年的验证数据集,数据集可以从官方的 MS-COCO 数据集网站获取。
注意
实际操作中,您应使用单独的数据集进行测试和验证,以避免在测试过程中数据泄露。使用相同的数据进行验证和测试可能导致模型错误地表示其在现实世界中的泛化能力。
在图 11.3中,我们可以看到验证集中一些图像的样本。这些是从验证集中精心挑选的例子,代表了各种不同的物体和场景:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_03.png
图 11.3:我们将用来测试算法生成图像描述能力的未见图像
下载数据
我们将使用的 MS-COCO 数据集相当大。因此,我们将手动下载这些数据集。为此,请按照以下说明操作:
-
在
Ch11-Image-Caption-Generation
文件夹中创建一个名为data
的文件夹 -
下载 2014 年的训练图像集(
images.cocodataset.org/zips/train2014.zip
),该集包含 83K 张图像(train2014.zip
) -
下载 2017 年的验证图像集(
images.cocodataset.org/zips/val2017.zip
),该集包含 5K 张图像(val2017.zip
) -
下载 2014 年(
annotations_trainval2014.zip
)(images.cocodataset.org/annotations/annotations_trainval2014.zip
)和 2017 年(annotations_trainval2017.zip
)(images.cocodataset.org/annotations/annotations_trainval2017.zip
)的注释集 -
将下载的压缩文件复制到
Ch11-Image-Caption-Generation/data
文件夹中 -
使用 Extract to 选项解压缩 zip 文件,使其在子文件夹内解压缩内容
完成上述步骤后,你应该会有以下子文件夹:
-
data/train2014
– 包含训练图像 -
data/annotations_trainval2014
– 包含训练图像的标题 -
data/val2017
– 包含验证图像 -
data/annotations_trainval2017
– 包含验证图像的标题
数据处理和标记化
在数据下载并放入正确的文件夹后,让我们定义包含所需数据的目录:
trainval_image_dir = os.path.join('data', 'train2014', 'train2014')
trainval_captions_dir = os.path.join('data', 'annotations_trainval2014', 'annotations')
test_image_dir = os.path.join('data', 'val2017', 'val2017')
test_captions_dir = os.path.join('data', 'annotations_trainval2017', 'annotations')
trainval_captions_filepath = os.path.join(trainval_captions_dir, 'captions_train2014.json')
test_captions_filepath = os.path.join(test_captions_dir, 'captions_val2017.json')
这里我们定义了包含训练和测试图像的目录,以及包含训练和测试图像标题的 JSON 文件路径。
数据预处理
接下来的步骤是将训练集分割为训练集和验证集。我们将使用原始数据集的 80% 作为训练数据,20% 作为验证数据(随机选择):
all_filepaths = np.array([os.path.join(trainval_image_dir, f) for f in os.listdir(trainval_image_dir)])
rand_indices = np.arange(len(all_filepaths))
np.random.shuffle(rand_indices)
split = int(len(all_filepaths)*0.8)
train_filepaths, valid_filepaths = all_filepaths[rand_indices[:split]], all_filepaths[rand_indices[split:]]
我们可以打印数据集的大小,看看我们得到了什么:
print(f"Train dataset size: {len(train_filepaths)}")
print(f"Valid dataset size: {len(valid_filepaths)}")
这将打印:
Train dataset size: 66226
Valid dataset size: 16557
现在,让我们读取标题并使用它们创建一个 pandas DataFrame。我们的 DataFrame 将包含四个重要的列:
-
image_id
– 标识图像(用于生成文件路径) -
image_filepath
– 由image_id
标识的图像文件位置 -
caption
– 原始标题 -
preprocessed_caption
– 经简单预处理后的标题
首先,我们将加载 JSON 文件中的数据,并将其导入到 DataFrame 中:
with open(trainval_captions_filepath, 'r') as f:
trainval_data = json.load(f)
trainval_captions_df = pd.json_normalize(trainval_data, "annotations")
我们在文件中寻找的数据位于一个名为 "annotations"
的键下。在 "annotations"
下,我们有一个字典列表,每个字典包含 image_id
、id
和 caption
。函数 pd.json_normalize()
接受加载的数据并将其转换为 pd.DataFrame
。
然后,我们通过将根目录路径前缀加到 image_id
上,并附加扩展名 .jpg
来创建名为 image_filepath
的列。
我们只保留 image_filepath
值在我们存储在 train_filepaths
中的训练图像中的数据点:
trainval_captions_df["image_filepath"] = trainval_captions_df["image_id"].apply(
lambda x: os.path.join(trainval_image_dir,
'COCO_train2014_'+format(x, '012d')+'.jpg')
)
train_captions_df = trainval_captions_df[trainval_captions_df["image_filepath"].isin(train_filepaths)]
我们现在定义一个名为 preprocess_captions()
的函数,用来处理原始标题:
def preprocess_captions(image_captions_df):
""" Preprocessing the captions """
image_captions_df["preprocessed_caption"] = "[START] " +
image_captions_df["caption"].str.lower().str.replace('[^\w\s]','')
+ " [END]"
return image_captions_df
在上面的代码中,我们:
-
添加了两个特殊标记
[START]
和[END]
,分别表示每个标题的开始和结束 -
将标题转换为小写
-
移除所有非单词、字符或空格的内容
然后我们在训练数据集上调用这个函数:
train_captions_df = preprocess_captions(train_captions_df)
然后我们对验证数据和测试数据执行类似的过程:
valid_captions_df = trainval_captions_df[
trainval_captions_df[
"image_filepath"
].isin(valid_filepaths)
]
valid_captions_df = preprocess_captions(valid_captions_df)
with open(test_captions_filepath, 'r') as f:
test_data = json.load(f)
test_captions_df = pd.json_normalize(test_data, "annotations")
test_captions_df["image_filepath"] = test_captions_df["image_id"].apply(
lambda x: os.path.join(test_image_dir, format(x, '012d')+'.jpg')
)
test_captions_df = preprocess_captions(test_captions_df)
让我们查看 training_captions_df
中的数据(图 11.4):
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_04.png
图 11.4:training_captions_df 中的数据
这些数据展示了重要信息,例如图像在文件结构中的位置、原始标题和预处理后的标题。
让我们也分析一些关于图像的统计信息。我们将从训练数据集中取出前 1,000 张图像的小样本,并查看图像的大小:
n_samples = 1000
train_image_stats_df = train_captions_df.loc[:n_samples, "image_filepath"].apply(lambda x: Image.open(x).size)
train_image_stats_df = pd.DataFrame(train_image_stats_df.tolist(), index=train_image_stats_df.index)
train_image_stats_df.describe()
这将产生 图 11.5:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_05.png
图 11.5:训练数据集中图像大小的统计信息
我们可以看到大多数图像的分辨率为 640x640。稍后我们需要将图像调整为 224x224,以匹配模型的输入要求。我们还可以查看词汇表大小:
train_vocabulary = train_captions_df["preprocessed_caption"].str.split(" ").explode().value_counts()
print(len(train_vocabulary[train_vocabulary>=25]))
这将打印:
3629
这告诉我们,有 3,629 个词在训练数据集中至少出现了 25 次。我们将这个作为词汇表的大小。
分词数据
由于我们正在开发 Transformer 模型,我们需要一个强大的分词器,类似于 BERT 等流行模型使用的分词器。Hugging Face 的tokenizers
库为我们提供了一系列易于使用的分词器。让我们了解如何使用这些分词器之一来满足我们的需求。你可以通过以下方式导入它:
from tokenizers import BertWordPieceTokenizer
接下来,让我们定义BertWordPieceTokenizer
。我们在定义时将传递以下参数:
-
unk_token
– 定义一个用于表示词汇表外(OOV)词汇的标记 -
clean_text
– 是否进行简单的预处理步骤以清理文本 -
lowercase
– 是否将文本转换为小写
以下是这些参数:
# Initialize an empty BERT tokenizer
tokenizer = BertWordPieceTokenizer(
unk_token="[UNK]",
clean_text=False,
lowercase=False,
)
定义好分词器后,我们可以调用train_from_iterator()
函数来在我们的数据集上训练分词器:
tokenizer.train_from_iterator(
train_captions_df["preprocessed_caption"].tolist(),
vocab_size=4000,
special_tokens=["[PAD]", "[UNK]", "[START]", "[END]"]
)
train_from_iterator()
函数接受多个参数:
-
iterator
– 一个可迭代对象,每次产生一个字符串(包含标题)。 -
vocab_size
– 词汇表的大小。 -
special_tokens
– 我们数据中将使用的特殊标记。具体来说,我们使用[PAD]
(表示填充),[UNK]
(表示 OOV 标记),[START]
(表示开始)和[END]
(表示结束)。这些标记将从 0 开始分配较低的 ID。
一旦分词器训练完成,我们可以使用它将文本字符串转换为标记序列。让我们使用训练好的分词器将几个示例句子转换为标记序列:
# Encoding a sentence
example_captions = valid_captions_df["preprocessed_caption"].iloc[:10].tolist()
example_tokenized_captions = tokenizer.encode_batch(example_captions)
for caption, tokenized_cap in zip(example_captions, example_tokenized_captions):
print(f"{caption} -> {tokenized_cap.tokens}")
这将输出:
[START] an empty kitchen with white and black appliances [END] -> ['[START]', 'an', 'empty', 'kitchen', 'with', 'white', 'and', 'black', 'appliances', '[END]']
[START] a white square kitchen with tile floor that needs repairs [END] -> ['[START]', 'a', 'white', 'square', 'kitchen', 'with', 'tile', 'floor', 'that', 'need', '##s', 'rep', '##air', '##s', '[END]']
[START] a few people sit on a dim transportation system [END] -> ['[START]', 'a', 'few', 'people', 'sit', 'on', 'a', 'dim', 'transport', '##ation', 'system', '[END]']
[START] a person protected from the rain by their umbrella walks down the road [END] -> ['[START]', 'a', 'person', 'prote', '##cted', 'from',
'the', 'rain', 'by', 'their', 'umbrella', 'walks', 'down', 'the', 'road', '[END]']
[START] a white kitchen in a home with the light on [END] -> ['[START]', 'a', 'white', 'kitchen', 'in', 'a', 'home', 'with', 'the', 'light', 'on', '[END]']
你可以看到分词器如何学习自己的词汇,并且正在对字符串句子进行分词。前面带有##
的词汇表示它们必须与前面的标记(无空格)结合,才能得到最终结果。例如,来自标记'image'
,'cap'
和'##tion'
的最终字符串是'image caption'
。让我们看看我们定义的特殊标记映射到哪些 ID:
vocab = tokenizer.get_vocab()
for token in ["[UNK]", "[PAD]", "[START]", "[END]"]:
print(f"{token} -> {vocab[token]}")
这将输出:
[UNK] -> 1
[PAD] -> 0
[START] -> 2
[END] -> 3
现在让我们看看如何使用处理过的数据定义一个 TensorFlow 数据管道。
定义一个 tf.data.Dataset
现在让我们看看如何使用数据创建tf.data.Dataset
。我们将首先编写一些辅助函数。也就是说,我们将定义:
-
parse_image()
用来加载和处理来自filepath
的图像 -
使用
generate_tokenizer()
来生成一个基于传入数据训练的分词器
首先让我们讨论一下parse_image()
函数。它需要三个参数:
-
filepath
– 图像的位置 -
resize_height
– 调整图像高度的目标值 -
resize_width
– 调整图像宽度的目标值
该函数定义如下:
def parse_image(filepath, resize_height, resize_width):
""" Reading an image from a given filepath """
# Reading the image
image = tf.io.read_file(filepath)
# Decode the JPEG, make sure there are 3 channels in the output
image = tf.io.decode_jpeg(image, channels=3)
image = tf.image.convert_image_dtype(image, tf.float32)
# Resize the image to 224x224
image = tf.image.resize(image, [resize_height, resize_width])
# Bring pixel values to [-1, 1]
image = image*2.0 - 1.0
return image
我们主要依赖tf.image
函数来加载和处理图像。这个函数具体来说:
-
从
filepath
读取图像 -
解码 JPEG 图像中的字节为
uint8
张量,并转换为float32 dtype
张量。
在这些步骤结束后,我们将得到一个像素值介于 0 和 1 之间的图像。接下来,我们:
-
将图像调整为给定的高度和宽度
-
最后,将图像归一化,使得像素值介于-1 和 1 之间(这符合我们将要使用的 ViT 模型的要求)
基于此,我们定义了第二个辅助函数。这个函数封装了我们之前讨论过的BertWordPieceTokenizer
的功能:
def generate_tokenizer(captions_df, n_vocab):
""" Generate the tokenizer with given captions """
# Define the tokenizer
tokenizer = BertWordPieceTokenizer(
unk_token="[UNK]",
clean_text=False,
lowercase=False,
)
# Train the tokenizer
tokenizer.train_from_iterator(
captions_df["preprocessed_caption"].tolist(),
vocab_size=n_vocab,
special_tokens=["[PAD]", "[UNK]", "[START]", "[END]"]
)
return tokenizer
有了这些,我们可以定义我们的主数据函数,以生成 TensorFlow 数据管道:
def generate_tf_dataset(
image_captions_df, tokenizer=None, n_vocab=5000, pad_length=33, batch_size=32, training=False
):
""" Generate the tf.data.Dataset"""
# If the tokenizer is not available, create one
if not tokenizer:
tokenizer = generate_tokenizer(image_captions_df, n_vocab)
# Get the caption IDs using the tokenizer
image_captions_df["caption_token_ids"] = [enc.ids for enc in
tokenizer.encode_batch(image_captions_df["preprocessed_caption"])]
vocab = tokenizer.get_vocab()
# Add the padding to short sentences and truncate long ones
image_captions_df["caption_token_ids"] =
image_captions_df["caption_token_ids"].apply(
lambda x: x+[vocab["[PAD]"]]*(pad_length - len(x) + 2) if
pad_length + 2 >= len(x) else x[:pad_length + 1] + [x[-1]]
)
# Create a dataset with images and captions
dataset = tf.data.Dataset.from_tensor_slices({
"image_filepath": image_captions_df["image_filepath"],
"caption_token_ids":
np.array(image_captions_df["caption_token_ids"].tolist())
})
# Each sample in our dataset consists of (image, caption token
# IDs, position IDs), (caption token IDs offset by 1)
dataset = dataset.map(
lambda x: (
(parse_image(x["image_filepath"], 224, 224),
x["caption_token_ids"][:-1], tf.range(pad_length+1,
dtype='float32')), x["caption_token_ids"]
)
)
# Shuffle and batch data in the training mode
if training:
dataset = dataset.shuffle(buffer_size=batch_size*10)
dataset = dataset.batch(batch_size)
return dataset, tokenizer
该函数接受以下参数:
-
image_captions_df
– 一个包含图像文件路径和处理过的标题的 pandas DataFrame -
tokenizer
– 可选的分词器,用于对标题进行分词 -
n_vocab
– 词汇表大小 -
pad_length
– 填充标题的长度 -
batch_size
– 批处理数据时的批量大小 -
training
– 数据管道是否应该以训练模式运行。在训练模式下,我们会打乱数据,反之则不会
首先,如果没有传递新的分词器,该函数会生成一个分词器。接下来,我们在我们的 DataFrame 中创建一个名为“caption_token_ids
”的列,这个列是通过调用分词器的encode_batch()
函数对preprocessed_caption
列进行编码而生成的。然后我们对caption_token_ids
列进行填充。如果标题的长度小于pad_length
,我们将添加[PAD]
令牌 ID;如果标题长度超过pad_length
,则进行截断。然后我们使用from_tensor_slices()
函数创建一个tf.data.Dataset
。
这个数据集中的每个样本将是一个字典,字典的键为image_filepath
和caption_token_ids
,值为相应的值。一旦我们完成这个步骤,就拥有了获取实际数据的基础。我们将调用tf.data.Dataset.map()
函数来:
-
对每个
image_filepath
调用parse_image()
以生成实际图像 -
返回所有标题令牌 ID,除了最后一个,作为输入
-
范围从 0 到令牌的数量,表示每个输入令牌 ID 的位置(用于获取 Transformer 的位置信息嵌入)
-
返回所有的标题令牌 ID 作为目标
让我们通过一个例子来理解输入和输出的样子。假设你有一个标题 a brown bear。下面是我们 Transformer 解码器中输入和输出的样子(图 11.6):
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_06.png
图 11.6:模型输入和目标的组织方式
最后,如果处于训练模式,我们使用batch_size
的 10 倍作为buffer_size
来打乱数据集。然后我们使用在调用函数时提供的batch_size
来批处理数据集。让我们在我们的训练数据集上调用这个函数,看看得到的结果:
n_vocab=4000
batch_size=2
sample_dataset, sample_tokenizer = generate_tf_dataset(train_captions_df, n_vocab=n_vocab, pad_length=10, batch_size=batch_size, training=True)
for i in sample_dataset.take(1):
print(i)
它将输出:
(
(
<tf.Tensor: shape=(2, 224, 224, 3), dtype=float32, numpy=
array([[[[-0.2051357 , -0.22082198, -0.31493968],
[-0.2015593 , -0.21724558, -0.31136328],
[-0.17017174, -0.18585801, -0.2799757 ],
...,
[-0.29620153, -0.437378 , -0.6155298 ],
[-0.28843057, -0.41392076, -0.6178423 ],
[-0.29654706, -0.43772352, -0.62483776]],
[[-0.8097613 , -0.6725868 , -0.55734015],
[-0.7580646 , -0.6420185 , -0.55782473],
[-0.77606916, -0.67418844, -0.5419755 ],
...,
[-0.6400192 , -0.4753132 , -0.24786222],
[-0.70908225, -0.5426947 , -0.31580424],
[-0.7206869 , -0.5324516 , -0.3128438 ]]]], dtype=float32)>,
<tf.Tensor: shape=(2, 11), dtype=int32, numpy=
array([[ 2, 24, 356, 114, 488, 1171, 1037, 2820, 566, 445, 116],
[ 2, 24, 1357, 2142, 63, 1473, 495, 282, 116, 24, 301]])>,
<tf.Tensor: shape=(2, 11), dtype=float32, numpy=
array([[ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10.],
[ 0., 1., 2., 3., 4., 5., 6., 7., 8., 9., 10.]],
dtype=float32)>
),
<tf.Tensor: shape=(2, 12), dtype=int32, numpy=
array([[ 2, 24, 356, 114, 488, 1171, 1037, 2820, 566, 445, 116,
3],
[ 2, 24, 1357, 2142, 63, 1473, 495, 282, 116, 24, 301,
3]])>
)
在这里,我们可以看到输入和输出被组织成一个嵌套的元组。它的格式是((image, input caption token IDs, position IDs), target caption token IDs)
。例如,我们已经生成了一个批量大小为 2、填充长度为 10、词汇表大小为 4,000 的数据管道。我们可以看到,图像批次的形状是[2, 224, 224, 3],输入标题的 token ID 和位置 ID 的形状是[2, 11],最后,目标标题的 token ID 的形状是[2, 12]。需要注意的是,我们使用了一个额外的缓冲区来处理填充长度,以包含[START]
和[END]
标签。因此,最终得到的张量使用了标题长度为 12(即 10+2)。这里最重要的是注意输入和目标标题的长度。输入标题比目标标题少一个项目,正如长度所示。这是因为输入标题中的第一个项目是图像特征向量。这使得输入 tokens 的长度等于目标 tokens 的长度。
在解决了数据管道之后,我们将讨论我们将使用的模型的机制。
图像标题生成的机器学习流程
在这里,我们将从一个非常高层次的角度来看图像标题生成流程,然后逐步分析,直到我们拥有完整的模型。图像标题生成框架由两个主要部分组成:
-
一个预训练的视觉 Transformer 模型,用于生成图像表示。
-
一个基于文本的解码器模型,可以将图像表示解码为一系列的 token ID。该模型使用文本分词器将 tokens 转换为 token ID,反之亦然。
尽管 Transformer 模型最初是用于基于文本的 NLP 问题,但它们已经超越了文本数据领域,应用于图像数据和音频数据等其他领域。
在这里,我们将使用一个可以处理图像数据的 Transformer 模型和一个可以处理文本数据的 Transformer 模型。
视觉 Transformer(ViT)
首先,让我们来看一下生成图像编码向量表示的 Transformer 模型。我们将使用一个预训练的视觉 Transformer(ViT)来实现这一点。该模型已经在我们之前讨论过的 ImageNet 数据集上进行了训练。接下来,让我们了解一下该模型的架构。
最初,ViT 是由 Dosovitskiy 等人在论文《An Image is Worth 16X16 Words: Transformers for Image Recognition at Scale》中提出的(arxiv.org/pdf/2010.11929.pdf
)。这可以被视为将 Transformer 应用于计算机视觉问题的第一个重要步骤。这个模型被称为视觉 Transformer 模型。
该方法的思路是将图像分解为 16x16 的小块,并将每一块视为一个独立的标记。每个图像块被展平为一个 1D 向量,并通过类似于原始 Transformer 的位置信息编码机制对其位置进行编码。但图像是二维结构;仅使用 1D 位置信息而没有 2D 位置信息是否足够呢?作者认为,1D 位置信息足够,而 2D 位置信息并未带来显著的提升。一旦图像被分解为 16x16 的块并展平,每张图像就可以像文本输入序列一样表示为一系列标记(图 11.7)。
然后,模型以自监督的方式进行预训练,使用名为 JFT-300M 的视觉数据集(paperswithcode.com/dataset/jft-300m
)。该论文提出了一种优雅的方式,使用图像数据对 ViT 进行半监督训练。类似于 NLP 问题中将文本单元表示为标记的方式,图像的一个“标记”是图像的一块(即一系列连续的值,这些值是标准化的像素)。然后,ViT 被预训练以预测给定图像块的平均 3 位 RGB 颜色。每个通道(即红色、绿色和蓝色)用 3 位表示(每个位的值为 0 或 1),这提供了 512 种可能性或类别。换句话说,对于给定的图像,图像块(类似于 NLP 中的标记)会被随机遮蔽(采用与 BERT 相同的方法),然后模型被要求预测该图像块的平均 3 位 RGB 颜色。
经过预训练后,模型可以通过在 ViT 上添加分类头或回归头进行任务特定问题的微调,就像 BERT 一样。ViT 在序列的开头也有[CLS]
标记,它将作为下游视觉模型的输入表示,这些视觉模型被接入 ViT 之上。
图 11.7展示了 ViT 的机制:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_07.png
图 11.7:视觉 Transformer 模型
我们将在这里使用的模型源自 Steiner 等人的论文如何训练你的 ViT?视觉 Transformer 中的数据、增强和正则化(arxiv.org/pdf/2106.10270.pdf
)。该论文提出了几种 ViT 模型的变体。具体来说,我们将使用 ViT-S/16 架构。ViT-S 是第二小的 ViT 模型,包含 12 层,隐藏输出维度为 384;总共拥有 22.2M 个参数。这里的数字 16 意味着该模型是在 16x16 的图像块上进行训练的。该模型已通过我们之前讨论的 ImageNet 数据集进行了微调。我们将使用模型的特征提取部分进行图像字幕生成。
基于文本的解码器 Transformer
基于文本的解码器的主要目的是根据前面的标记预测序列中的下一个标记。这个解码器与我们在上一章使用的 BERT 大致相同。让我们回顾一下 Transformer 模型的组成部分。Transformer 由多个堆叠的层组成。每一层都有:
-
自注意力层 – 通过接收输入标记并关注序列中其他位置的标记,为每个标记位置生成一个隐藏表示
-
一个全连接子网络 – 通过将自注意力层的输出通过两个全连接层传播,生成逐元素的非线性隐藏表示
除了这些,网络还使用残差连接和层归一化技术来提高性能。谈到输入时,模型使用两种类型的输入嵌入来告知模型:
-
标记嵌入 – 每个标记都用一个与模型共同训练的嵌入向量表示
-
位置嵌入 – 每个标记位置通过一个 ID 和该位置的相应嵌入来表示
与我们在上一章使用的 BERT 相比,我们模型中的一个关键区别是自注意力机制的使用方式。使用 BERT 时,自注意力层能够以双向方式进行注意(即同时关注当前输入两侧的标记)。然而,在基于解码器的模型中,它只能关注当前标记左侧的标记。换句话说,注意力机制只能访问当前输入之前看到的输入。
将所有内容整合在一起
现在我们来学习如何将这两个模型结合起来。我们将使用以下程序来训练端到端模型:
-
我们通过 ViT 模型生成图像编码。它为图像生成 384 个项目的单一表示。
-
这个表示连同所有标题标记(除了最后一个)一起作为输入送入解码器。
-
给定当前输入标记,解码器预测下一个标记。在这个过程结束时,我们将得到完整的图像标题。
将 ViT 和文本解码器模型连接的另一个替代方法是通过提供直接访问 ViT 完整序列的编码器输出作为解码器注意力机制的一部分。在这项工作中,为了不让讨论过于复杂,我们仅使用 ViT 模型的一个输出作为解码器的输入。
使用 TensorFlow 实现模型
我们现在将实现刚才学习的模型。首先让我们导入一些内容:
import tensorflow_hub as hub
import tensorflow as tf
import tensorflow.keras.backend as K
实现 ViT 模型
接下来,我们将从 TensorFlow Hub 下载预训练的 ViT 模型。我们将使用 Sayak Paul 提交的模型。该模型可在tfhub.dev/sayakpaul/vit_s16_fe/1
找到。你还可以查看其他 Vision Transformer 模型,网址是tfhub.dev/sayakpaul/collections/vision_transformer/1
。
image_encoder = hub.KerasLayer("https://tfhub.dev/sayakpaul/vit_s16_fe/1", trainable=False)
然后我们定义一个输入层来输入图像,并将其传递给image_encoder
以获取该图像的最终特征向量:
image_input = tf.keras.layers.Input(shape=(224, 224, 3))
image_features = image_encoder(image_input)
你可以通过运行以下代码来查看最终图像表示的大小:
print(f"Final representation shape: {image_features.shape}")
该代码将输出:
Final representation shape: (None, 384)
接下来我们将详细了解如何实现基于文本的 Transformer 模型,它将输入图像表示以生成图像描述。
实现基于文本的解码器
在这里,我们将从头开始实现一个 Transformer 解码器模型。这与我们之前使用 Transformer 模型的方式不同,当时我们下载了一个预训练模型并使用它。
在我们实现模型之前,我们将实现两个自定义的 Keras 层:一个用于自注意力机制,另一个用于捕获 Transformer 模型中单层的功能。我们从自注意力层开始。
定义自注意力层
在这里,我们使用 Keras 子类化 API 定义自注意力层:
class SelfAttentionLayer(tf.keras.layers.Layer):
""" Defines the computations in the self attention layer """
def __init__(self, d):
super(SelfAttentionLayer, self).__init__()
# Feature dimensionality of the output
self.d = d
def build(self, input_shape):
# Query weight matrix
self.Wq = self.add_weight(
shape=(input_shape[-1], self.d),
initializer='glorot_uniform',
trainable=True, dtype='float32'
)
# Key weight matrix
self.Wk = self.add_weight(
shape=(input_shape[-1], self.d),
initializer='glorot_uniform',
trainable=True, dtype='float32'
)
# Value weight matrix
self.Wv = self.add_weight(
shape=(input_shape[-1], self.d),
initializer='glorot_uniform',
trainable=True, dtype='float32'
)
def call(self, q_x, k_x, v_x, mask=None):
q = tf.matmul(q_x,self.Wq) #[None, t, d]
k = tf.matmul(k_x,self.Wk) #[None, t, d]
v = tf.matmul(v_x,self.Wv) #[None, t, d]
# Computing the final output
h = tf.keras.layers.Attention(causal=True)([
q, #q
v, #v
k, #k
], mask=[None, mask])
# [None, t, t] . [None, t, d] => [None, t, d]
return h
在这里,我们必须填充三个函数的逻辑:
-
__init__()
和__build__()
– 定义特定于层初始化的各种超参数和逻辑 -
call()
– 在调用该层时需要进行的计算
我们可以看到,我们将注意力输出的维度d
作为参数传递给__init__()
方法。接着,在__build__()
方法中,我们定义了三个权重矩阵,Wq
、Wk
和Wv
。如果你记得我们在上一章的讨论,这些分别代表查询、键和值的权重。
最后,在call
方法中我们有逻辑。它接受四个输入:查询、键、值输入和一个可选的值掩码。然后,我们通过与相应的权重矩阵Wq
、Wk
和Wv
相乘来计算潜在的q
、k
和v
。为了计算注意力,我们将使用现成的层tf.keras.layers.Attention
。我们在第九章《序列到序列学习——神经机器翻译*》中使用了类似的层来计算 Bahdanau 注意力机制。tf.keras.layers.Attention()
层有几个参数,其中一个我们关心的是设置causal=True
。
这样做的目的是指示该层将当前令牌右侧的令牌进行屏蔽。这本质上防止了解码器泄漏关于未来令牌的信息。接下来,层在调用过程中会接受以下参数:
-
inputs
– 包含查询、值和键的输入列表,按此顺序排列 -
mask
– 包含查询和值的掩码的两个项的列表
最后返回注意力层的输出 h
。接下来,我们将实现 Transformer 层的计算。
定义 Transformer 层
使用自注意力层,我们可以捕捉单个 Transformer 层中的计算过程。它使用自注意力、全连接层和其他优化技术来计算输出:
class TransformerDecoderLayer(tf.keras.layers.Layer):
""" The Decoder layer """
def __init__(self, d, n_heads):
super(TransformerDecoderLayer, self).__init__()
# Feature dimensionality
self.d = d
# Dimensionality of a head
self.d_head = int(d/n_heads)
# Number of heads
self.n_heads = n_heads
# Actual attention heads
self.attn_heads = [SelfAttentionLayer(self.d_head) for i in
range(self.n_heads)]
# Fully connected layers
self.fc1_layer = tf.keras.layers.Dense(512, activation='relu')
self.fc2_layer = tf.keras.layers.Dense(d)
self.add_layer = tf.keras.layers.Add()
self.norm1_layer = tf.keras.layers.LayerNormalization()
self.norm2_layer = tf.keras.layers.LayerNormalization()
def _compute_multihead_output(self, x):
""" Computing the multi head attention output"""
outputs = [head(x, x, x) for head in self.attn_heads]
outputs = tf.concat(outputs, axis=-1)
return outputs
def call(self, x):
# Multi head attention layer output
h1 = self._compute_multihead_output(x)
h1_add = self.add_layer([x, h1])
h1_norm = self.norm1_layer(h1_add)
# Fully connected outputs
h2_1 = self.fc1_layer(h1_norm)
h2_2 = self.fc2_layer(h2_1)
h2_add = self.add_layer([h1, h2_2])
h2_norm = self.norm2_layer(h2_add)
return h2_norm
TransformerDecoderLayer
执行以下步骤:
-
使用给定的输入,该层计算多头注意力输出。多头注意力输出通过计算几个较小头的注意力输出并将这些输出连接到单个输出 (
h1
)。 -
接下来我们将原始输入
x
加到h1
中形成残差连接 (h1_add
)。 -
接着进行层归一化步骤来归一化 (
h1_norm
)。 -
h1_norm
经过全连接层生成h2_1
。 -
h2_1
经过另一个全连接层生成h2_2
。 -
然后我们通过将
h1
和h2_2
相加创建另一个残差连接来产生h2_add
。 -
最后,我们执行层归一化以生成
h2_norm
,这是此自定义层的最终输出。
定义完整的解码器
当所有实用程序层实现后,我们可以实现文本解码器。我们将定义两个输入层。第一个接受一个令牌序列作为输入,第二个接受一个序列位置(基于 0 索引),以表示每个令牌的位置。您可以看到,两个层都被定义为能够接受任意长度的序列作为输入。这将在推理过程中起重要作用:。
caption_input = tf.keras.layers.Input(shape=(None,))
position_input = tf.keras.layers.Input(shape=(None,))
接下来我们定义嵌入。我们的嵌入向量长度为 384,以匹配 ViT 模型的输出维度。我们定义了两个嵌入层:token 嵌入层和位置嵌入层:
d_model = 384
# Token embeddings
input_embedding = tf.keras.layers.Embedding(len(tokenizer.get_vocab()), d_model, mask_zero=True)
令牌嵌入层的工作方式与我们多次见过的一样。它为序列中的每个令牌生成一个嵌入向量。我们用 ID 0
掩盖输入,因为它们表示填充的令牌。接下来让我们了解如何实现位置嵌入:
position_embedding = tf.keras.layers.Lambda(
lambda x: tf.where(
tf.math.mod(tf.repeat(tf.expand_dims(x, axis=-1), d_model,
axis=-1), 2)==0,
tf.math.sin(
tf.expand_dims(x, axis=-1) /
10000**(2*tf.reshape(tf.range(d_model,
dtype='float32'),[1,1, -1])/d_model)
),
tf.math.cos(
tf.expand_dims(x, axis=-1) /
10000**(2*tf.reshape(tf.range(d_model,
dtype='float32'),[1,1, -1])/d_model)
)
)
)
我们已经讨论了如何计算位置嵌入。原始 Transformer 论文使用以下方程式生成位置嵌入:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_001.png
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_002.png
这里 pos 表示序列中的位置,i 表示第 i^(th) 个特征维度 (0< i<d_model
)。偶数特征使用正弦函数,奇数特征使用余弦函数。计算这一层需要一些工作。让我们慢慢分解这个逻辑。首先我们计算以下两个张量(为了方便我们用 x
和 y
表示):
x = PE(pos, i) = sin(pos/10000**(2i/d))
y = PE(pos, i) = cos(pos/10000**(2i/d))
我们使用tf.where(cond, x, y)
函数,根据与cond
的布尔矩阵的大小相同,按元素从x
和y
中选择值。对于某一位置,如果cond
为True
,选择x
;如果cond
为False
,选择y
。这里我们使用条件pos%2 == 0
,这会对偶数位置返回True
,对奇数位置返回False
。
为了确保我们生成形状正确的张量,我们利用了 TensorFlow 的广播能力。
让我们稍微了解一下广播是如何帮助的。来看一下计算:
tf.math.sin(
tf.expand_dims(x, axis=-1) /
10000**(2*tf.reshape(tf.range(d_model,
dtype='float32'),[1,1, -1])/d_model)
)
在这里,我们需要一个[batch size, time steps, d_model]
大小的输出。tf.expand_dims(x, axis=-1)
会生成一个[batch size, time steps, 1]
大小的输出。10000**(2*tf.reshape(tf.range(d_model, dtype='float32'),[1,1, -1])/d_model)
会生成一个[1, 1, d_model]
大小的输出。将第一个输出除以第二个输出,得到一个大小为[batch size, time steps, d_model]
的张量。这是因为 TensorFlow 的广播能力允许它在任意大小的维度和大小为 1 的维度之间执行操作。你可以想象 TensorFlow 将大小为 1 的维度复制 n 次,以执行与 n 大小维度的操作。但实际上,它这样做得更高效。
一旦令牌和位置嵌入被计算出来,我们将它们按元素相加,得到最终的嵌入:
embed_out = input_embedding(caption_input) + position_embedding(position_input)
如果你还记得,解码器的第一个输入是图像特征向量,后面跟着字幕令牌。因此,我们需要将image_features
(由 ViT 产生)与embed_out
拼接起来,得到完整的输入序列:
image_caption_embed_out = tf.keras.layers.Concatenate(axis=1)([tf.expand_dims(image_features,axis=1), embed_out])
然后我们定义四个 Transformer 解码器层,并计算这些层的隐藏输出:
out = image_caption_embed_out
for l in range(4):
out = TransformerDecoderLayer(d_model, 64)(out)
我们使用一个Dense
层,具有n_vocab
个输出节点,并采用softmax激活函数来计算最终输出:
final_out = tf.keras.layers.Dense(n_vocab, activation='softmax')(out)
最后,我们定义完整的模型。它接受以下输入:
-
image_input
– 一批 224x224x3 大小的图像 -
caption_input
– 字幕的令牌 ID(不包括最后一个令牌) -
position_input
– 表示每个令牌位置的一批位置 ID
并将final_out
作为输出:
full_model = tf.keras.models.Model(inputs=[image_input, caption_input, position_input], outputs=final_out)
full_model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics='accuracy')
full_model.summary()
现在我们已经定义了完整的模型(图 11.8):
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_08.png
图 11.8:代码参考叠加在完整模型的插图上
训练模型
现在数据管道和模型已定义,训练它就非常容易了。首先定义一些参数:
n_vocab = 4000
batch_size=96
train_fraction = 0.6
valid_fraction = 0.2
我们使用 4,000 的词汇量和 96 的批量大小。为了加速训练,我们只使用 60%的训练数据和 20%的验证数据。然而,你可以增加这些数据以获得更好的结果。然后我们得到在完整训练数据集上训练的分词器:
tokenizer = generate_tokenizer(
train_captions_df, n_vocab=n_vocab
)
接下来我们定义 BLEU 指标。这与第九章《序列到序列学习——神经机器翻译*中的 BLEU 计算相同,仅有一些小的差异。因此,我们在这里不再重复讨论。
bleu_metric = BLEUMetric(tokenizer=tokenizer)
在训练循环外采样较小的验证数据集,以保持数据集的恒定:
sampled_validation_captions_df = valid_captions_df.sample(frac=valid_fraction)
接下来,我们训练模型 5 个周期:
for e in range(5):
print(f"Epoch: {e+1}")
train_dataset, _ = generate_tf_dataset(
train_captions_df.sample(frac=train_fraction),
tokenizer=tokenizer, n_vocab=n_vocab, batch_size=batch_size,
training=True
)
valid_dataset, _ = generate_tf_dataset(
sampled_validation_captions_df, tokenizer=tokenizer,
n_vocab=n_vocab, batch_size=batch_size, training=False
)
full_model.fit(
train_dataset,
epochs=1
)
valid_loss, valid_accuracy, valid_bleu = [], [], []
for vi, v_batch in enumerate(valid_dataset):
print(f"{vi+1} batches processed", end='\r')
loss, accuracy = full_model.test_on_batch(v_batch[0],
v_batch[1])
batch_predicted = full_model(v_batch[0])
bleu_score =
bleu_metric.calculate_bleu_from_predictions(v_batch[1],
batch_predicted)
valid_loss.append(loss)
valid_accuracy.append(accuracy)
valid_bleu.append(bleu_score)
print(
f"\nvalid_loss: {np.mean(valid_loss)} - valid_accuracy:
{np.mean(valid_accuracy)} - valid_bleu: {np.mean(valid_bleu)}"
)
在每次迭代中,我们会生成train_dataset
和valid_dataset
。注意,训练集在每个周期内会随机采样,导致不同的数据点,而验证集是固定的。还要注意,我们将先前生成的 tokenizer 作为参数传递给数据管道函数。我们在循环中使用full_model.fit()
函数,并用训练数据集对其进行单次训练。最后,我们遍历验证数据集的批次,计算每个批次的损失、准确率和 BLEU 值。然后,我们输出这些批次指标的平均值。输出结果如下所示:
Epoch: 1
2071/2071 [==============================] - 1945s 903ms/step - loss: 1.3344 - accuracy: 0.7625
173 batches processed
valid_loss: 1.1388846477332142 - valid_accuracy: 0.7819634135058849 - valid_bleu: 0.09385878526196685
Epoch: 2
2071/2071 [==============================] - 1854s 894ms/step - loss: 1.0860 - accuracy: 0.7878
173 batches processed
valid_loss: 1.090059520192229 - valid_accuracy: 0.7879036186058397 - valid_bleu: 0.10231472779803133
Epoch: 3
2071/2071 [==============================] - 1855s 895ms/step - loss:
1.0610 - accuracy: 0.7897
173 batches processed
valid_loss: 1.0627685799075 - valid_accuracy: 0.7899546606003205 - valid_bleu: 0.10398145099074609
Epoch: 4
2071/2071 [==============================] - 1937s 935ms/step - loss: 1.0479 - accuracy: 0.7910
173 batches processed
valid_loss: 1.0817485169179177 - valid_accuracy: 0.7879597275932401 - valid_bleu: 0.10308500219058511
Epoch: 5
2071/2071 [==============================] - 1864s 899ms/step - loss: 1.0244 - accuracy: 0.7937
173 batches processed
valid_loss: 1.0498641329693656 - valid_accuracy: 0.79208166544148 - valid_bleu: 0.10667336005789202
让我们看看结果。我们可以看到,训练损失和验证损失大致持续下降。我们的训练和验证准确率大约为 80%。最后,valid_bleu
得分约为 0.10。你可以在这里看到一些模型的最新技术:paperswithcode.com/sota/image-captioning-on-coco
。可以看到,UNIMO 模型达到了 39 的 BLEU-4 分数。值得注意的是,实际上我们的 BLEU 得分比这里报告的要高。这是因为每张图片有多个描述。在计算多个参考的 BLEU 得分时,你需要对每个描述计算 BLEU,并取最大值。而我们在计算 BLEU 得分时只考虑了每张图片的一个描述。此外,我们的模型要简单得多,且只在一小部分可用数据上进行了训练。如果你希望提高模型性能,可以尝试使用完整的训练集,并实验更大的 ViT 模型和数据增强技术来提高表现。
接下来我们讨论在图像描述的上下文中,衡量序列质量的不同指标。
定量评估结果
评估生成的描述质量和相关性有许多不同的技术。我们将简要讨论几种可用于评估描述的指标。我们将讨论四个指标:BLEU、ROUGE、METEOR 和 CIDEr。
所有这些指标共享一个关键目标,即衡量生成文本的适应性(生成文本的意义)和流畅性(文本的语法正确性)。为了计算这些指标,我们将使用候选句子和参考句子,其中候选句子是我们算法预测的句子/短语,而参考句子是我们要与之比较的真实句子/短语。
BLEU
双语评估替代法 (BLEU) 是由 Papineni 等人在 BLEU: A Method for Automatic Evaluation of Machine Translation 中提出的,《第 40 届计算语言学协会年会论文集(ACL)》,费城,2002 年 7 月:311-318。它通过一种与位置无关的方式来度量参考句子和候选句子之间的 n-gram 相似度。这意味着候选句子中的某个 n-gram 出现在参考句子的任何位置都被认为是匹配的。BLEU 计算 n-gram 相似度时使用精确度。BLEU 有多个变种(BLEU-1、BLEU-2、BLEU-3 等),表示 n-gram 中 n 的值。
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_003.png
这里,Count(n-gram) 是候选句子中给定 n-gram 的总出现次数。Count[clip] (n-gram) 是一种度量方法,用于计算给定 n-gram 的 Count(n-gram) 值,并根据最大值进行裁剪。n-gram 的最大值是通过该 n-gram 在参考句子中的出现次数来计算的。例如,考虑以下两句话:
-
候选短语:the the the the the the the
-
参考文献:the cat sat on the mat
Count(“the”) = 7
Count [clip] (“the”)=2
注意,实体 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_004.png 是精确度的一种表现形式。事实上,它被称为修正后的 n-gram 精确度。当存在多个参考文献时,BLEU 被认为是最大值:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_005.png
然而,修正后的 n-gram 精确度对于较小的候选短语倾向于较高,因为这个实体是由候选短语中的 n-gram 数量来划分的。这意味着这种度量会使模型倾向于生成较短的短语。为了避免这种情况,增加了一个惩罚项 BP,它对短候选短语也进行惩罚。BLEU 存在一些局限性,比如在计算分数时忽略了同义词,且没有考虑召回率,而召回率也是衡量准确性的一个重要指标。此外,BLEU 对某些语言来说似乎不是一个理想选择。然而,这是一个简单的度量方法,通常在大多数情况下与人工评判具有较好的相关性。
ROUGE
面向召回的摘要评估替代法 (ROUGE),由 Chin-Yew Lin 在 ROUGE: A Package for Automatic Evaluation of Summaries 中提出,《文本摘要分支扩展研讨会论文集(2004)》,可以被视为 BLEU 的一种变体,且使用召回率作为基本的性能评估标准。ROUGE 度量公式如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_006.png
这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_007.png 是候选词组中出现在参考文献中的 n-gram 数量,而 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_008.png 是参考文献中出现的总 n-gram 数量。如果存在多个参考文献,ROUGE-N 的计算公式如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_009.png
这里,ref[i]是来自可用参考集合中的一个单一参考。ROUGE 度量有多个变种,针对标准 ROUGE 度量进行改进。ROUGE-L 通过计算候选句子与参考句子对之间找到的最长公共子序列来得分。需要注意的是,在这种情况下,最长公共子序列不需要是连续的。接下来,ROUGE-W 根据最长公共子序列进行计算,并根据子序列中的碎片化程度进行惩罚。ROUGE 也存在一些局限性,比如在得分计算中没有考虑精度。
METEOR
翻译评估度量标准(Explicit Ordering Translation Evaluation Metric)(METEOR),由 Michael Denkowski 和 Alon Lavie 提出,见于Meteor Universal: Language Specific Translation Evaluation for Any Target Language,第九届统计机器翻译研讨会论文集(2014):376-380,是一种更先进的评估度量标准,它对候选句子和参考句子进行对齐。METEOR 与 BLEU 和 ROUGE 的不同之处在于,METEOR 考虑了单词的顺序。在计算候选句子与参考句子之间的相似性时,以下情况被视为匹配:
-
完全匹配:候选句子中的单词与参考句子中的单词完全匹配
-
词干:一个词干化后的词(例如,walk是词walked的词干)与参考句子中的单词匹配
-
同义词:候选句子中的单词是参考句子中单词的同义词
为了计算 METEOR 得分,可以通过表格展示参考句子与候选句子之间的匹配情况,如图 11.10所示。然后,基于候选句子和参考句子中匹配项的数量,计算精度(P)和召回率(R)值。最后,使用P和R的调和均值来计算 METEOR 得分:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_010.png
在这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_015.png、https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_016.png和https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_017.png是可调参数,frag会惩罚碎片化的匹配,以偏好那些匹配中间隙较少且单词顺序与参考句子接近的候选句子。frag是通过观察最终单一词映射中的交叉数来计算的(图 11.9):
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_09.png
图 11.9:两串字符串的不同对齐方式
例如,我们可以看到左侧有 7 个交叉,而右侧有 10 个交叉,这意味着右侧的对齐会比左侧更受惩罚。
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_10.png
图 11.10:METEOR 单词匹配表
你可以看到,我们用圆圈和椭圆表示候选句子与参考句子之间的匹配。例如,我们用实心黑色圆圈表示完全匹配,用虚线空心圆圈表示同义词匹配,用点状圆圈表示词干匹配。
METEOR 在计算上更复杂,但通常被发现与人工评判的相关性高于 BLEU,表明 METEOR 是比 BLEU 更好的评估指标。
CIDEr
基于共识的图像描述评估(CIDEr),由 Ramakrishna Vedantam 等人在《CIDEr: 基于共识的图像描述评估》,IEEE 计算机视觉与模式识别会议(CVPR),2015中提出,是另一种评估候选句子与给定参考句子集合共识度的衡量标准。CIDEr 旨在衡量候选句子的语法正确性、显著性和准确性(即精度和召回率)。
首先,CIDEr 通过 TF-IDF 加权候选句子和参考句子中出现的每个 n-gram,因此更常见的 n-gram(例如,单词a和the)的权重较小,而稀有单词的权重较大。最后,CIDEr 通过计算候选句子和参考句子中 TF-IDF 加权 n-gram 向量之间的余弦相似度来得出:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_011.png
在这里,cand是候选句子,ref是参考句子的集合,ref[j]是ref的第j^(个)句子,m是给定候选句子的参考句子数量。最重要的是,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_012.png是为候选句子中的所有 n-gram 计算的 TF-IDF 值,并将其作为向量。https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_013.png是参考句子ref[i]的相同向量。https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_014.png表示该向量的大小。
总的来说,应该注意的是,没有一个明确的赢家能够在自然语言处理中的所有任务上表现出色。这些指标在很大程度上依赖于任务,并应根据具体任务谨慎选择。在这里,我们将使用 BLEU 分数来评估我们的模型。
评估模型
训练好模型后,让我们在未见过的测试数据集上测试模型。测试逻辑与我们在模型训练过程中讨论的验证逻辑几乎相同。因此,我们不会在这里重复讨论。
bleu_metric = BLEUMetric(tokenizer=tokenizer)
test_dataset, _ = generate_tf_dataset(
test_captions_df, tokenizer=tokenizer, n_vocab=n_vocab, batch_size=batch_size, training=False
)
test_loss, test_accuracy, test_bleu = [], [], []
for ti, t_batch in enumerate(test_dataset):
print(f"{ti+1} batches processed", end='\r')
loss, accuracy = full_model.test_on_batch(t_batch[0], t_batch[1])
batch_predicted = full_model.predict_on_batch(t_batch[0])
bleu_score = bleu_metric.calculate_bleu_from_predictions(t_batch[1], batch_predicted)
test_loss.append(loss)
test_accuracy.append(accuracy)
test_bleu.append(bleu_score)
print(
f"\ntest_loss: {np.mean(test_loss)} - test_accuracy: {np.mean(test_accuracy)} - test_bleu: {np.mean(test_bleu)}"
)
这将输出:
261 batches processed
test_loss: 1.057080413646625 - test_accuracy: 0.7914185857407434 - test_bleu: 0.10505496256163914
很好,我们可以看到模型在测试数据上的表现与在验证数据上的表现相似。这意味着我们的模型没有过拟合,并且在现实世界中应该表现得相当不错。现在,让我们为一些示例图像生成描述。
为测试图像生成的描述
通过使用诸如准确率和 BLEU 之类的度量标准,我们确保了我们的模型表现良好。但训练好的模型最重要的任务之一是为新数据生成输出。我们将学习如何使用模型生成实际的标题。首先,让我们从概念上理解如何生成标题。通过使用图像来生成图像表示是非常直接的。棘手的部分是调整文本解码器以生成标题。正如你所想,解码器推理需要在与训练不同的环境下工作。这是因为在推理时,我们没有标题令牌可以输入模型。
我们使用模型进行预测的方式是从图像和一个包含单一令牌[START]
的起始标题开始。我们将这两个输入传递给模型,以生成下一个令牌。然后,我们将新令牌与当前输入结合,预测下一个令牌。我们会一直这么进行,直到达到一定的步数,或者模型输出[END]
(图 11.11)。如果你还记得,我们以这样一种方式开发了模型,使得它能够接受任意长度的令牌序列。这在推理过程中非常有用,因为在每个时间步长,序列的长度都会增加。
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_11.png
图 11.11:训练好的模型的解码器如何生成给定图像的新标题
我们将从测试数据集中选择一个包含 10 个样本的小数据集,并生成标题:
n_samples = 10
test_dataset, _ = generate_tf_dataset(
test_captions_df.sample(n=n_samples), tokenizer=tokenizer,
n_vocab=n_vocab, batch_size=n_samples, training=False
)
接下来,让我们定义一个名为generate_captions()
的函数。这个函数接收:
-
model
– 训练好的模型 -
image_input
– 一批输入图像 -
tokenizer
– 训练好的分词器 -
n_samples
– 批次中的样本数量
如下所示:
def generate_caption(model, image_input, tokenizer, n_samples):
# 2 -> [START]
batch_tokens = np.repeat(np.array([[2]]), n_samples, axis=0)
for i in range(30):
if np.all(batch_tokens[:,-1] == 3):
break
position_input = tf.repeat(tf.reshape(tf.range(i+1),[1,-1]),
n_samples, axis=0)
probs = full_model((image_input, batch_tokens,
position_input)).numpy()
batch_tokens = np.argmax(probs, axis=-1)
predicted_text = []
for sample_tokens in batch_tokens:
sample_predicted_token_ids = sample_tokens.ravel()
sample_predicted_tokens = []
for wid in sample_predicted_token_ids:
sample_predicted_tokens.append(tokenizer.id_to_token(wid))
if wid == 3:
break
sample_predicted_text = " ".join([tok for tok in
sample_predicted_tokens])
sample_predicted_text = sample_predicted_text.replace(" ##",
"")
predicted_text.append(sample_predicted_text)
return predicted_text
这个函数以一个单一的标题令牌 ID 开始。ID 2 映射到令牌[START]
。我们预测 30 步,或者当最后一个令牌是[END]
(映射到令牌 ID 3)时停止。我们通过创建一个从 0 到 i 的范围序列,并在批次维度上重复 n_sample
次,为数据批次生成位置输入。然后,我们将输入传递给模型,以预测令牌的概率。
我们现在可以使用这个函数生成标题:
for batch in test_dataset.take(1):
(batch_image_input, _, _), batch_true_caption = batch
batch_predicted_text = generate_caption(full_model, batch_image_input, tokenizer, n_samples)
现在,让我们将标题与图像输入并排显示。另外,我们还将展示真实的标题:
fig, axes = plt.subplots(n_samples, 2, figsize=(8,30))
for i,(sample_image_input, sample_true_caption, sample_predicated_caption) in enumerate(zip(batch_image_input, batch_true_caption, batch_predicted_text)):
sample_true_caption_tokens = [tokenizer.id_to_token(wid) for wid in
sample_true_caption.numpy().ravel()]
sample_true_text = []
for tok in sample_true_caption_tokens:
sample_true_text.append(tok)
if tok == '[END]':
break
sample_true_text = " ".join(sample_true_text).replace(" ##", "")
axes[i][0].imshow(((sample_image_input.numpy()+1.0)/2.0))
axes[i][0].axis('off')
true_annotation = f"TRUE: {sample_true_text}"
predicted_annotation = f"PRED: {sample_predicated_caption}"
axes[i][1].text(0, 0.75, true_annotation, fontsize=18)
axes[i][1].text(0, 0.25, predicted_annotation, fontsize=18)
axes[i][1].axis('off')
你将得到一个类似于下图的图表。每次运行时,采样的图像会被随机采样。此次运行的结果可以在图 11.12中看到:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_11_12.png
图 11.12:在测试数据样本上生成的标题
我们可以看到我们的模型在生成标题方面表现得很好。总的来说,我们可以看到模型能够识别图像中展示的物体和活动。同样需要记住的是,每张图像都有多个与之关联的标题。因此,预测的标题不一定需要与图像中的真实标题完全匹配。
总结
在本章中,我们专注于一个非常有趣的任务——为给定的图像生成描述。我们的图像描述模型是本书中最复杂的模型之一,包含以下内容:
-
一个生成图像表示的视觉 Transformer 模型
-
一个基于文本的 Transformer 解码器
在开始模型之前,我们分析了数据集,以理解各种特征,例如图像大小和词汇量大小。接着我们了解了如何使用分词器对描述字符串进行分词。然后,我们利用这些知识构建了一个 TensorFlow 数据管道。
我们详细讨论了每个组件。视觉 Transformer (ViT) 接收一张图像并生成该图像的隐藏表示。具体来说,ViT 将图像拆分成一系列 16x16 像素的小块。之后,它将每个小块作为一个 token 嵌入传递给 Transformer(包括位置编码信息),以生成每个小块的表示。它还在开头加入了[CLS] token,用来提供图像的整体表示。
接下来,文本解码器将图像表示和描述 token 作为输入。解码器的目标是在每个时间步预测下一个 token。我们在验证数据集上达到了 BLEU-4 得分略高于 0.10。
随后,我们讨论了几种不同的评价指标(BLEU、ROUGE、METEOR 和 CIDEr),这些指标可以用来定量评估生成的图像描述,并且我们看到,在将算法应用于训练数据时,BLEU-4 得分随着时间的推移而增加。此外,我们还通过视觉检查生成的描述,发现我们的机器学习管道在逐渐提高图像描述的准确性。
接下来,我们在测试数据集上评估了我们的模型,并验证了其在测试数据上的表现与预期相符。最后,我们学习了如何使用训练好的模型为未见过的图像生成描述。
本书已经结束。我们涵盖了许多自然语言处理的不同主题,并讨论了有助于解决问题的先进模型和技术。
在附录中,我们将讨论与机器学习相关的一些数学概念,并解释如何使用可视化工具 TensorBoard 来可视化词向量。
要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub
加入我们的 Discord 社区,结识志同道合的人,与超过 1000 名成员一起学习,访问链接:packt.link/nlp
附录 A:数学基础与高级 TensorFlow
在这里,我们将讨论一些概念,这些概念将帮助你理解本书中提供的某些细节。首先,我们将讨论书中常见的几种数学数据结构,然后介绍对这些数据结构执行的各种操作。接下来,我们将讨论概率的概念。概率在机器学习中起着至关重要的作用,因为它通常提供有关模型对其预测的不确定性的见解。最后,我们将以如何使用 TensorBoard 作为词嵌入的可视化工具的指南结束本附录。
基本数据结构
标量
标量是一个单一的数字,不像矩阵或向量。例如,1.3 是一个标量。标量可以在数学上表示如下:https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_001.png。
这里,R 是实数空间。
向量
向量是一个数字数组。与集合不同,集合中的元素没有顺序,而向量的元素是有顺序的。一个示例向量是 [1.0, 2.0, 1.4, 2.3]
。在数学上,它可以表示为:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_002.png
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_003.png
这里,R 是实数空间,n 是向量中的元素个数。
矩阵
矩阵可以被看作是一组标量的二维排列。换句话说,矩阵可以被看作是一个向量的向量。一个示例矩阵如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_004.png
一个更一般的矩阵,其大小为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_005.png,可以在数学上定义如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_006.png
并且:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_007.png
这里,m 是矩阵的行数,n 是矩阵的列数,R 是实数空间。
矩阵索引
我们将使用零索引表示法(即,索引从 0 开始)。
要从矩阵中索引单个元素,位于 (i, j)^(th) 位置,我们使用以下表示法:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_008.png
参考之前定义的矩阵,我们得到如下结果:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_004.png
我们像这样从 A 中索引一个元素:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_010.png
我们表示任何矩阵 A 的一行,如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_011.png
对于我们的示例矩阵,我们可以表示矩阵的第二行(索引为 1),如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_012.png
我们表示从矩阵 A 的 (i, k)^(th) 索引到 (j, l)^(th) 索引的切片,如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_013.png
在我们的示例矩阵中,我们可以表示从第一行第三列到第二行第四列的切片,如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_014.png
特殊类型的矩阵
单位矩阵
单位矩阵是一个方阵,其中对角线上的值为 1,其他位置的值为 0。在数学上,它可以表示为:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_015.png
这看起来如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_016.png
这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_017.png。
单位矩阵与另一个矩阵 A 相乘时,具有以下良好的性质:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_018.png
方阵对角矩阵
方阵对角矩阵是单位矩阵的一个更一般的情况,其中对角线上的值可以取任意值,而非对角线上的值为零:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_019.png
张量
一个 n 维矩阵被称为 张量。换句话说,一个具有任意维数的矩阵被称为张量。例如,一个四维张量可以表示如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_020.png
这里,R 是实数空间。
张量/矩阵操作
转置
转置是一个重要的操作,适用于矩阵或张量。对于矩阵,转置定义如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_021.png
这里,A^T 表示 A 的转置。
转置操作的一个例子可以如下说明:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_004.png
经过转置操作后:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_023.png
对于张量,转置可以看作是对维度顺序的重新排列。例如,我们定义一个张量 S,如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_024.png
现在可以定义一个转置操作(多次转置中的一种),如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_025.png
矩阵乘法
矩阵乘法是另一个在线性代数中非常常见的重要操作。
给定矩阵 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_026.png 和 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_027.png,A 和 B 的乘法定义如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_028.png
这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_029.png。
考虑这个例子:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_030.png
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_031.png
这给出了 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_032.png,并且 C 的值如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_033.png
元素级乘法
元素级矩阵乘法(或 Hadamard 乘积)是对形状相同的两个矩阵进行计算的。给定矩阵 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_034.png 和 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_035.png,A 和 B 的元素级乘法定义如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_036.png
这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_037.png。
考虑这个例子:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_038.png
这给出了 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_039.png,并且 C 的值如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_040.png
逆
矩阵 A 的逆矩阵表示为 A^(-1),它满足以下条件:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_041.png
逆矩阵在我们试图解线性方程组时非常有用。考虑这个例子:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_042.png
我们可以通过如下方式解出 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_043.png:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_044.png
这可以写作 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_045.png,利用结合律——即,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_046.png。
接下来,我们将得到,其中 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_049.png 是单位矩阵。
最后,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_050.png,因为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_051.png。
例如,多项式回归是回归技术之一,使用线性方程组来解决回归问题。回归类似于分类,但与分类输出一个类别不同,回归模型输出一个连续值。让我们看一个示例问题:给定一所房子的卧室数量,我们将计算这所房产的价值。形式上,一个多项式回归问题可以写成如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_052.png
这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_053.png 是第i个数据输入,其中 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_054.png 是输入,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_055.png 是标签,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_056.png 是数据中的噪声。在我们的例子中,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_057.png 是卧室的数量,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_058.png 是房子的价格。这可以写成如下的线性方程组:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_059.png
然而,并非所有的A都存在逆。为了矩阵有逆,需要满足一定的条件。例如,为了定义逆矩阵,A需要是一个方阵(即,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_060.png)。即使逆矩阵存在,我们也并不总能以封闭形式找到它;有时它只能通过有限精度计算机进行近似。如果逆矩阵存在,那么有几种算法可以找到它,我们将在接下来的内容中讨论。
注意
当我们说要矩阵是方阵才能有逆时,指的是标准的逆运算。也存在逆运算的变种(例如,摩尔-彭若斯逆,也称为伪逆),它可以对一般的 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_061.png 矩阵进行矩阵求逆操作。
求解矩阵逆——奇异值分解(SVD)
现在让我们看看如何使用 SVD 求解矩阵A的逆。SVD 将A分解为三个不同的矩阵,如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_062.png
这里,U的列被称为左奇异向量,V的列被称为右奇异向量,D(一个对角矩阵)的对角值被称为奇异值。左奇异向量是矩阵 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_063.png 的特征向量,右奇异向量是矩阵 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_064.png 的特征向量。最后,奇异值是矩阵 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_065.png 和 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_066.png 的特征值的平方根。矩阵A的特征向量 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_067.png 及其对应的特征值 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_068.png 满足以下条件:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_069.png
然后,如果 SVD 存在,矩阵A的逆由以下公式给出:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_070.png
由于D是对角矩阵,D^(-1)只是D中非零元素的逐元素倒数。SVD(奇异值分解)是机器学习中一个重要的矩阵分解技术。例如,SVD 被用于计算主成分分析(PCA),这是一种流行的数据降维技术(其目的类似于我们在第四章,先进的词向量算法中看到的 t-SNE)。SVD 在自然语言处理(NLP)中的另一个应用是文档排名。即,当你想获取最相关的文档(并根据与某个术语的相关性对它们进行排名,例如足球),可以使用 SVD 来实现这一目标。想要了解更多关于 SVD 的信息,可以参考这篇博客文章,它提供了 SVD 的几何直觉,并展示了它在 PCA 中的应用:gregorygundersen.com/blog/2018/12/10/svd/
。
范数
范数用作衡量向量大小(即向量中的值)的标准。p^(th)范数的计算和表示如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_071.png
例如,L2范数是这样的:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_072.png
行列式
方阵的行列式表示为https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_073.png。行列式在许多方面都非常有用。例如,A仅当且仅当行列式不为零时才是可逆的。行列式也可以被解释为矩阵所有特征值的乘积。2x2矩阵A的行列式表示为
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_075.png
如下所示
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_076.png
计算方法为
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_077.png
以下方程展示了3x3矩阵行列式的计算:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_078.png
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_079.png
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_080.png
概率
接下来,我们将讨论与概率论相关的术语。概率论是机器学习的一个重要部分,因为使用概率模型建模数据可以帮助我们得出关于模型在某些预测上不确定性的结论。以情感分析的使用案例为例,我们想为给定的电影评论输出一个预测(正面/负面)。尽管模型对于我们输入的每一个样本都输出一个介于 0 和 1 之间的值(0 表示负面,1 表示正面),但模型并不知道它对其答案的不确定性有多大。
让我们理解不确定性如何帮助我们做出更好的预测。例如,一个确定性模型(即输出确切值而非值的分布的模型)可能会错误地说评论 “我从未失去兴趣” 的正向概率是 0.25(也就是说,它更可能是负面评论)。然而,概率模型将为预测提供一个均值和一个标准差。例如,它可能会说,这个预测的均值为 0.25,标准差为 0.5。在第二种模型下,我们知道由于标准差较大,预测很可能是错误的。然而,在确定性模型中,我们没有这种奢侈的选择。这一特性对于关键的机器系统(例如,恐怖主义风险评估模型)尤其有价值。
为了开发这样的概率机器学习模型(例如,贝叶斯逻辑回归、贝叶斯神经网络或高斯过程),你应该熟悉基本的概率理论。因此,我们将在这里提供一些基本的概率信息。
随机变量
随机变量是一个可以随机取值的变量。此外,随机变量通常表示为 x[1]、x[2] 等。随机变量可以分为两种类型:离散型和连续型。
离散随机变量
离散随机变量是指可以取离散随机值的变量。例如,掷硬币的试验可以被建模为一个随机变量;即,硬币掷出的正面或反面是一个离散变量,因为结果只能是正面或反面。另外,掷骰子的结果也是离散的,因为其值只能来自集合 {1,2,3,4,5,6}
。
连续随机变量
连续随机变量是一个可以取任何实数值的变量,也就是说,如果 x 是一个连续随机变量:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_081.png
这里,R 表示实数空间。
例如,一个人的身高是一个连续随机变量,因为它可以取任何实数值。
概率质量/密度函数
概率质量函数(PMF)或概率密度函数(PDF)是一种展示随机变量在不同值上概率分布的方式。对于离散变量,定义了 PMF;对于连续变量,定义了 PDF。图 A.1 显示了一个 PMF 的例子:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_01.png
A.1: 概率质量函数(PMF)离散型
上述的概率质量函数(PMF)可能是通过一个偏骰子实现的。在这张图中,我们可以看到,掷这个骰子时,出现 3 的概率很高。这样的图形可以通过进行多次试验(比如 100 次)并统计每个面朝上的次数得到。最后,你需要将每个计数除以试验次数,以获得标准化后的概率。请注意,所有的概率总和应为 1,正如这里所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_082.png
相同的概念被扩展到连续随机变量,以获得一个 PDF。假设我们试图建模给定人群的某个身高的概率。与离散情况不同,我们没有个别的值来计算概率,而是一个连续的值范围(在本例中,它从 0 到 2.4 m)。如果我们像 图 A.1 中的示例一样绘制图表,我们需要以无穷小区间来考虑它。例如,我们找出一个人身高在 0.0 m-0.01 m, 0.01-0.02 m, …, 1.8 m-1.81 m, … 等范围内的概率密度。概率密度可以使用以下公式计算:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_083.png
然后,我们将这些条形图画得靠近彼此,从而获得一个连续的曲线,如图 A.2所示。请注意,给定的区间的概率密度可以大于 1(因为它是密度),但是曲线下的面积必须为 1:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_02.png
图 A.2:概率密度函数(PDF)连续
在图 A.2中显示的形状被称为正态分布(或高斯分布)。它也被称为钟形曲线。我们之前给出的是关于如何理解连续概率密度函数的直观解释。
更正式地说,正态分布的连续 PDF 有一个公式,定义如下。假设连续随机变量 X 具有均值 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_084.png 和标准差 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_085.png 的正态分布。对于任何 x 的值,X = x 的概率由以下公式给出:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_086.png
如果你对所有可能的无穷小 dx 值进行积分,应该得到区域(有效的 PDF 需要为 1),如以下公式所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_087.png
任意 a 和 b 值的正态分布积分通过以下公式给出:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_088.png
使用这个公式,我们可以得到正态分布的积分,其中 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_089.png 和 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_090.png:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_091.png
这给出了所有 x 值的概率值的累积,并给出了一个值为 1 的结果。
你可以在 mathworld.wolfram.com/GaussianIntegral.html
查找更多信息,或者参考 en.wikipedia.org/wiki/Gaussian_integral
进行更简单的讨论。
条件概率
条件概率表示在一个事件发生的前提下,另一个事件发生的概率。例如,给定两个随机变量,X 和 Y,在 Y = y 的条件下,X = x 的条件概率可以用以下公式表示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_092.png
这种概率的一个实际例子如下所示:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_093.png
联合概率
给定两个随机变量X和Y,我们将X = x和Y = y的概率称为X = x和Y = y的联合概率。其公式表示如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_094.png
如果X和Y是互斥事件,则此表达式将简化为:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_095.png
一个现实世界中的例子如下:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_096.png
边际概率
边际概率分布是给定所有变量的联合概率分布时,某一随机变量子集的概率分布。例如,假设存在两个随机变量X和Y,且我们已经知道https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_097.png,我们想要计算P(x):
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_098.png
直观地说,我们正在对所有可能的Y值求和,实际上是在计算Y = 1的概率。
贝叶斯定理
贝叶斯定理为我们提供了一种计算https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_099.png的方法,前提是我们已经知道https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_100.png和https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_101.png。我们可以通过以下方式轻松推导出贝叶斯定理:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_102.png
现在让我们来看中间和右边的部分:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_103.png
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_104.png
这就是贝叶斯定理。简单来说,就是这样:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_105.png
使用 TensorBoard 可视化词嵌入
当我们在第三章“Word2vec——学习词嵌入”中想要可视化词嵌入时,我们是通过手动实现 t-SNE 算法来进行可视化的。然而,你也可以使用 TensorBoard 来可视化词嵌入。TensorBoard 是 TensorFlow 提供的一个可视化工具。你可以用 TensorBoard 来可视化程序中的 TensorFlow 变量。这让你可以看到不同变量随着时间的变化(例如,模型的损失/准确度),从而帮助你识别模型中的潜在问题。
TensorBoard 使你能够可视化标量值(例如,训练迭代中的损失值)和向量作为直方图(例如,模型层节点的激活)。除此之外,TensorBoard 还允许你可视化词嵌入。因此,如果你需要分析嵌入的样子,TensorBoard 为你提供了所有所需的代码实现。接下来,我们将看到如何使用 TensorBoard 来可视化词嵌入。本练习的代码在Appendix
文件夹中的tensorboard_word_embeddings.ipynb
里提供。
启动 TensorBoard
首先,我们将列出启动 TensorBoard 的步骤。TensorBoard 作为一个服务运行,并使用特定的端口(默认情况下是6006
)。要启动 TensorBoard,你需要按照以下步骤操作:
-
打开命令提示符(Windows)或终端(Ubuntu/macOS)。
-
进入项目的主目录。
-
如果你使用的是 python 的
virtualenv
,请激活你已安装 TensorFlow 的虚拟环境。 -
确保你能通过 Python 看到 TensorFlow 库。为此,按照以下步骤操作:
-
输入
python3
;你将看到一个类似>>>
的提示符。 -
尝试
import tensorflow as tf
-
如果你能够成功运行此操作,那么你就没问题了
-
通过输入
exit()
退出python
提示符(即>>>
)
-
-
输入
tensorboard --logdir=models
:-
--logdir
选项指向你将创建数据以供可视化的目录 -
可选地,你可以使用
--port=<port_you_like>
来更改 TensorBoard 运行的端口
-
-
你现在应该能看到以下消息:
TensorBoard 1.6.0 at <url>;:6006 (Press CTRL+C to quit)
-
在网页浏览器中输入
<url>:6006
。此时,你应该能够看到一个橙色的仪表盘。由于我们还没有生成任何数据,所以不会显示任何内容。
保存词嵌入并通过 TensorBoard 进行可视化
首先,我们将从nlp.stanford.edu/projects/glove/
下载并加载 50 维的 GloVe 词向量文件(glove.6B.zip
),并将其放入Appendix
文件夹中。我们将加载文件中的前 50,000 个词向量,稍后将这些词向量用于初始化 TensorFlow 变量。同时,我们还将记录每个词的字符串,因为稍后我们会将这些字符串作为标签,在 TensorBoard 中显示每个点:
vocabulary_size = 50000
embedding_df = []
index = []
# Open the zip file
with zipfile.ZipFile('glove.6B.zip') as glovezip:
# Read the file with 50 dimensional embeddings
with glovezip.open('glove.6B.50d.txt') as glovefile:
# Read line by line
for li, line in enumerate(glovefile):
# Print progress
if (li+1)%10000==0: print('.',end='')
# Get the word and the corresponding vector
line_tokens = line.decode('utf-8').split(' ')
word = line_tokens[0]
vector = [float(v) for v in line_tokens[1:]]
assert len(vector)==50
index.append(word)
# Update the embedding matrix
embedding_df.append(np.array(vector))
# If the first 50000 words being read, finish
if li >= vocabulary_size-1:
break
embedding_df = pd.DataFrame(embedding_df, index=index)
我们已将嵌入定义为一个 pandas DataFrame。它将词向量作为列,将词作为索引。
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_03.png
图 A.3:以 pandas DataFrame 形式呈现的 GloVe 向量
我们还需要定义与 TensorFlow 相关的变量和操作。在此之前,我们将创建一个名为embeddings
的目录,用于存储这些变量:
# Create a directory to save our model
log_dir = 'embeddings'
os.makedirs(log_dir, exist_ok=True)
然后,我们将定义一个变量,该变量将用我们之前从文本文件中复制的词嵌入进行初始化:
# Save the weights we want to analyse as a variable.
embeddings = tf.Variable(embedding_df.values)
print(f"weights.shape: {embeddings.shape}")
# Create a checkpoint from embedding
checkpoint = tf.train.Checkpoint(embedding=embeddings)
checkpoint.save(os.path.join(log_dir, "embedding.ckpt"))
我们还需要保存一个元数据文件。元数据文件包含与词嵌入相关的标签/图像或其他类型的信息,以便当你悬停在嵌入可视化上时,相应的点将显示它们所代表的词/标签。元数据文件应为.tsv
(制表符分隔值)格式,且应包含vocabulary_size
行,其中每行包含一个词,按它们在词嵌入矩阵中出现的顺序排列:
with open(os.path.join(log_dir, 'metadata.tsv'), 'w', encoding='utf-8') as f:
for w in embedding_df.index:
f.write(w+'\n')
然后,我们需要告诉 TensorFlow 它在哪里可以找到我们保存到磁盘的嵌入数据的元数据。为此,我们需要创建一个ProjectorConfig
对象,该对象保存有关我们要显示的嵌入的各种配置信息。存储在ProjectorConfig
文件夹中的详细信息将保存在models
目录中的projector_config.pbtxt
文件中:
config = projector.ProjectorConfig()
在这里,我们将填写我们创建的ProjectorConfig
对象的必填字段。首先,我们将告诉它我们感兴趣的变量名称。然后,我们将告诉它在哪里可以找到与该变量对应的元数据:
config = projector.ProjectorConfig()
# You can add multiple embeddings. Here we add only one.
embedding_config = config.embeddings.add()
embedding_config.tensor_name = "embedding/.ATTRIBUTES/VARIABLE_VALUE"
# Link this tensor to its metadata file (e.g. labels).
embedding_config.metadata_path = 'metadata.tsv'
# TensorBoard will read this file during startup.
projector.visualize_embeddings(log_dir, config)
请注意,我们在embedding
名称后添加了后缀/.ATTRIBUTES/VARIABLE_VALUE
。这是 TensorBoard 找到此张量所必需的。TensorBoard 将在启动时读取必要的文件:
projector.visualize_embeddings(log_dir, config)
现在,如果你加载 TensorBoard,你应该能看到类似图 A.4的内容:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_04.png
图 A.4:TensorBoard 可视化的嵌入
当您将鼠标悬停在显示的点云上时,系统会显示您当前悬停的单词标签,因为我们在 metadata.tsv
文件中提供了这些信息。此外,您还有几个选项。第一个选项(如虚线框所示,标记为 1)允许您选择嵌入空间的一个子集。您可以在感兴趣的嵌入空间区域画出一个边界框,效果如 图 A.5 所示。我选择了可视化中右侧的嵌入。您可以在右侧看到选定单词的完整列表:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_05.png
图 A.5:选择嵌入空间的一个子集
另一种选择是查看单词本身,而非点。您可以通过选择 图 A.4 中的第二个选项(标记为 2 的实心框)来实现。这将显示如 图 A.6 所示的效果。此外,您可以根据需要平移/缩放/旋转视图。如果点击帮助按钮(如 图 A.6 中标记为 1 的实心框所示),将显示一个控制视图的指南:
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_12_06.png
图 A.6:以单词形式显示的嵌入向量,而非点
最后,您可以通过左侧面板更改可视化算法(如 图 A.4 中所示,标记为 3 的虚线框)。
摘要
在这里,我们讨论了一些数学背景知识,以及我们在其他章节中没有涉及的一些实现。首先,我们讨论了标量、向量、矩阵和张量的数学符号。接着,我们讨论了对这些数据结构进行的各种操作,例如矩阵乘法和矩阵求逆。之后,我们讨论了一些有助于理解概率机器学习的术语,如概率密度函数、联合概率、边际概率和贝叶斯规则。最后,我们在附录中以如何使用 TensorFlow 附带的可视化平台 TensorBoard 来可视化词嵌入的指南结束。
https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/New_Packt_Logo1.png
packt.com
订阅我们的在线数字图书馆,您将可以访问超过 7,000 本书籍和视频,此外还可以使用行业领先的工具帮助您规划个人发展并推动职业进步。欲了解更多信息,请访问我们的网站。
为什么订阅?
-
花更少的时间学习,花更多的时间编码,利用来自超过 4,000 名行业专业人士的实用电子书和视频
-
通过为您量身定制的技能计划提高您的学习效率
-
每月获取免费的电子书或视频
-
完全可搜索,方便快速访问重要信息
-
复制和粘贴、打印和收藏内容
在 www.packt.com,您还可以阅读一系列免费的技术文章,订阅各种免费的电子邮件通讯,并获得 Packt 图书和电子书的独家折扣和优惠。
其他您可能喜欢的书籍
如果您喜欢本书,您可能对 Packt 出版的其他书籍感兴趣:
自然语言处理的变压器模型(第二版)
丹尼斯·罗斯曼(Denis Rothman)
ISBN:9781803247335
-
了解 ViT 和 CLIP 如何为图像(包括模糊图像!)打标签,并使用 DALL-E 根据句子生成图像
-
探索新的技术以研究复杂的语言问题
-
对比和分析 GPT-3 与 T5、GPT-2 和 BERT 模型的结果
-
使用 TensorFlow、PyTorch 和 GPT-3 进行情感分析、文本摘要、口语分析、机器翻译等任务
-
衡量关键变压器的生产力,以定义它们的范围、潜力和生产限制
使用 PyTorch 和 Scikit-Learn 进行机器学习
塞巴斯蒂安·拉施卡(Sebastian Raschka)
刘宇熙(Hayden Liu)
瓦希德·米尔贾利利(Vahid Mirjalili)
ISBN:9781801819312
-
探索框架、模型和技术,让机器从数据中“学习”
-
使用 scikit-learn 进行机器学习,使用 PyTorch 进行深度学习
-
在图像、文本等数据上训练机器学习分类器
-
构建和训练神经网络、变压器模型和提升算法
-
发现评估和调优模型的最佳实践
-
使用回归分析预测连续的目标结果
-
深入挖掘文本和社交媒体数据,使用情感分析
Packt 正在寻找像您这样的作者
如果您有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天就申请。我们与成千上万的开发者和技术专家合作,帮助他们与全球技术社区分享见解。您可以提交一般申请,申请我们正在招聘作者的特定热门话题,或者提交您自己的创意。
分享您的想法
现在您已经完成了使用 TensorFlow 进行自然语言处理(第二版),我们很想听听您的想法!如果您从 Amazon 购买了本书,请点击这里直接进入 Amazon 的书评页面,分享您的反馈或在您购买的站点上留下评论。
您的评价对我们以及技术社区非常重要,将帮助我们确保提供优质的内容。