购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

3.2 编码器

正如我们之前所看到的,Transformer的编码器由许多编码器层相互堆叠而成。如图3-2所示,每个编码器层接收一系列嵌入,然后通过以下子层进行馈送处理:

●一个多头自注意力层。

●一个全连接前馈层,应用于每个输入嵌入。

每个编码器层的输出嵌入尺寸与输入嵌入相同,我们很快就会看到编码器堆叠的主要作用是“更新”输入嵌入,以产生编码一些序列中的上下文信息的表示。例如,如果单词“苹果”附近的单词是“主题演讲”或“电话”,那么该单词将被更新为更像“公司”的特性,而不是更像“水果”的特性。

图3-2:编码器层放大图

这些子层同样使用跳跃连接和层规范化,这些是训练深度神经网络的常用技巧。但要想真正理解Transformer的工作原理,我们还需要更深入地研究。我们从最重要的构建模块开始:自注意力层。

3.2.1 自注意力机制

正如我们在第1章中讨论的那样,注意力机制是一种神经网络为序列中的每个元素分配不同权重或“注意力”的机制。对文本序列来说,元素则为我们在第2章遇到的词元嵌入(其中每个词元映射为固定维度的向量)。例如,在BERT中,每个词元表示为一个768维向量。自注意力中的“自”指的是这些权重是针对同一组隐藏状态计算的,例如编码器的所有隐藏状态。与自注意力相对应的,与循环模型相关的注意力机制则计算每个编码器隐藏状态对于给定解码时间步的解码器隐藏状态的相关性。

自注意力的主要思想是,不是使用固定的嵌入值来表示每个词元,而是使用整个序列来计算每个嵌入值的加权平均值。另一种表述方式是说,给定词元嵌入的序列 x 1 ,…, x n ,自注意力产生新的嵌入序列 ,其中每个 是所有 x j 的线性组合:

其中的系数 w ji 称为注意力权重,其被规范化以使得∑ j w ji =1。如果想要了解为什么平均词元嵌入可能是一个好主意,那么可以这样思考,当你看到单词“flies”时会想到什么。也许你会想到令人讨厌的昆虫,但是如果你得到更多的上下文,比如“time flies like an arrow”,那么你会意识到“flies”表示的是动词。同样地,我们可以通过以不同的比例结合所有词元嵌入来创建“flies”的表示形式,也许可以给“time”和“arrow”的词元嵌入分配较大的权重 w ji 。用这种方式生成的嵌入称为上下文嵌入,早在Transformer发明之前就存在了,例如ELMo语言模型 [2] 。图3-3展示了这一过程,我们通过自注意力根据上下文生成了“flies”的两种不同表示 [3]

图3-3:自注意力将原始的词元嵌入(顶部)更新为上下文嵌入(底部),从而创建包含了整个序列信息的表示

现在我们看一下注意力权重是如何计算的。

缩放点积注意力

实现自注意力层的方法有好几种,但最常见的是那篇著名的Transformer架构论文 [4] 所介绍的缩放点积注意力(scaled dot-product attention)。要实现这种机制,需要四个主要步骤:

1.将每个词元嵌入投影到三个向量中,分别称为 query key value

2.计算注意力分数。我们使用相似度函数确定query和key向量的相关程度。顾名思义,缩放点积注意力的相似度函数是点积,并通过嵌入的矩阵乘法高效计算。相似的query和key将具有较大的点积,而那些没有相似处的则几乎没有重叠。这一步的输出称为注意力分数,在一个有 n 个输入词元的序列中,将对应着一个 n × n 的注意力分数矩阵。

3.计算注意力权重。点积在一般情况下有可能会产生任意大的数,这可能会导致训练过程不稳定。为了处理这个问题,首先将注意力分数乘以一个缩放因子来规范化它们的方差,然后再通过softmax进行规范化,以确保所有列的值相加之和为1。结果得到一个 n × n 的矩阵,该矩阵包含了所有的注意力权重 w ji

4.更新词嵌入。计算完注意力权重之后,我们将它们与值向量 v 1 ,…, v n 相乘,最终获得词嵌入表示

我们可以使用一个很赞的库,Jupyter的BertViz( https://oreil.ly/eQK3I ),来可视化以上的注意力权重计算过程。该库提供了一些可用于可视化Transformer模型的不同方面注意力的函数。如果想可视化注意力权重,我们可以使用neuron_view模块,该模块跟踪权重计算的过程,以显示如何将query向量和key向量相结合以产生最终权重。由于BertViz需要访问模型的注意力层,因此我们将使用BertViz中的模型类来实例化我们的BERT checkpoint,然后使用show()函数为特定的编码器层和注意力头生成交互式可视化。请注意,你需要单击左侧的“+”才能激活注意力可视化。

从以上可视化图中,我们可以看到query向量和key向量的值表示为一条条的条带,其中每个条带的强度对应于其大小。连线的权重是根据词元之间的注意力加权的,我们可以看到,“flies”的query向量与“arrow”的key向量重叠最强。

揭开query、key和value的神秘面纱

在你第一次接触query、key和value向量的概念时,可能会觉得这些概念有点晦涩难懂。这些概念受到信息检索系统的启发,但我们可以用一个简单的类比来解释它们的含义。你可以这样想象,你正在超市购买晚餐所需的所有食材。你有一份食谱,食谱里面每个食材可以视为一个query。然后你会扫描货架,通过货架上的标注(key),以检查该商品是否与你列表中的食材相匹配(相似度函数)。如果匹配成功,那么你就从货架上取走这个商品(value)。

在这个类比中,你只会得到与食材匹配的商品,而忽略掉其他不匹配的商品。自注意力是这个类比更抽象和流畅的版本:超市中的每个标注都与配料匹配,匹配的程度取决于每个key与query的匹配程度。因此,如果你的清单包括一打鸡蛋,那么你可能会拿走10个鸡蛋、一个煎蛋卷和一个鸡翅。

缩放点积注意力过程的更详细细节可以参见图3-4。

图3-4:缩放点积注意力中的操作

本章我们将使用PyTorch实现Transformer架构,使用TensorFlow实现Transformer架构的步骤与之类似。两个框架中最重要的函数之间的映射关系详见表3-1。

表3-1:本章中使用的PyTorch和TensorFlow(Keras)的类和方法

我们需要做的第一件事是对文本进行词元化,因此我们使用词元分析器提取输入ID:

正如我们在第2章所看到的那样,句子中的每个词元都被映射到词元分析器的词表中的唯一ID。为了保持简单,我们还通过设置add_special_tokens=False来将[CLS]和[SEP]词元排除在外。接下来,我们需要创建一些密集嵌入。这里的密集是指嵌入中的每个条目都包含一个非零值。相反,我们在第2章所看到的独热编码是稀疏的,因为除一个之外的所有条目都是零。在PyTorch中,我们可以通过使用torch.nn.Embedding层来实现这一点,该层作为每个输入ID的查找表:

在这里,我们使用AutoConfig类加载了与bert-base-uncased checkpoint相关联的 config.json 文件。在Hugging Face Transformers库中,每个checkpoint都被分配一个配置文件,该文件指定了各种超参数,例如vocab_size和hidden_size。在我们的示例中,每个输入ID将映射到nn.Embedding中存储的30 522个嵌入向量之一,其中每个向量维度为768。AutoConfig类还存储其他元数据,例如标注名称,用于格式化模型的预测。

需要注意的是,此时的词元嵌入与它们的上下文是独立的。这意味着,同形异义词(拼写相同但意义不同的词),如前面例子中的“flies”(“飞行”或“苍蝇”),具有相同的表示形式。后续的注意力层的作用是将这些词元嵌入进行混合,以消除歧义,并通过其上下文的内容来丰富每个词元的表示。

现在我们有了查找表,通过输入ID,我们可以生成嵌入向量:

这给我们提供了一个形状为[batch_size,seq len,hidden_dim]的张量,就像我们在第2章中看到的一样。这里我们将推迟位置编码,因此下一步是创建query、key和value向量,并使用点积作为相似度函数来计算注意力分数:

这产生了一个5×5矩阵,其中包含批量中每个样本的注意力分数。稍后我们将看到,query、key和value向量是通过将独立的权重矩阵 W Q , K , V 应用到嵌入中生成的,但这里为简单起见,我们将它们设为相等。在缩放点积注意力中,点积按照嵌入向量的大小进行缩放,这样我们在训练过程中就不会得到太多的大数,从而可以避免下一步要应用的softmax饱和。

torch.bmm()函数执行批量矩阵乘积,简化了注意力分数的计算过程,其中query和key向量的形状为[batch_size,seq_len,hidden_dim]。如果我们忽略批处理维度,那么我们可以通过简单地转置key张量以使其形状为[hidden dim,seq len],然后使用矩阵乘积来收集所有在[seq_len,seq_len]矩阵中的点积。由于我们希望对批处理中的所有序列独立地执行此操作,因此我们使用torch.bmm(),它接收两个矩阵批处理并将第一个批处理中的每个矩阵与第二个批处理中的相应矩阵相乘。

接下来我们应用softmax:

最后将注意力权重与值相乘:

这就是全部了,我们已经完成了简化形式的自注意力机制实现的所有步骤!请注意,整个过程仅涉及两个矩阵乘法和一个softmax,因此你可以将“自注意力”视为一种花哨的平均形式。

我们把这些步骤封装成一个函数,以便以后我们可以重用它:

我们的注意力机制在query向量和key向量相等的情况下,会给上下文中相同的单词分配非常高的分数,特别是给当前单词本身:query向量与自身的点积总是1。而实际上,一个单词的含义将更好地受到上下文中其他单词的影响,而不是同一单词(甚至自身)。以前面的句子为例,通过结合“time”和“arrow”的信息来定义“flies”的含义,比重复提及“flies”要更好。那么我们如何实现这点?

我们可以让模型使用三个不同的线性投影将初始词元向量投影到三个不同的空间中,从而允许模型为query、key和value创建一个不同的向量集。

多头注意力

前面提到,我们将query、key和value视为相等来计算注意力分数和权重。但在实践中,我们会使用自注意力层对每个嵌入应用三个独立的线性变换,以生成query、key和value向量。这些变换对嵌入进行投影,每个投影都带有其自己的可学习参数,这使得自注意力层能够专注于序列的不同语义方面。

同时,拥有多组线性变换通常也是有益的,每组变换代表一种所谓的注意力头。多头注意力层如图3-5所示。但是,为什么我们需要多个注意力头?原因是一个注意力头的softmax函数往往会集中在相似度的某一方面。拥有多个头能够让模型同时关注多个方面。例如,一个头负责关注主谓交互,而另一个头负责找到附近的形容词。显然,我们没有在模型中手工制作这些关系,它们完全是从数据中学习到的。如果你对计算机视觉模型熟悉,你可能会发现其与卷积神经网络中的滤波器相似,其中一个滤波器负责检测人脸,而另一个滤波器负责在图像中找到汽车的车轮。

图3-5:多头注意力

现在我们来编码实现,首先编写一个单独的注意力头的类:

这里我们初始化了三个独立的线性层,用于对嵌入向量执行矩阵乘法,以生成形状为[batch_size,seq_len,head_dim]的张量,其中head_dim是我们要投影的维数数量。尽管head_dim不一定比词元的嵌入维数(embed_dim)小,但在实践中,我们选择head_dim是embed_dim的倍数,以便跨每个头的计算能够保持恒定。例如,BERT有12个注意力头,因此每个头的维数为768/12=64。

现在我们有了一个单独的注意力头,因此我们可以将每个注意力头的输出串联起来,来实现完整的多头注意力层:

请注意,注意力头连接后的输出也通过最终的线性层进行馈送,以生成形状为[batch_size,seq_len,hidden_dim]的输出张量,以适用于下游的前馈网络。为了确认,我们看看多头注意力层是否产生了我们输入的预期形状。在初始化MultiHeadAttention模块时,我们传递了之前从预训练的BERT模型中加载的配置。这确保我们使用与BERT相同的设置:

这么做是可行的!最后,我们再次使用BertViz可视化单词“flies”的两个不同用法的注意力。这里我们可以使用BertViz的head_view()函数,通过计算预训练checkpoint的注意力并指示句子边界的位置来显示注意力:

这种可视化展示了注意力权重,表现为连接正在被更新嵌入的词元(左侧)与所有被关注的单词(右侧)之间的线条。线条的颜色深度表现了注意力权重的大小,深色线条代表值接近于1,淡色线条代表值接近于0。

在这个例子中,输入由两个句子组成,[CLS]和[SEP]符号是我们在第2章中遇到的BERT的词元分析器中的特殊符号。从可视化结果中我们可以看到注意力权重最大的是属于同一句子的单词,这表明BERT能够判断出它应该关注同一句子中的单词。然而,对于单词“flies”,我们可以看到BERT已经识别出在第一句中“arrow”是重要的,在第二句中“fruit”和“banana”是重要的。这些注意力权重使模型能够根据它所处的上下文来区分“flies”到底应该为动词还是名词!

至此我们已经讲述完注意力机制了,我们来看一下如何实现编码器层缺失的一部分:位置编码前馈神经网络。

3.2.2 前馈层

编码器和解码器中的前馈子层仅是一个简单的两层全连接神经网络,但有一点小小的不同:它不会将整个嵌入序列处理为单个向量,而是独立处理每个嵌入。因此,该层通常称为位置编码前馈神经网络。有时候你还会看到它又被称为内核大小为1的1维卷积,这种叫法通常来自具有计算机视觉背景的人(例如,OpenAI GPT代码库就是这么叫的)。论文中的经验法则是第一层的隐藏尺寸应为嵌入尺寸的四倍,并且最常用的激活函数是GELU。这是大部分容量和记忆发生的地方,也是扩展模型时最经常进行缩放的部分。我们可以将其实现为一个简单的nn.Module,如下所示:

需要注意的是,像nn.Linear这样的前馈层通常应用于形状为(batch_size,input_dim)的张量上,它将独立地作用于批量维度中的每个元素。这对于除了最后一个维度之外的任何维度都是正确的,因此当我们将形状为(batch_size,seq_len,hidden_dim)的张量传给该层时,该层将独立地应用于批量和序列中的所有词元嵌入,这正是我们想要的。我们可以通过传递注意力输出来测试这一点:

现在我们已经拥有了创建完整的Transformer编码器层的所有要素!唯一剩下的部分是决定在哪里放置跳跃连接和层规范化。我们看看这会如何影响模型架构。

3.2.3 添加层规范化

如前所述,Transformer架构使用了层规范化和跳跃连接。前者将批处理中的每个输入规范化为零均值和单位方差。跳跃连接直接将张量传给模型的下一层,而不做处理,只将其添加到处理的张量中。在将层规范化放置在Transformer的编码器或解码器层中时,论文提供了两种选项:

后置层规范化

这是Transformer论文中使用的一种结构,它把层规范化置于跳跃连接之后。这种结构从头开始训练时会比较棘手,因为梯度可能会发散。因此,在训练过程中我们经常会看到一个称为学习率预热的概念,其中学习率在训练期间从一个小值逐渐增加到某个最大值。

前置层规范化

这是论文中最常见的布局,它将层规范化置于跳跃连接之前。这样做往往在训练期间更加稳定,并且通常不需要任何学习率预热。

这两种方式的区别如图3-6所示。

图3-6:Transformer编码器层中层规范化的两种方式

这里我们将使用第二种方式,因此我们可以简单地将我们的基本构件粘在一起,如下所示:

现在使用我们的输入嵌入来测试一下:

我们已经成功地从头开始实现了我们的第一个Transformer编码器层!然而,我们设置编码器层的方式存在一个问题:它们对于词元的位置是完全不变的。由于多头注意力层实际上是一种精致的加权和,因此词元位置的信息将丢失 [5]

幸运的是,有一种简单的技巧可以使用位置嵌入来整合位置信息。我们来看看。

3.2.4 位置嵌入

位置编码基于一个简单但非常有效的想法:用一个按向量排列的位置相关模式来增强词元嵌入。如果该模式对于每个位置都是特定的,那么每个栈中的注意力头和前馈层可以学习将位置信息融合到它们的转换中。

有几种实现这个目标的方法,其中最流行的方法之一是使用可学习的模式,特别是在预训练数据集足够大的情况下。这与仅使用词元嵌入的方式完全相同,但是使用位置索引作为输入,而不是词元ID。通过这种方法,在预训练期间可以学习到一种有效的编码词元位置的方式。

我们创建一个自定义的Embeddings模块,它将输入的input_ids投影到密集的隐藏状态上,并结合Position_ids的位置嵌入进行投影。最终的嵌入层是两个嵌入层的简单求和:

我们可以看到嵌入层现在为每个词元创建了一个密集的嵌入。

这种可学习的位置嵌入易于实现并广泛使用,除此之外,还有其他一些方法:

绝对位置表示

Transformer模型可以使用由调制正弦和余弦信号组成的静态模式来编码词元的位置。当没有大量数据可用时,这种方法尤其有效。

相对位置表示

尽管绝对位置很重要,但有观点认为,在计算嵌入时,周围的词元最为重要。相对位置表示遵循这种直觉,对词元之间的相对位置进行编码。这不能仅通过在开头引入新的相对嵌入层来设置,因为相对嵌入针对每个词元会因我们对序列的访问位置的不同而不同。对此,注意力机制本身通过添加额外项来考虑词元之间的相对位置。像DeBERTa等模型就使用这种表示 [6]

现在我们把所有内容整合起来,通过将嵌入与编码器层结合起来构建完整的Transformer编码器:

我们检查编码器的输出形状:

我们可以看到在每个批量中,我们为每个词元获取了一个隐藏状态。这种输出格式使得架构非常灵活,我们可以很容易地适应各种应用,例如在掩码语言建模中预测缺失的词元,或者在问答中预测回答的起始和结束位置。在3.2.5节中,我们将讲述如何构建一个类似于我们在第2章中使用的分类器。

3.2.5 添加分类头

Transformer模型通常分为与任务无关的主体和与任务相关的头。我们将在第4章讲述Hugging Face Transformers库的设计模式时会再次提到这种模式。到目前为止,我们所构建的都是主体部分的内容,如果我们想构建一个文本分类器,那么我们还需要将分类头附加到该主体上。每个词元都有一个隐藏状态,但我们只需要做出一个预测。有几种方法可以解决这个问题。一般来说,这种模型通常使用第一个词元来进行预测,我们可以附加一个dropout和一个线性层来进行分类预测。下面的类对现有的编码器进行了扩展以用于序列分类:

在初始化模型之前,我们需要定义希望预测的类数目:

这正是我们一直在寻找的。对于批处理中的每个样本,我们会得到输出中每个类别的非规范化logit值。这类似于我们在第2章中使用的BERT模型(用于检测推文中的情感)。

至此我们对编码器以及如何将其与具体任务的头相组合的部分就结束了。现在我们将注意力(双关语!)转向解码器。 jMlB3+MEESBuvIgGN3HuRtSRhiY2sDWMjg5webVyhTH1JaAFE6f67FhebmnvzLFb

点击中间区域
呼出菜单
上一章
目录
下一章
×