大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流
个人主页-Sonhhxg_柒的博客_CSDN博客
欢迎各位→点赞 + 收藏⭐️ + 留言
系列专栏 - 机器学习【ML】 自然语言处理【NLP】 深度学习【DL】
foreword
✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。
如果你对这个系列感兴趣的话,可以关注订阅哟
文章目录
Transformer架构
编码器
自注意力
缩放点积注意力
多头注意力
前馈层
添加层规范化
位置嵌入
添加分类头
解码器
认识变形金刚
变形金刚生命之树
编码器分支
解码器分支
编码器-解码器分支
结论
在第 2 章中,我们看到了如何微调和评估变压器。现在让我们看看它们是如何在幕后工作的。在本章中,我们将探索变压器模型的主要构建块以及如何使用 PyTorch 实现它们。我们还将提供有关如何在 TensorFlow 中执行相同操作的指导。我们将首先专注于构建注意力机制,然后添加使转换器编码器工作所需的点点滴滴。我们还将简要介绍编码器和解码器模块之间的架构差异。在本章结束时,您将能够自己实现一个简单的变压器模型!
虽然对于 Transformer 架构的深入技术理解通常不需要 为您的用例使用 Transformer 和微调模型,但它有助于理解和导航 Transformer 的局限性并在新领域中使用它们。
本章还介绍了转换器的分类,以帮助您了解近年来出现的模型动物园。在深入研究代码之前,让我们先概述一下启动 Transformer 革命的原始架构。
正如我们在第 1 章中看到的,最初的 Transformer 基于广泛用于机器翻译等任务的编码器-解码器架构,其中将一系列单词从一种语言翻译成另一种语言。该架构由两个组件组成:
编码器
将输入的标记序列转换为嵌入向量序列,通常称为隐藏状态或上下文
解码器
使用编码器的隐藏状态迭代生成令牌的输出序列,一次一个令牌
如图3-1 所示,编码器和解码器本身由几个构建块组成。
图 3-1。Transformer的Encoder-decoder架构,图中上半部分为encoder,下半部分为decoder
稍后我们将详细了解每个组件,但我们已经可以在 图 3-1中看到一些表征 Transformer 架构的东西:
使用我们在第 2 章中遇到的技术将输入文本标记化并转换为标记嵌入。由于注意力机制不知道标记的相对位置,我们需要一种方法将一些关于标记位置的信息注入到输入中,以对文本的顺序性质进行建模。因此,令牌嵌入与包含每个令牌的位置信息的位置嵌入相结合。
编码器由一堆编码器层或“块”组成,类似于计算机视觉中的堆叠卷积层。解码器也是如此,它有自己的解码器层堆栈。
编码器的输出被馈送到每个解码器层,然后解码器为序列中最可能的下一个标记生成预测。然后将此步骤的输出反馈到解码器以生成下一个令牌,依此类推,直到达到特殊的序列结束 (EOS) 令牌。在 图 3-1的示例中,假设解码器已经预测了“Die”和“Zeit”。现在它将这两个作为输入以及所有编码器的输出来预测下一个标记“fliegt”。在下一步中,解码器将“fliegt”作为附加输入。我们重复这个过程,直到解码器预测出 EOS 令牌或我们达到最大长度。
Transformer 架构最初是为机器翻译等序列到序列的任务而设计的,但编码器和解码器块很快都被改编为独立模型。尽管有数百种不同的变压器型号,但大多数都属于以下三种类型之一:
仅编码器
这些模型将输入的文本序列转换为丰富的数字表示,非常适合文本分类或命名实体识别等任务。BERT 及其变体,如 RoBERTa 和 DistilBERT,属于此类架构。在此架构中为给定令牌计算的表示取决于左侧(令牌之前)和右侧(令牌之后)上下文。这通常称为双向注意。
仅解码器
给定诸如“谢谢你的午餐,我有一个……”之类的文本提示,这些模型将通过迭代预测最可能的下一个单词来自动完成序列。GPT 模型家族属于此类。在此架构中为给定令牌计算的表示仅取决于左侧上下文。这通常被称为 因果或自回归注意。
编码器-解码器
这些用于建模从一个文本序列到另一个文本序列的复杂映射;它们适用于机器翻译和摘要任务。除了我们看到的结合了编码器和解码器的 Transformer 架构之外,BART 和 T5 模型也属于这一类。
笔记
实际上,仅解码器与仅编码器架构的应用程序之间的区别有点模糊。例如,像 GPT 系列中的那些仅解码器模型可以准备用于翻译等任务,这些任务通常被认为是序列到序列的任务。类似地,像 BERT 这样的仅编码器模型可以应用于通常与编码器-解码器或仅解码器模型相关联的摘要任务。1
现在您已经对 Transformer 架构有了一个高层次的了解,让我们仔细看看编码器的内部工作原理。
正如我们之前看到的,transformer 的编码器由许多彼此相邻堆叠的编码器层组成。如图 3-2 所示,每个编码器层接收一个嵌入序列,并通过以下子层馈送它们:
多头自注意力层
应用于每个输入嵌入的全连接前馈层
每个编码器层的输出嵌入与输入具有相同的大小,我们很快就会看到编码器堆栈的主要作用是“更新”输入嵌入以生成编码序列中的一些上下文信息的表示。例如,如果“keynote”或“phone”靠近它,“apple”这个词将被更新为更“company-like”而不是“fruit-like”。
图 3-2。放大编码器层
这些子层中的每一个还使用跳过连接和层归一化,这是有效训练深度神经网络的标准技巧。但要真正了解变压器的工作原理,我们必须更深入。让我们从最重要的构建块开始:自注意力层。
正如我们在第 1 章中所讨论的,注意力是一种机制,它允许神经网络为序列中的每个元素分配不同的权重或“注意力”。对于文本序列,元素是令牌嵌入,就像我们在第 2 章中遇到的那样,其中每个令牌都映射到某个固定维度的向量。例如,在 BERT 中,每个标记都表示为一个 768 维的向量。自注意力的“自我”部分指的是这些权重是为同一集合中的所有隐藏状态计算的,例如,编码器的所有隐藏状态。相比之下,与循环模型相关的注意力机制涉及在给定的解码时间步计算每个编码器隐藏状态与解码器隐藏状态的相关性。
self-attention 背后的主要思想是,我们可以使用整个序列来计算每个嵌入的加权平均值,而不是对每个标记使用固定 嵌入。另一种表述方式是说,给定一系列令牌嵌入 X1,...,Xn, self-attention 产生一系列新的嵌入X1',...,Xn' 其中每个X一世'是所有的线性组合Xj:
系数在j一世被称为注意力权重并被归一化,使得∑j在j一世=1. 要了解为什么平均令牌嵌入可能是一个好主意,请考虑当您看到“苍蝇”一词时会想到什么。你可能会想到恼人的昆虫,但如果给你更多的上下文,比如“时间像箭一样飞逝”,那么你会意识到“苍蝇”是指动词。类似地,我们可以通过组合不同比例的所有令牌嵌入来创建包含此上下文的“苍蝇”表示,也许通过分配更大的权重 在j一世到“时间”和“箭头”的标记嵌入。以这种方式生成的嵌入称为上下文嵌入,并且早于 ELMo 等语言模型中转换器的发明。2该过程的示意图 如图 3-3所示,其中我们说明了如何根据上下文,通过自我注意生成两种不同的“苍蝇”表示。
图 3-3。图表显示了自我注意如何将原始令牌嵌入(上)更新为上下文化嵌入(下)以创建包含整个序列信息的表示
现在让我们看看如何计算注意力权重。
有几种方法可以实现自注意力层,但最常见的一种是缩放点积注意力,来自介绍 Transformer 架构的论文。3实施这一机制需要四个主要步骤:
将每个令牌嵌入投影到三个向量中,称为查询、键和值。
计算注意力分数。我们使用相似度函数来确定查询和关键向量之间的相关程度。顾名思义,缩放点积注意力的相似性函数是点积,使用嵌入的矩阵乘法有效计算。相似的查询和键将具有较大的点积,而那些没有太多共同点的查询和键将几乎没有重叠。这一步的输出称为注意力分数,对于具有n 个输入标记的序列,有一个对应的n×n注意分数矩阵。
计算注意力权重。点积通常可以产生任意大的数字,这会破坏训练过程的稳定性。为了解决这个问题,首先将注意力分数乘以一个缩放因子以标准化它们的方差,然后用一个 softmax 标准化以确保所有列值总和为 1。生成的 n × n矩阵现在包含所有注意力权重, Wji
我们可以使用一个名为BertViz for Jupyter的漂亮库来可视化如何计算注意力权重。该库提供了几个函数,可用于可视化 Transformer 模型中注意力的不同方面。为了可视化注意力权重,我们可以使用该neuron_view
模块跟踪权重的计算,以显示查询和关键向量如何组合以产生最终权重。由于 BertViz 需要利用模型的注意力层,我们将使用来自 BertViz 的模型类实例化我们的 BERT 检查点,然后使用该 show()
函数为特定的编码器层和注意力头生成交互式可视化。请注意,您需要单击左侧的“+”来激活注意力可视化
from transformers import AutoTokenizer
from bertviz.transformers_neuron_view import BertModel
from bertviz.neuron_view import show
model_ckpt = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
model = BertModel.from_pretrained(model_ckpt)
text = "time flies like an arrow"
show(model, "bert", tokenizer, text, display_mode="light", layer=0, head=8)
从可视化中,我们可以看到查询和关键向量的值表示为垂直波段,其中每个波段的强度对应于幅度。连接线根据标记之间的注意力进行加权,我们可以看到“flies”的查询向量与“arrow”的关键向量有最强的重叠。
揭开查询、键和值的神秘面纱
查询、键和值向量的概念在您第一次遇到它们时可能看起来有点神秘。他们的名字受到信息检索系统的启发,但我们可以通过一个简单的类比来激发他们的含义。想象一下,您在超市购买晚餐所需的所有食材。你有这道菜的食谱,每一种所需的配料都可以被认为是一个查询。当您扫描货架时,您会查看标签(键)并检查它们是否与您列表中的成分匹配(相似性函数)。如果您有匹配项,则从架子上取出物品(价值)。
在这个类比中,每一个与成分匹配的标签,你只能得到一个杂货。Self-attention 是一个更抽象和“平滑”的版本:超市中的每个标签都与成分匹配到每个键与查询匹配的程度。因此,如果您的清单包括一打鸡蛋,那么您最终可能会抓到 10 个鸡蛋、一个煎蛋卷和一个鸡翅。
让我们通过实现计算缩放点积注意力的操作图来更详细地了解这个过程,如图 3-4所示。
图 3-4。缩放点积注意力中的操作
本章我们将使用 PyTorch 实现 Transformer 架构,但 TensorFlow 中的步骤类似。我们在表 3-1中提供了两个框架中最重要的功能之间的映射。
PyTorch | TensorFlow(Keras) | Creates/implements |
---|---|---|
|
|
A dense neural network layer |
|
|
The building blocks of models |
|
|
A dropout layer |
|
|
Layer normalization |
|
|
An embedding layer |
|
|
The Gaussian Error Linear Unit activation function |
|
|
Batched matrix multiplication |
|
|
The model’s forward pass |
我们需要做的第一件事是对文本进行标记,所以让我们使用标记器来提取输入 ID
inputs = tokenizer(text, return_tensors="pt", add_special_tokens=False)
inputs.input_ids
tensor([[ 2051, 10029, 2066, 2019, 8612]])
正如我们在第 2 章中看到的,句子中的每个标记都已映射到标记器词汇表中的唯一 ID。为了简单起见,我们还通过设置排除了 [CLS]
和[SEP]
标记add_special_tokens=False
。接下来,我们需要创建一些密集的嵌入。在这种情况下,密集意味着嵌入中的每个条目都包含一个非零值。相比之下,我们在第 2 章中看到的 one-hot 编码是 稀疏的,因为除了一个之外的所有条目都是零。在 PyTorch 中,我们可以通过使用一个torch.nn.Embedding
充当每个输入 ID 的查找表的层来做到这一点
from torch import nn
from transformers import AutoConfig
config = AutoConfig.from_pretrained(model_ckpt)
token_emb = nn.Embedding(config.vocab_size, config.hidden_size)
token_emb
Embedding(30522, 768)
在这里,我们使用AutoConfig
该类来加载 与检查点关联的config.jsonbert-base-uncased
文件。
Transformers 中,每个检查点都分配有一个配置文件,该文件指定各种超参数,例如vocab_size
和hidden_size
,在我们的示例中,每个输入 ID 将映射到存储在 中的 30,522 个嵌入向量之一nn.Embedding
,每个向量的大小为 768。AutoConfig
该类还存储额外的元数据,例如标签名称,用于格式化模型的预测。
请注意,此时的令牌嵌入与它们的上下文无关。这意味着同音异义词(拼写相同但含义不同的单词),如前面示例中的“flies”,具有相同的表示。后续注意力层的作用是混合这些标记嵌入,以消除歧义,并告知每个标记的表示及其上下文的内容。
现在我们有了查找表,我们可以通过输入输入 ID 来生成嵌入
inputs_embeds = token_emb(inputs.input_ids)
inputs_embeds.size()
torch.Size([1, 5, 768])
这给了我们一个张量的形状[batch_size, seq_len, hidden_dim]
,就像我们在第 2 章中看到的那样。我们将推迟位置编码,因此下一步是创建查询、键和值向量,并使用点积作为相似度函数来计算注意力分数
import torch
from math import sqrt
query = key = value = inputs_embeds
dim_k = key.size(-1)
scores = torch.bmm(query, key.transpose(1,2)) / sqrt(dim_k)
scores.size()
torch.Size([1, 5, 5])
这创造了一个5×5批次中每个样本的注意力得分矩阵。稍后我们将看到查询、键和值向量是通过应用独立的权重矩阵生成的在问,ķ,在到嵌入,但现在为了简单起见,我们保持它们相等。在缩放的点积注意力中,点积按嵌入向量的大小进行缩放,这样我们在训练期间就不会得到太多的大数字,这会导致我们接下来将应用的 softmax 饱和。
笔记
该
torch.bmm()
函数执行批处理矩阵-矩阵乘积,简化了查询和关键向量具有形状的注意力分数的计算[batch_size, seq_len,
hidden_dim
]
。如果我们忽略批量维度,我们可以通过简单地将关键张量转置为具有形状[hidden_dim, seq_len]
,然后使用矩阵乘积将所有点积收集到一个[seq_len, seq_len]
矩阵中来计算每个查询和关键向量之间的点积。由于我们想对批次中的所有序列独立执行此操作,因此我们使用torch.bmm()
,它采用两批矩阵并将第一批中的每个矩阵与第二批中的相应矩阵相乘。
现在让我们应用 softmax
import torch.nn.functional as F
weights = F.softmax(scores, dim=-1)
weights.sum(dim=-1)
tensor([[1., 1., 1., 1., 1.]], grad_fn=)
最后一步是将注意力权重乘以值
attn_outputs = torch.bmm(weights, value)
attn_outputs.shape
torch.Size([1, 5, 768])
就是这样——我们已经完成了实现简化形式的自我注意的所有步骤!请注意,整个过程只是两个矩阵乘法和一个 softmax,因此您可以将“self-attention”视为一种奇特的平均形式。
让我们将这些步骤包装成一个我们以后可以使用的函数
def scaled_dot_product_attention(query, key, value):
dim_k = query.size(-1)
scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k)
weights = F.softmax(scores, dim=-1)
return torch.bmm(weights, value)
我们具有相等查询和键向量的注意力机制将为上下文中的相同单词分配一个非常大的分数,尤其是当前单词本身:查询与自身的点积始终为 1。但在实践中,上下文中的互补词比相同的词更能说明一个词——例如,通过结合来自“时间”和“箭头”的信息来更好地定义“苍蝇”的含义,而不是通过再次提及“苍蝇”来定义。我们如何促进这种行为?
让我们允许模型通过使用三个不同的线性投影将我们的初始令牌向量投影到三个不同的空间中,为令牌的查询、键和值创建一组不同的向量。
在我们的简单示例中,我们仅“按原样”使用嵌入来计算注意力分数和权重,但这远非全部。在实践中,自注意力层对每个嵌入应用三个独立的线性变换来生成查询、键和值向量。这些转换投影嵌入,每个投影都带有自己的一组可学习参数,这使得自注意力层能够专注于序列的不同语义方面。
事实证明,拥有多组线性投影也是有益的,每组代表一个所谓的注意力头。生成的多头注意力层如图 3-5 所示. 但是为什么我们需要不止一个注意力头呢?原因是一个头部的 softmax 倾向于主要关注相似性的一个方面。拥有多个头部可以让模型同时关注多个方面。例如,一个人可以专注于主谓交互,而另一个人可以找到附近的形容词。显然,我们不会将这些关系手工制作到模型中,它们是从数据中完全学习的。如果您熟悉计算机视觉模型,您可能会看到与卷积神经网络中的过滤器的相似之处,其中一个过滤器可以负责检测人脸,另一个过滤器可以在图像中找到汽车的轮子。
图 3-5。多头注意力
让我们首先编写一个注意力头来实现这一层
class AttentionHead(nn.Module):
def __init__(self, embed_dim, head_dim):
super().__init__()
self.q = nn.Linear(embed_dim, head_dim)
self.k = nn.Linear(embed_dim, head_dim)
self.v = nn.Linear(embed_dim, head_dim)
def forward(self, hidden_state):
attn_outputs = scaled_dot_product_attention(
self.q(hidden_state), self.k(hidden_state), self.v(hidden_state))
return attn_outputs
在这里,我们初始化了三个独立的线性层,它们将矩阵乘法应用于嵌入向量以产生 shape 的张量[batch_size, seq_len, head_dim]
,其中head_dim
是我们投影到的维数。尽管head_dim
不必小于标记 ( embed_dim
) 的嵌入维数,但在实践中,它被选择为 的倍数, embed_dim
以便跨每个头的计算是恒定的。例如,BERT 有 12 个注意力头,所以每个头的维度为 768/12=64.
现在我们有了一个注意力头,我们可以连接每个注意力头的输出来实现完整的多头注意力层
class MultiHeadAttention(nn.Module):
def __init__(self, config):
super().__init__()
embed_dim = config.hidden_size
num_heads = config.num_attention_heads
head_dim = embed_dim // num_heads
self.heads = nn.ModuleList(
[AttentionHead(embed_dim, head_dim) for _ in range(num_heads)]
)
self.output_linear = nn.Linear(embed_dim, embed_dim)
def forward(self, hidden_state):
x = torch.cat([h(hidden_state) for h in self.heads], dim=-1)
x = self.output_linear(x)
return x
请注意,来自注意力头的连接输出也通过最终的线性层馈送,以产生[batch_size, seq_len,
hidden_dim
]
适合下游前馈网络的形状的输出张量。为了确认,让我们看看多头注意力层是否产生了我们输入的预期形状。在初始化MultiHeadAttentio
模块时,我们传递了我们之前从预训练的 BERT 模型加载的配置。这确保我们使用与 BERT 相同的设置:
multihead_attn = MultiHeadAttention(config)
attn_output = multihead_attn(inputs_embeds)
attn_output.size()
torch.Size([1, 5, 768])
有用!为了结束注意力这一节,让我们再次使用 BertViz 来可视化“苍蝇”这个词的两种不同用法的注意力。在这里,我们可以head_view()
通过计算预训练检查点的注意力并指示句子边界的位置来使用 BertViz 的函数
from bertviz import head_view
from transformers import AutoModel
model = AutoModel.from_pretrained(model_ckpt, output_attentions=True)
sentence_a = "time flies like an arrow"
sentence_b = "fruit flies like a banana"
viz_inputs = tokenizer(sentence_a, sentence_b, return_tensors='pt')
attention = model(**viz_inputs).attentions
sentence_b_start = (viz_inputs.token_type_ids == 0).sum(dim=1)
tokens = tokenizer.convert_ids_to_tokens(viz_inputs.input_ids[0])
head_view(attention, tokens, sentence_b_start, heads=[8])
该可视化将注意力权重显示为连接正在更新嵌入的令牌(左)与正在关注的每个单词(右)的线。线条的强度表示注意力权重的强度,暗线表示接近 1 的值,暗线表示接近 0 的值
在这个例子中,输入由两个句子组成,and[CLS]
和 [SEP]
标记是我们在第 2 章中遇到的 BERT 标记器中的特殊标记。我们可以从可视化中看到的一件事是,属于同一句子的单词之间的注意力权重最强,这表明 BERT 可以判断它应该注意同一句子中的单词。但是,对于“flies”这个词,我们可以看到 BERT 在第一句中将“arrow”标识为重要,在第二句中将“fruit”和“banana”标识为重要。这些注意力权重允许模型区分“苍蝇”作为动词或名词的使用,具体取决于它发生的上下文!
现在我们已经讨论了注意力,让我们来看看实现编码器层的缺失部分:位置前馈网络。
编码器和解码器中的前馈子层只是一个简单的两层全连接神经网络,但有一个转折:它不是将整个嵌入序列作为单个向量处理,而是独立处理每个嵌入。因此,该层通常被称为位置前馈层. 您可能还会看到它被称为内核大小为 1 的一维卷积,通常由具有计算机视觉背景的人使用(例如,OpenAI GPT 代码库使用此命名法)。文献中的一条经验法则是,第一层的隐藏大小是嵌入大小的四倍,最常用的是 GELU 激活函数。这是假设大部分容量和记忆发生的地方,也是扩大模型时最常被扩大的部分。我们可以简单地实现它 nn.Module
,如下所示
class FeedForward(nn.Module):
def __init__(self, config):
super().__init__()
self.linear_1 = nn.Linear(config.hidden_size, config.intermediate_size)
self.linear_2 = nn.Linear(config.intermediate_size, config.hidden_size)
self.gelu = nn.GELU()
self.dropout = nn.Dropout(config.hidden_dropout_prob)
def forward(self, x):
x = self.linear_1(x)
x = self.gelu(x)
x = self.linear_2(x)
x = self.dropout(x)
return x
请注意,前馈层(例如nn.Linear
)通常应用于形状为 的张量(batch_size, input_dim)
,其中它独立地作用于批次维度的每个元素。这实际上适用于除最后一个维度之外的任何维度,因此当我们传递一个形状张量时, (batch_size, seq_len, hidden_dim)
该层将独立应用于批次和序列的所有标记嵌入,这正是我们想要的。让我们通过传递注意力输出来测试它
feed_forward = FeedForward(config)
ff_outputs = feed_forward(attn_outputs)
ff_outputs.size()
torch.Size([1, 5, 768])
我们现在拥有创建一个成熟的 Transformer 编码器层的所有要素!剩下要做的唯一决定是在哪里放置跳过连接和层规范化。让我们看看这如何影响模型架构。
如前所述,Transformer 架构利用层规范化和跳过连接。前者将批次中的每个输入标准化为均值为零且方差为单位。跳过连接将张量传递给模型的下一层而不进行处理,并将其添加到处理后的张量中。在将层归一化放置在转换器的编码器或解码器层中时,文献中采用了两种主要选择:
后层标准化
这是 Transformer 论文中使用的安排;它将层规范化放在跳过连接之间。这种安排很难从头开始训练,因为梯度可能会发散。出于这个原因,你会经常看到一个叫做学习率预热的概念,在训练过程中,学习率从一个很小的值逐渐增加到某个最大值。
预层归一化
这是文献中最常见的安排;它将层标准化放置在跳过连接的范围内。这在训练期间往往更加稳定,并且通常不需要任何学习率预热。
图 3-6说明了这两种安排之间的区别 。
图 3-6。变压器编码器层中层归一化的不同安排
我们将使用第二种安排,因此我们可以简单地将我们的构建块粘在一起,如下所示
class TransformerEncoderLayer(nn.Module):
def __init__(self, config):
super().__init__()
self.layer_norm_1 = nn.LayerNorm(config.hidden_size)
self.layer_norm_2 = nn.LayerNorm(config.hidden_size)
self.attention = MultiHeadAttention(config)
self.feed_forward = FeedForward(config)
def forward(self, x):
# Apply layer normalization and then copy input into query, key, value
hidden_state = self.layer_norm_1(x)
# Apply attention with a skip connection
x = x + self.attention(hidden_state)
# Apply feed-forward layer with a skip connection
x = x + self.feed_forward(self.layer_norm_2(x))
return x
现在让我们用我们的输入嵌入来测试它
encoder_layer = TransformerEncoderLayer(config)
inputs_embeds.shape, encoder_layer(inputs_embeds).size()
(torch.Size([1, 5, 768]), torch.Size([1, 5, 768]))
我们现在已经从头开始实现了我们的第一个转换器编码器层!但是,我们设置编码器层的方式有一个警告:它们 对标记的位置完全不变。由于多头注意力层实际上是一个花哨的加权和,因此丢失了有关令牌位置的信息。4
幸运的是,有一个简单的技巧可以使用位置嵌入来合并位置信息。让我们来看看。
位置嵌入基于一个简单但非常有效的想法:使用排列在向量中的与位置相关的值模式来增强令牌嵌入。如果模式是每个位置的特征,则每个堆栈中的注意力头和前馈层可以学习将位置信息合并到它们的转换中。
有几种方法可以实现这一点,最流行的方法之一是使用可学习的模式,尤其是在预训练数据集足够大的情况下。这与令牌嵌入的工作方式完全相同,但使用位置索引而不是令牌 ID 作为输入。使用这种方法,在预训练期间学习了一种编码标记位置的有效方法。
让我们创建一个自定义模块,该模块结合了将 投影到密集隐藏状态 Embeddings
的令牌嵌入层以及对input_id
执行相同操作的位置嵌入position_id
。生成的嵌入只是两个嵌入的总和:
class Embeddings(nn.Module):
def __init__(self, config):
super().__init__()
self.token_embeddings = nn.Embedding(config.vocab_size,
config.hidden_size)
self.position_embeddings = nn.Embedding(config.max_position_embeddings,
config.hidden_size)
self.layer_norm = nn.LayerNorm(config.hidden_size, eps=1e-12)
self.dropout = nn.Dropout()
def forward(self, input_ids):
# Create position IDs for input sequence
seq_length = input_ids.size(1)
position_ids = torch.arange(seq_length, dtype=torch.long).unsqueeze(0)
# Create token and position embeddings
token_embeddings = self.token_embeddings(input_ids)
position_embeddings = self.position_embeddings(position_ids)
# Combine token and position embeddings
embeddings = token_embeddings + position_embeddings
embeddings = self.layer_norm(embeddings)
embeddings = self.dropout(embeddings)
return embeddings
embedding_layer = Embeddings(config)
embedding_layer(inputs.input_ids).size()
torch.Size([1, 5, 768])
我们看到嵌入层现在为每个标记创建了一个单一的、密集的嵌入。
虽然可学习的位置嵌入很容易实现并被广泛使用,但还有一些替代方案:
绝对位置表示
Transformer 模型可以使用由调制的正弦和余弦信号组成的静态模式来编码令牌的位置。当没有大量可用数据时,这种方法尤其有效。
相对位置表示
尽管绝对位置很重要,但可以说在计算嵌入时,周围的标记是最重要的。相对位置表示遵循这种直觉并编码标记之间的相对位置。这不能通过在开始时引入一个新的相对嵌入层来设置,因为每个标记的相对嵌入会根据我们关注的序列的位置而变化。取而代之的是,注意力机制本身被修改了附加术语,这些术语考虑了标记之间的相对位置。DeBERTa 等模型使用这种表示。5
现在让我们通过构建将嵌入与编码器层相结合的完整转换器编码器将所有这些放在一起
class TransformerEncoder(nn.Module):
def __init__(self, config):
super().__init__()
self.embeddings = Embeddings(config)
self.layers = nn.ModuleList([TransformerEncoderLayer(config)
for _ in range(config.num_hidden_layers)])
def forward(self, x):
x = self.embeddings(x)
for layer in self.layers:
x = layer(x)
return x
让我们检查一下编码器的输出形状
encoder = TransformerEncoder(config)
encoder(inputs.input_ids).size()
torch.Size([1, 5, 768])
我们可以看到我们为批次中的每个令牌获得了一个隐藏状态。这种输出格式使架构非常灵活,我们可以轻松地将其调整为各种应用程序,例如在掩码语言建模中预测丢失的标记或在问答中预测答案的开始和结束位置。在下一节中,我们将看到如何构建一个类似于我们在 第 2 章中使用的分类器。
Transformer 模型通常分为独立于任务的主体和特定于任务的头。我们将在第 4 章查看Transformer的设计模式时再次遇到这种模式。到目前为止,我们构建的是主体,因此如果我们希望构建文本分类器,我们需要将分类头附加到该主体。我们对每个令牌都有一个隐藏状态,但我们只需要进行一次预测。有几种方法可以解决这个问题。传统上,此类模型中的第一个标记用于预测,我们可以附加一个 dropout 和一个线性层来进行分类预测。以下类扩展了现有的序列 分类编码器
class TransformerForSequenceClassification(nn.Module):
def __init__(self, config):
super().__init__()
self.encoder = TransformerEncoder(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(config.hidden_size, config.num_labels)
def forward(self, x):
x = self.encoder(x)[:, 0, :] # select hidden state of [CLS] token
x = self.dropout(x)
x = self.classifier(x)
return x
在初始化模型之前,我们需要定义我们想要预测的类数
config.num_labels = 3
encoder_classifier = TransformerForSequenceClassification(config)
encoder_classifier(inputs.input_ids).size()
torch.Size([1, 3])
这正是我们一直在寻找的。对于批处理中的每个示例,我们得到输出中每个类的非标准化 logits。这对应于我们在 第 2 章中用于检测推文中的情绪的 BERT 模型。
这结束了我们对编码器的分析,以及我们如何将它与特定任务的头部结合起来。现在让我们将注意力(双关语!)放到解码器上。
如图3-7所示,解码器和编码器的主要区别在于解码器有两个注意力子层:
Masked 多头自注意力层
确保我们在每个时间步生成的标记仅基于过去的输出和当前预测的标记。没有这个,解码器可以通过简单地复制目标翻译在训练期间作弊;屏蔽输入可确保任务并非微不足道。
编码器-解码器注意层
对编码器堆栈的输出键和值向量执行多头注意力,解码器的中间表示充当查询。6通过这种方式,编码器-解码器注意力层学习如何关联来自两个不同序列的标记,例如两种不同的语言。解码器可以访问每个块中的编码器键和值。
让我们看一下我们需要进行的修改,以在我们的自注意力层中包含掩码,并将编码器-解码器注意力层的实现留作家庭作业问题。使用掩码自注意力的技巧是引入一个掩码矩阵,其下方对角线为 1,上方为 0
seq_len = inputs.input_ids.size(-1)
mask = torch.tril(torch.ones(seq_len, seq_len)).unsqueeze(0)
mask[0]
tensor([[1., 0., 0., 0., 0.], [1., 1., 0., 0., 0.], [1., 1., 1., 0., 0.], [1., 1., 1., 1., 0.], [1., 1., 1., 1., 1.]])
在这里,我们使用 PyTorch 的tril()
函数来创建下三角矩阵。Tensor.masked_fill()
一旦我们有了这个掩码矩阵,我们就可以通过用负无穷大替换所有的零来防止每个注意力头偷看未来的标记
scores.masked_fill(mask == 0, -float("inf"))
tensor([[[26.8082, -inf, -inf, -inf, -inf], [-0.6981, 26.9043, -inf, -inf, -inf], [-2.3190, 1.2928, 27.8710, -inf, -inf], [-0.5897, 0.3497, -0.3807, 27.5488, -inf], [ 0.5275, 2.0493, -0.4869, 1.6100, 29.0893]]], grad_fn=)
图 3-7。放大变压器解码器层
通过将上限值设置为负无穷大,我们保证一旦我们对分数进行 softmax,注意力权重都为零,因为和-∞=0(回想一下,softmax 计算归一化指数)。我们可以很容易地包含这种屏蔽行为,只需对我们在本章前面实现的缩放点积注意力函数进行微小的更改
def scaled_dot_product_attention(query, key, value, mask=None):
dim_k = query.size(-1)
scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k)
if mask is not None:
scores = scores.masked_fill(mask == 0, float("-inf"))
weights = F.softmax(scores, dim=-1)
return weights.bmm(value)
从这里开始构建解码器层是一件简单的事情;我们向读者指出 Andrej Karpathy 对minGPT的出色实现以了解详细信息。
我们在这里为您提供了很多技术信息,但现在您应该对 Transformer 架构的每个部分的工作原理有了很好的了解。在我们继续为比文本分类更高级的任务构建模型之前,让我们先退后一步,看看不同 Transformer 模型的前景以及它们之间的关系,从而完成本章。
揭开编码器-解码器注意力的神秘面纱
让我们看看我们是否可以阐明编码器-解码器注意力的奥秘。想象一下,您(解码器)正在课堂上参加考试。您的任务是根据前面的单词(解码器输入)预测下一个单词,这听起来很简单,但非常困难(自己尝试并预测本书段落中的下一个单词)。幸运的是,您的邻居(编码器)拥有全文。不幸的是,他们是一名外国交换生,而且课文是他们的母语。你是狡猾的学生,无论如何你都会想办法作弊。你画了一个小漫画来说明你已经拥有的文本(查询)并将它交给你的邻居。他们试图找出与该描述(键)匹配的段落,绘制描述该段落后面的单词(值)的卡通,然后将其传回给您。有了这个系统,
正如您在本章中所见,transformer 模型有三种主要架构:编码器、解码器和编码器-解码器。早期 Transformer 模型的初步成功引发了模型开发的寒武纪爆发,因为研究人员在不同大小和性质的各种数据集上构建模型,使用新的预训练目标,并调整架构以进一步提高性能。尽管模型的动物园仍在快速增长,但它们仍然可以分为这三类。
在本节中,我们将简要概述每个类别中最重要的变压器模型。让我们先来看看变压器的家谱。
随着时间的推移,三种主要架构中的每一种都经历了自己的演变。这在图 3-8中进行了说明,其中显示了一些最突出的模型及其后代。
图 3-8。一些最突出的变压器架构概述
Transformers 中包含超过 50 种不同的架构,这个家族树绝不提供所有现有架构的完整概述:它只是突出了一些架构里程碑。我们已经在本章中深入介绍了原始的 Transformer 架构,所以让我们从编码器分支开始仔细看看一些关键的后代。
第一个基于 Transformer 架构的仅编码器模型是 BERT。在发布时,它在流行的 GLUE 基准测试中优于所有最先进的模型,该基准测试 在不同难度的多个任务中测量自然语言理解 (NLU) 7 。随后,BERT 的预训练目标和架构进行了调整,以进一步提高性能。仅编码器模型仍然在 NLU 任务(如文本分类、命名实体识别和问答)的研究和行业中占据主导地位。让我们简要了解一下 BERT 模型及其变体:
BERT
BERT 预训练有两个目标,即预测文本中的掩码标记并确定一个文本段落是否可能跟随另一个。8前一个任务称为掩码语言建模(MLM),后 一个任务称为下一句预测(NSP)。
DistilBERT
尽管 BERT 提供了很好的结果,但它的大小可能使其难以部署在需要低延迟的环境中。通过在预训练期间使用一种称为知识蒸馏的技术,DistilBERT 实现了 BERT 97% 的性能,同时使用的内存减少了 40%,速度提高了 60%。9您可以在第 8 章中找到有关知识蒸馏的更多详细信息 。
RoBERTa
BERT 发布后的一项研究表明,通过修改预训练方案可以进一步提高其性能。RoBERTa 的训练时间更长,批量更大,训练数据更多,它放弃了 NSP 任务。10与原始 BERT 模型相比,这些变化显着提高了其性能。
XLM
在跨语言语言模型 (XLM) 的工作中探索了构建多语言模型的几个预训练目标,11包括来自 GPT 类模型的自回归语言建模和来自 BERT 的 MLM。此外,XLM 预训练论文的作者介绍了翻译语言建模 (TLM),它是 MLM 对多语言输入的扩展。通过对这些预训练任务进行试验,他们在多个多语言 NLU 基准测试以及翻译任务上取得了最先进的结果。
XLM-RoBERTa
继 XLM 和 RoBERTa 的工作之后,XLM-RoBERTa 或 XLM-R 模型通过大规模升级 训练数据使多语言预训练更进一步。12使用 Common Crawl 语料库,其开发人员创建了一个包含 2.5 TB 文本的数据集;然后他们在这个数据集上用 MLM 训练了一个编码器。由于数据集仅包含没有平行文本(即翻译)的数据,因此 XLM 的 TLM 目标被删除。这种方法在很大程度上击败了 XLM 和多语言 BERT 变体,尤其是在低资源 语言上。
ALBERT
ALBERT 模型引入了三项更改以使编码器架构更高效。13首先,它将令牌嵌入维度与隐藏维度解耦,从而允许嵌入维度较小,从而节省参数,尤其是在词汇量变大时。其次,所有层共享相同的参数,这进一步减少了有效参数的数量。最后,将 NSP 目标替换为句子排序预测:模型需要预测两个连续句子的顺序是否交换,而不是预测它们是否完全属于一起。这些变化使得用更少的参数训练更大的模型成为可能,并在 NLU 任务上达到卓越的性能。
ELECTRA
标准 MLM 预训练目标的一个限制是,在每个训练步骤中,仅更新掩码标记的表示,而不会更新其他输入标记。为了解决这个问题,ELECTRA 使用了一种双模型方法:14第一个模型(通常很小)类似于标准的掩码语言模型并预测掩码标记。第二个模型,称为 鉴别器,然后负责预测第一个模型输出中的哪些标记最初被屏蔽。因此,判别器需要对每个token进行二进制分类,这使得训练效率提高了30倍。对于下游任务,鉴别器像标准 BERT 模型一样进行微调。
DeBERTa
DeBERTa 模型引入了两个架构变化。15首先,每个标记表示为两个向量:一个用于内容,另一个用于相对位置。通过将 token 的内容与它们的相对位置分离,self-attention 层可以更好地模拟附近 token 对的依赖关系。另一方面,单词的绝对位置也很重要,尤其是对于解码而言。出于这个原因,在令牌解码头的 softmax 层之前添加了一个绝对位置嵌入。DeBERTa 是第一个在 SuperGLUE 基准测试中击败人类基线的模型(作为一个整体),16是一个更困难的 GLUE 版本,由用于测量 NLU 性能的几个子任务组成。
现在我们已经强调了一些主要的仅编码器架构,让我们看一下仅解码器模型。
变压器解码器模型的进展在很大程度上是由 OpenAI 带头的。这些模型非常擅长预测序列中的下一个单词,因此主要用于文本生成任务(有关详细信息,请参阅第 5 章)。通过使用更大的数据集并将语言模型扩展到越来越大的大小,他们的进步得到了推动。让我们来看看这些引人入胜的生成模型的演变:
GPT
GPT 的引入结合了 NLP 中的两个关键思想:17新颖高效的 Transformer 解码器架构和迁移学习。在该设置中,模型是通过根据之前的单词预测下一个单词来进行预训练的。该模型在 BookCorpus 上进行训练,在分类等下游任务上取得了很好的效果。
GPT-2
受简单且可扩展的预训练方法成功的启发,原始模型和训练集被放大以产生 GPT-2。18该模型能够生成长序列的连贯文本。由于担心可能的误用,该模型分阶段发布,先发布较小的模型,然后发布完整的模型。
CTRL
像 GPT-2 这样的模型可以继续输入序列(也称为提示)。但是,用户几乎无法控制生成序列的样式。条件转换器 语言 (CTRL) 模型通过在序列开头添加“控制标记”来解决此问题。19这些允许控制生成文本的样式,从而实现多样化的生成。
GPT-3
在成功将 GPT 扩展到 GPT-2 之后,对不同尺度的语言模型行为的全面分析表明,存在简单的幂律来控制计算、数据集大小、模型大小和语言性能之间的关系模型。20受这些见解的启发,GPT-2 放大了 100 倍以产生 GPT-3, 21具有 1750 亿个参数。除了能够生成令人印象深刻的真实文本段落外,该模型还展示了小样本学习能力:通过一些新任务的示例,例如将文本转换为代码,该模型能够完成新示例的任务。OpenAI 并没有开源这个模型,而是通过OpenAI API提供了一个接口。
GPT-Neo/GPT-J-6B
GPT-Neo 和 GPT-J-6B 是由EleutherAI训练的类似 GPT 的模型,EleutherAI是一组旨在重新创建和发布 GPT-3 比例模型的研究人员。22当前模型是完整 1750 亿参数模型的较小变体,具有 1.3、2.7 和 60 亿参数,与 OpenAI 提供的较小 GPT-3 模型具有竞争力。
Transformers 生命树中的最后一个分支是编码器-解码器模型。让我们来看看。
尽管使用单个编码器或解码器堆栈构建模型已变得很普遍,但 Transformer 架构的几个编码器-解码器变体在 NLU 和 NLG 领域都有新的应用:
T5
T5 模型通过将所有 NLU 和 NLG 任务转换为文本到文本的任务来统一它们。23所有任务都被定义为序列到序列的任务,采用编码器-解码器架构是很自然的。例如,对于文本分类问题,这意味着文本被用作编码器的输入,而解码器必须将标签生成为普通文本而不是类。我们将在 第 6 章中更详细地讨论这一点。T5 架构使用原始的 Transformer 架构。使用大型爬网 C4 数据集,该模型通过掩码语言建模以及 SuperGLUE 任务进行预训练,将所有这些任务转换为文本到文本的任务。具有 110 亿个参数的最大模型在多个基准测试中产生了最先进的结果。
BART
BART 在编码器-解码器架构中结合了 BERT 和 GPT 的预训练过程。24输入序列经历了几种可能的转换之一,从简单的掩码到句子排列、标记删除和文档旋转。这些修改后的输入通过编码器传递,解码器必须重建原始文本。这使得模型更加灵活,因为它可以用于 NLU 和 NLG 任务,并且在两者上都实现了最先进的性能。
M2M-100
传统上,为一个语言对和翻译方向构建翻译模型。自然,这不适用于多种语言,此外,语言对之间可能存在共享知识,可用于稀有语言之间的翻译。M2M-100 是第一个可以在 100 种语言之间进行翻译的翻译模型。25这允许在稀有语言和代表性不足的语言之间进行高质量的翻译。该模型使用前缀标记(类似于特殊[CLS]
标记)来指示源语言和目标 语言。
BigBird
由于注意力机制的二次内存要求,Transformer 模型的一个主要限制是最大上下文大小。BigBird 通过使用线性扩展的稀疏注意力形式解决了这个问题。26这允许将上下文从大多数 BERT 模型中的 512 个标记大幅扩展到 BigBird 中的 4,096 个。这在需要保存长依赖关系的情况下特别有用,例如在文本摘要中。
我们在本节中看到的所有模型的预训练检查点都可以在Hugging Face Hub上使用,并且可以使用 Transformers 对您的用例进行微调,如前一章所述。
在本章中,我们从 Transformer 架构的核心开始,深入研究了 self-attention,随后我们添加了所有必要的部分来构建一个 Transformer 编码器模型。我们为标记和位置信息添加了嵌入层,我们构建了一个前馈层来补充注意力头,最后我们在模型主体中添加了一个分类头来进行预测。我们还查看了 Transformer 架构的解码器端,并以最重要的模型架构概述结束本章。