AGI 之 【Hugging Face】 的【Transformer】的 [ Transformer 架构 ] / [ 编码器 ]的简单整理
目录
[AGI 之 【Hugging Face】 的【Transformer】的 [ Transformer 架构 ] / [ 编码器 ]的简单整理](#AGI 之 【Hugging Face】 的【Transformer】的 [ Transformer 架构 ] / [ 编码器 ]的简单整理)
[2、 前馈层](#2、 前馈层)
一、简单介绍
AGI,即通用人工智能(Artificial General Intelligence),是一种具备人类智能水平的人工智能系统。它不仅能够执行特定的任务,而且能够理解、学习和应用知识于广泛的问题解决中,具有较高的自主性和适应性。AGI的能力包括但不限于自我学习、自我改进、自我调整,并能在没有人为干预的情况下解决各种复杂问题。
AGI能做的事情非常广泛:
跨领域任务执行:AGI能够处理多领域的任务,不受限于特定应用场景。
自主学习与适应:AGI能够从经验中学习,并适应新环境和新情境。
创造性思考:AGI能够进行创新思维,提出新的解决方案。
社会交互:AGI能够与人类进行复杂的社会交互,理解情感和社会信号。
关于AGI的未来发展前景,它被认为是人工智能研究的最终目标之一,具有巨大的变革潜力:
技术创新:随着机器学习、神经网络等技术的进步,AGI的实现可能会越来越接近。
跨学科整合:实现AGI需要整合计算机科学、神经科学、心理学等多个学科的知识。
伦理和社会考量:AGI的发展需要考虑隐私、安全和就业等伦理和社会问题。
增强学习和自适应能力:未来的AGI系统可能利用先进的算法,从环境中学习并优化行为。
多模态交互:AGI将具备多种感知和交互方式,与人类和其他系统交互。
Hugging Face作为当前全球最受欢迎的开源机器学习社区和平台之一,在AGI时代扮演着重要角色。它提供了丰富的预训练模型和数据集资源,推动了机器学习领域的发展。Hugging Face的特点在于易用性和开放性,通过其Transformers库,为用户提供了方便的模型处理文本的方式。随着AI技术的发展,Hugging Face社区将继续发挥重要作用,推动AI技术的发展和应用,尤其是在多模态AI技术发展方面,Hugging Face社区将扩展其模型和数据集的多样性,包括图像、音频和视频等多模态数据。
在AGI时代,Hugging Face可能会通过以下方式发挥作用:
模型共享:作为模型共享的平台,Hugging Face将继续促进先进的AGI模型的共享和协作。
开源生态:Hugging Face的开源生态将有助于加速AGI技术的发展和创新。
工具和服务:提供丰富的工具和服务,支持开发者和研究者在AGI领域的研究和应用。
伦理和社会责任:Hugging Face注重AI伦理,将推动负责任的AGI模型开发和应用,确保技术进步同时符合伦理标准。
AGI作为未来人工智能的高级形态,具有广泛的应用前景,而Hugging Face作为开源社区,将在推动AGI的发展和应用中扮演关键角色。
(注意:以下代码运行,可能需要科学上网)
二、Transformer
Transformer 是一种深度学习模型架构,最初由 Vaswani 等人在 2017 年的论文《Attention is All You Need》中提出。它引入了基于注意力机制的结构,克服了传统 RNN(递归神经网络)在处理长序列输入时的效率和效果问题。Transformer 模型特别适用于自然语言处理(NLP)任务,如机器翻译、文本生成和文本分类等。
Transformer 架构主要由编码器(Encoder)和解码器(Decoder)两个部分组成:
编码器(Encoder):
- 编码器由多个相同的编码层堆叠而成。
- 每个编码层包含两个子层:多头自注意力机制(Multi-Head Self-Attention Mechanism)和前馈神经网络(Feedforward Neural Network)。
- 编码器的输入是源序列,通过各层的处理,编码器生成对源序列的表示。
解码器(Decoder):
- 解码器也由多个相同的解码层堆叠而成。
- 每个解码层包含三个子层:多头自注意力机制、编码器-解码器注意力机制(Encoder-Decoder Attention Mechanism)和前馈神经网络。
- 解码器的输入是目标序列,通过各层的处理,解码器生成对目标序列的预测。
Hugging Face 是一个致力于推动 NLP 研究和应用的公司。其主要产品是开源的 Transformers 库,该库实现了各种基于 Transformer 架构的模型,并提供了易于使用的 API。
Transformers 库
模型实现:
- Transformers 库实现了许多流行的 Transformer 模型,例如 BERT、GPT、T5、RoBERTa 等。
- 这些模型经过预训练,可以直接用于各种下游任务,如文本分类、情感分析、命名实体识别等。
使用和定制:
- 用户可以轻松加载预训练模型,进行微调或直接使用。
- 库中提供了丰富的工具和方法,用于模型训练、评估和部署。
管道(Pipeline)API:
- Transformers 库的 Pipeline API 简化了常见任务的实现过程,如文本分类、问答系统、文本生成等。
- 用户只需几行代码即可完成复杂的 NLP 任务。
Transformer 架构在 Hugging Face 的应用
- 预训练和微调: Hugging Face 提供了大量预训练的 Transformer 模型,这些模型在大型数据集上进行预训练,并可以在小数据集上进行微调,适应特定任务。
- 社区和模型库: Hugging Face Hub 上有成千上万的模型,由社区成员和 Hugging Face 提供,这些模型可以被下载、使用、评估和分享。
- 集成和部署: Transformers 库支持在各种平台和环境中部署 Transformer 模型,包括云服务、边缘设备和移动设备。
通过 Hugging Face,研究人员和开发者可以快速地利用 Transformer 架构的强大功能,解决各种 NLP 任务,并推动 NLP 技术的发展。
三、T r a n s f o r m e r 架 构
原始 Transformer 是基于编码器-解码器架构的,该架构广泛用于机器翻译等任务中,即将一个单词序列从一种语言翻译成另一种语言。
该架构由两个组件组成:
- 编 码 器
将一个词元的输入序列转化为一系列嵌入向量,通常被称为隐藏状态或上下文。
- 解 码 器
利用编码器的隐藏状态,逐步生成一个词元的输出序列,每次生成一个词元。
而编码器和解码器又由如图所示的几个构建块组成。
Transformer的编码器-解码器架构,图中上半部分为编码器,下半部分为解码器
Transformer架构的特性:
- 输入的文本会使用第2章中所介绍的技术进行词元化,并转换成词元嵌入。由于注意力机制不了解词元之间的相对位置,因此我们需要一种方法将词元位置的信息注入输入,以便模拟文本的顺序性质。也就是说,词元嵌入会与包含每个词元位置信息的位置嵌入进行组合。
- 编码器由一系列编码器层或"块"堆叠而成,类似于计算机视觉中叠加卷积层。解码器也是如此,由一系列解码器层堆叠而成。
- 编码器的输出被提供给每个解码器层,然后解码器生成一个对于序列中下一个最可能的词元的预测。该步骤的输出随后被反馈回解码器以生成下一个词元,以此类推,直到达到特殊的结束序列(EOS)词元。假设解码器已经预测了"Die"和"Zeit"。现在它会把这两个作为输入,并作为所有编码器的输出,来预测下一个词元"fliegt"。在下一步中,解码器将"fliegt"作为额外的输入。我们重复这个过程,直到解码器预测出EOS词元或我们达到了最大长度。
Transformer架构最初是为序列到序列的任务(如机器翻译)而设计的,但编码器和解码器模块很快就被抽出来单独形成模型。虽然Transformer模型已经有数百种不同的变体,但其中大部分属于以下三种类型之一:
- 纯 编 码 器
这些模型将文本输入序列转换为富数字表示的形式,非常适用于文本分类或命名实体识别等任务。BERT及其变体,例如RoBERTa和DistilBERT,属于这类架构。此架构中为给定词元计算的表示取决于左侧(词元之前)和右侧(词元之后)上下文。这通常称为双向注意力。
- 纯 解 码 器
针对像"谢谢你的午餐,我有一个......"这样的文本提示,这类模型将通过迭代预测最可能的下一个词来自动完成这个序列。GPT模型家族属于这一类。在这种架构中,对于给定词元计算出来的表示仅依赖于左侧的上下文。这通常称为因果或自回归注意力。
- 编 码 器 - 解 码 器
这类模型用于对一个文本序列到另一个文本序列的复杂映射进行建模。它们适用于机器翻译和摘要任务。除了Transformer架构,它将编码器和解码器相结合,BART和T5模型也属于这个类。
实际上,纯解码器和纯编码器架构的应用之间的区别有些模糊不清。例如,像GPT系列中的纯解码器模型可以被优化用于传统上认为是序列到序列任务的翻译任务。同样,BERT等纯编码器模型也可以应用于通常与编码器-解码器或纯解码器模型相关的文本摘要任务注 。
现在你已经对Transformer架构有了高层次的理解,接下来我们将更深入地了解编码器的内部工作原理。
四、编码器
编码器(Encoder)是 Transformer 架构中的一个核心部分,负责将输入序列(如文本、图像等)转换为表示序列的高级抽象。
编码器的结构
编码器层(Encoder Layer):
- 编码器由多个相同的编码器层堆叠而成。
- 每个编码器层包含两个子层:
- 多头自注意力机制(Multi-Head Self-Attention): 允许编码器在处理每个位置的输入时,能够注意到输入序列的其他位置。
- 前馈神经网络(Feedforward Neural Network): 每个位置的表示经过一个全连接前馈网络,对位置上的信息进行处理。
- 每个子层后面通常还有一个残差连接(Residual Connection),以及层归一化(Layer Normalization)操作,有助于提高模型的训练稳定性和速度。
编码器堆叠:
- Transformer 中的编码器由多个相同的编码器层堆叠而成,通常有几层(如论文中的六层编码器)。
- 每个编码器层生成的输出作为下一个编码器层的输入,形成了一种逐层处理和传递信息的结构。
位置编码(Positional Encoding):
- Transformer 模型不使用递归结构(如 RNN),因此需要一种方式来处理序列中单词或位置的顺序信息。
- 位置编码是一种在输入嵌入中添加位置信息的方法,它使得模型能够区分不同位置的单词或子词。
编码器的工作原理
- 输入表示: 编码器接收输入序列的表示,通常是词嵌入(Word Embeddings)的组合,可能还包括位置编码。
- 多头自注意力机制:
- 注意力机制(Attention Mechanism): 允许编码器在处理每个位置的输入时,能够依据输入序列中其他位置的重要性来调整自己的表示。
- 多头注意力(Multi-Head Attention): 在注意力机制中使用多组不同的注意力权重,从不同的子空间中学习信息,提高了模型对不同表示空间的学习能力。
- 前馈神经网络:
- 全连接层: 每个位置的表示通过一个两层的全连接前馈神经网络进行变换。
- 激活函数(Activation Function): 前馈神经网络通常使用 ReLU(Rectified Linear Unit)作为激活函数。
- 残差连接和层归一化:
- 残差连接(Residual Connection): 通过将每个子层的输入与其输出相加,有助于避免梯度消失问题,使得模型更容易训练。
- 层归一化(Layer Normalization): 对每个子层的输出进行归一化处理,加速训练并提高模型的泛化能力。
编码器在 Transformer 中的应用
- 特征提取和表示: 编码器负责从输入序列中提取和学习关键的特征表示,这些表示可以用于各种 NLP 任务,如文本分类、命名实体识别和机器翻译等。
- 模型堆叠和预训练: 多层编码器的堆叠以及与解码器的结合,使得 Transformer 模型在大规模预训练和微调任务中表现出色,成为当前领域的主流模型之一。
- 灵活性和性能: 编码器结构的灵活性使得它能够处理不同长度和类型的输入序列,同时其并行化处理也显著提高了模型的计算效率。
编码器是 Transformer 架构中负责将输入序列转换为高级抽象表示的关键组件,它通过多层编码器层、自注意力机制和前馈神经网络来实现对序列信息的有效编码和表示。
Transformer的编码器由许多编码器层相互堆叠而成。如图所示
编码器层放大图
每个编码器层接收一系列嵌入,然后通过以下子层进行馈送处理:
●一个多头自注意力层。
●一个全连接前馈层,应用于每个输入嵌入。
每个编码器层的输出嵌入尺寸与输入嵌入相同,我们很快就会看到编码器堆叠的主要作用是"更新"输入嵌入,以产生编码一些序列中的上下文信息的表示。例如,如果单词"苹果"附近的单词是"主题演讲"或"电话",那么该单词将被更新为更像"公司"的特性,而不是更像"水果"的特性。
这些子层同样使用跳跃连接和层规范化,这些是训练深度神经网络的常用技巧。但要想真正理解Transformer的工作原理,我们还需要更深入地研究。我们从最重要的构建模块开始:自注意力层。
1、自 注 意 力 机 制
注意力机制是一种神经网络为序列中的每个元素分配不同权重或"注意力"的机制。对文本序列来说,元素则为我们在之前遇到的词元嵌入(其中每个词元映射为固定维度的向量)。例如,在BERT中,每个词元表示为一个768维向量。自注意力中的"自"指的是这些权重是针对同一组隐藏状态计算的,例如编码器的所有隐藏状态。与自注意力相对应的,与循环模型相关的注意力机制则计算每个编码器隐藏状态对于给定解码时间步的解码器隐藏状态的相关性。
自注意力的主要思想是,不是使用固定的嵌入值来表示每个词元,而是使用整个序列来计算每个嵌入值的加权平均值。另一种表述方式是说,给定词元嵌入的序列x ,...,x ,自注意力产生新的嵌入序列,其中每个是所有x的线性组合:
1n j
左边的flies为飞的意思,所以上下文嵌入与表示飞的soars相近。右边的flies为苍蝇的意思,所以上下文嵌入与表示昆虫的insect相近。
其中的系数w ji称为注意力权重,其被规范化以使得∑jw ji=1。如果想要了解为什么平均词元嵌入可能是一个好主意,那么可以这样思考,当你看到单词"flies"时会想到什么。也许你会想到令人讨厌的昆虫,但是如果你得到更多的上下文,比如"time flies like an arrow",那么你会意识到"flies"表示的是动词。同样地,我们可以通过以不同的比例结合所有词元嵌入来创建"flies"的表示形式,也许可以给"time"和"arrow"的词元嵌入分配较大的权重wji。用这种方式生成的嵌入称为上下文嵌入,早在Transformer发明之前就存在了,例如ELMo语言模型 。如图展示了这一过程,我们通过自注意力根据上下文生成了"flies"的两种不同表示 。
自注意力将原始的词元嵌入(顶部)更新为上下文嵌入(底部),从而创建包含了整个序列信息的表示
现在我们看一下注意力权重是如何计算的。
1.1 缩 放 点 积 注 意 力
实现自注意力层的方法有好几种,但最常见的是那篇著名的Transformer架构论文 所介绍的缩放点积注意力(scaled dot-product attention)。要实现这种机制,需要四个主要步骤:
1)将每个词元嵌入投影到三个向量中,分别称为q u e r y 、k e y 和v a l u e。
2)计算注意力分数。我们使用相似度函数确定query和key向量的相关程度。顾名思义,缩放点积注意力的相似度函数是点积,并通过嵌入的矩阵乘法高效计算。相似的query和key将具有较大的点积,而那些没有相似处的则几乎没有重叠。这一步的输出称为注意力分数,在一个有n 个输入词元的序列中,将对应着一个n ×n的注意力分数矩阵。
3)计算注意力权重。点积在一般情况下有可能会产生任意大的数,这可能会导致训练过程不稳定。为了处理这个问题,首先将注意力分数乘以一个缩放因子来规范化它们的方差,然后再通过softmax 进行规范化,以确保所有列的值相加之和为1。结果得到一个n ×n 的矩阵,该矩阵包含了所有的注意力权重。
4)更新词嵌入。计算完注意力权重之后,我们将它们与值向量v ,...,v相乘,最终获得词嵌入表示。
1n
我们可以使用一个很赞的库,Jupyter 的 BertViz(h t t p s : / / o r e i l . l y / e Q K 3 I),来可视化以上的注意力权重计算过程。该库提供了一些可用于可视化Transformer模型的不同方面注意力的函数。如果想可视化注意力权重,我们可以使用 neuron_view 模块,该模块跟踪权重计算的过程,以显示如何将query向量和key向量相结合以产生最终权重。由于BertViz需要访问模型的注意力层,因此我们将使用BertViz中的模型类来实例化我们的BERT checkpoint,然后使用show()函数为特定的编码器层和注意力头生成交互式可视化。请注意,你需要单击左侧的"+"才能激活注意力可视化。
(注意安装 bertviz:pip install bertviz)
python
from transformers import AutoTokenizer # 从transformers库中导入AutoTokenizer
from bertviz.transformers_neuron_view import BertModel # 从bertviz库中导入BertModel
from bertviz.neuron_view import show # 从bertviz库中导入show函数
# 设置模型检查点
model_ckpt = "bert-base-uncased"
# 加载预训练的分词器
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
# 加载预训练的BERT模型
model = BertModel.from_pretrained(model_ckpt)
# 定义要处理的文本
text = "time flies like an arrow"
# 可视化BERT模型在给定文本上的注意力权重
show(model, "bert", tokenizer, text, display_mode="light", layer=0, head=8)
# 显示BERT模型在第0层、第8个注意力头的注意力权重
运行结果:
1.2 qu e r y 、 k e y 和 v a l u e 的 神 秘 面 纱
在你第一次接触query、key和value向量的概念时,可能会觉得这些概念有点晦涩难懂。这些概念受到信息检索系统的启发,但我们可以用一个简单的类比来解释它们的含义。你可以这样想象,你正在超市购买晚餐所需的所有食材。你有一份食谱,食谱里面每个食材可以视为一个query。然后你会扫描货架,通过货架上的标注(key),以检查该商品是否与你列表中的食材相匹配(相似度函数)。如果匹配成功,那么你就从货架上取走这个商品(value)。
在这个类比中,你只会得到与食材匹配的商品,而忽略掉其他不匹配的商品。自注意力是这个类比更抽象和流畅的版本:超市中的每个标注都与配料匹配,匹配的程度取决于每个key与query的匹配程度。因此,如果你的清单包括一打鸡蛋,那么你可能会拿走10个鸡蛋、一个煎蛋卷和一个鸡翅。
缩放点积注意力过程的更详细细节可以如下图所示。
缩放点积注意力中的操作
我们将使用PyTorch实现Transformer架构,使用TensorFlow实现Transformer架构的步骤与之类似。两个框架中最重要的函数之间的映射关系详见下表
我们需要做的第一件事是对文本进行词元化,因此我们使用词元分析器提取输入ID:
python
from transformers import AutoTokenizer
# 设置模型检查点,使用 BERT 基础版(未区分大小写)
model_ckpt = "bert-base-uncased"
# 定义要处理的文本
text = "time flies like an arrow"
# 加载预训练的分词器
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
# 使用 tokenizer 对文本进行编码,并返回 PyTorch 张量格式的输入
inputs = tokenizer(text, return_tensors="pt", add_special_tokens=False)
# 打印输入的 token IDs
inputs.input_ids
运行结果:
tensor([[ 2051, 10029, 2066, 2019, 8612]])
正如我们之前所看到的那样,句子中的每个词元都被映射到词元分析器的词表中的唯一ID。为了保持简单,我们还通过设置add_special_tokens=False来将[CLS]和[SEP]词元排除在外。接下来,我们需要创建一些密集嵌入。这里的密集是指嵌入中的每个条目都包含一个非零值。相反,我们在之前所看到的独热编码是稀疏的,因为除一个之外的所有条目都是零。在PyTorch中,我们可以通过使用torch.nn.Embedding层来实现这一点,该层作为每个输入ID的查找表:
python
from torch import nn
from transformers import AutoConfig
# 从预训练模型的配置中加载配置信息
config = AutoConfig.from_pretrained(model_ckpt)
# 创建一个 token embeddings 层,使用预训练模型的词汇表大小和隐藏层大小
token_emb = nn.Embedding(config.vocab_size, config.hidden_size)
# 打印 token embeddings 层
token_emb
运行结果:
Embedding(30522, 768)
在这里,我们使用AutoConfig类加载了与bert-base-uncased checkpoint相关联的c o n f i g . j s o n文件。在Hugging Face Transformers库中,每个checkpoint都被分配一个配置文件,该文件指定了各种超参数,例如vocab_size和hidden_size。在我们的示例中,每个输入ID将映射到nn.Embedding中存储的30 522个嵌入向量之一,其中每个向量维度为768。AutoConfig类还存储其他元数据,例如标注名称,用于格式化模型的预测。
需要注意的是,此时的词元嵌入与它们的上下文是独立的。这意味着,同形异义词(拼写相同但意义不同的词),如前面例子中的"flies"("飞行"或"苍蝇"),具有相同的表示形式。后续的注意力层的作用是将这些词元嵌入进行混合,以消除歧义,并通过其上下文的内容来丰富每个词元的表示。
现在我们有了查找表,通过输入ID,我们可以生成嵌入向量:
python
# 使用 token embeddings 层将输入的 token IDs 转换为嵌入向量
inputs_embeds = token_emb(inputs.input_ids)
# 打印输入嵌入向量的形状
inputs_embeds.size()
运行结果:
torch.Size([1, 5, 768])
这给我们提供了一个形状为[batch_size,seq len,hidden_dim]的张量,就像我们在第2章中看到的一样。这里我们将推迟位置编码,因此下一步是创建query、key和value向量,并使用点积作为相似度函数来计算注意力分数:
python
import torch
from math import sqrt
# 定义 query、key 和 value 都为输入的嵌入向量
query = key = value = inputs_embeds
# 获取 key 的最后一个维度的大小,用于缩放点积注意力分数
dim_k = key.size(-1)
# 计算注意力分数(使用缩放点积注意力)
# torch.bmm 是批量矩阵乘法,将 query 和 key 的转置相乘,并按 sqrt(dim_k) 缩放
scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k)
# 打印注意力分数张量的形状
scores.size()
运行结果:
torch.Size([1, 5, 5])
这产生了一个5×5矩阵,其中包含批量中每个样本的注意力分数。稍后我们将看到,query、key和value向量是通过将独立的权重矩阵WQ应用到嵌入中生成的,但这里为简单起见,我们将它们设为相等。在缩放点积注意力中,点积按照嵌入向量的大小进行缩放,这样我们在训练过程中就不会得到太多的大数,从而可以避免下一步要应用的softmax饱和。
,K ,V
torch.bmm()函数执行批量矩阵乘积,简化了注意力分数的计算过程,其中query和key向量的形状为[batch_size,seq_len,hidden_dim]。如果我们忽略批处理维度,那么我们可以通过简单地转置key张量以使其形状为[hidden dim,seq len],然后使用矩阵乘积来收集所有在[seq_len,seq_len]矩阵中的点积。由于我们希望对批处理中的所有序列独立地执行此操作,因此我们使用torch.bmm(),它接收两个矩阵批处理并将第一个批处理中的每个矩阵与第二个批处理中的相应矩阵相乘。
接下来我们应用softmax:
python
import torch.nn.functional as F
# 对注意力分数进行 softmax 操作,得到注意力权重
weights = F.softmax(scores, dim=-1)
# 计算注意力权重在最后一个维度上的和
weights_sum = weights.sum(dim=-1)
# 打印注意力权重在最后一个维度上的和
weights_sum
运行结果:
tensor([[1., 1., 1., 1., 1.]], grad_fn=<SumBackward1>)
最后将注意力权重与值相乘:
python
# 使用注意力权重 weights 对 value 进行加权求和,得到注意力输出
attn_outputs = torch.bmm(weights, value)
# 打印注意力输出张量的形状
attn_outputs.shape
运行结果:
torch.Size([1, 5, 768])
这就是全部了,我们已经完成了简化形式的自注意力机制实现的所有步骤!请注意,整个过程仅涉及两个矩阵乘法和一个softmax,因此你可以将"自注意力"视为一种花哨的平均形式。
我们把这些步骤封装成一个函数,以便以后我们可以重用它:
python
def scaled_dot_product_attention(query, key, value):
# 获取 key 的最后一个维度的大小,用于缩放点积注意力分数
dim_k = query.size(-1)
# 计算缩放点积注意力分数
scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k)
# 对注意力分数进行 softmax 操作,得到注意力权重
weights = F.softmax(scores, dim=-1)
# 使用注意力权重对 value 进行加权求和,得到注意力输出
return torch.bmm(weights, value)
我们的注意力机制在query向量和key向量相等的情况下,会给上下文中相同的单词分配非常高的分数,特别是给当前单词本身:query向量与自身的点积总是1。而实际上,一个单词的含义将更好地受到上下文中其他单词的影响,而不是同一单词(甚至自身)。以前面的句子为例,通过结合"time"和"arrow"的信息来定义"flies"的含义,比重复提及"flies"要更好。那么我们如何实现这点?
我们可以让模型使用三个不同的线性投影将初始词元向量投影到三个不同的空间中,从而允许模型为query、key和value创建一个不同的向量集。
1.3 多 头 注 意 力
前面提到,我们将query、key和value视为相等来计算注意力分数和权重。但在实践中,我们会使用自注意力层对每个嵌入应用三个独立的线性变换,以生成query、key和value向量。这些变换对嵌入进行投影,每个投影都带有其自己的可学习参数,这使得自注意力层能够专注于序列的不同语义方面。
同时,拥有多组线性变换通常也是有益的,每组变换代表一种所谓的注意力头。多头注意力层如下图所示。但是,为什么我们需要多个注意力头?原因是一个注意力头的softmax函数往往会集中在相似度的某一方面。拥有多个头能够让模型同时关注多个方面。例如,一个头负责关注主谓交互,而另一个头负责找到附近的形容词。显然,我们没有在模型中手工制作这些关系,它们完全是从数据中学习到的。如果你对计算机视觉模型熟悉,你可能会发现其与卷积神经网络中的滤波器相似,其中一个滤波器负责检测人脸,而另一个滤波器负责在图像中找到汽车的车轮。
现在我们来编码实现,首先编写一个单独的注意力头的类:
python
class AttentionHead(nn.Module):
def __init__(self, embed_dim, head_dim):
super().__init__()
# 定义线性变换,用于生成 query, key 和 value
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):
# 通过线性变换计算 query, key 和 value
query = self.q(hidden_state)
key = self.k(hidden_state)
value = self.v(hidden_state)
# 计算缩放点积注意力
attn_outputs = scaled_dot_product_attention(query, key, value)
# 返回注意力输出
return attn_outputs
这里我们初始化了三个独立的线性层,用于对嵌入向量执行矩阵乘法,以生成形状为[batch_size,seq_len,head_dim]的张量,其中head_dim是我们要投影的维数数量。尽管head_dim不一定比词元的嵌入维数(embed_dim)小,但在实践中,我们选择head_dim是embed_dim的倍数,以便跨每个头的计算能够保持恒定。例如,BERT有12个注意力头,因此每个头的维数为768/12=64。
现在我们有了一个单独的注意力头,因此我们可以将每个注意力头的输出串联起来,来实现完整的多头注意力层:
python
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]的输出张量,以适用于下游的前馈网络。为了确认,我们看看多头注意力层是否产生了我们输入的预期形状。在初始化MultiHeadAttention模块时,我们传递了之前从预训练的BERT模型中加载的配置。这确保我们使用与BERT相同的设置:
python
# 创建 MultiHeadAttention 实例,传入配置对象
multihead_attn = MultiHeadAttention(config)
# 进行前向传播,计算多头注意力的输出
attn_output = multihead_attn(inputs_embeds)
# 打印注意力输出张量的形状
attn_output.size()
运行结果:
torch.Size([1, 5, 768])
这么做是可行的!最后,我们再次使用BertViz可视化单词"flies"的两个不同用法的注意力。这里我们可以使用BertViz的head_view()函数,通过计算预训练checkpoint的注意力并指示句子边界的位置来显示注意力:
python
# 从 bertviz 导入 head_view 函数
from bertviz import head_view
# 从 transformers 导入 AutoModel 类
from transformers import AutoModel
# 加载预训练的 BERT 模型,并启用输出注意力
model = AutoModel.from_pretrained(model_ckpt, output_attentions=True)
# 定义两个句子
sentence_a = "time flies like an arrow"
sentence_b = "fruit flies like a banana"
# 使用 tokenizer 对两个句子进行编码
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)
# 将输入 IDs 转换为 tokens
tokens = tokenizer.convert_ids_to_tokens(viz_inputs.input_ids[0])
# 使用 head_view 函数可视化注意力
head_view(attention, tokens, sentence_b_start, heads=[8])
运行结果:
这种可视化展示了注意力权重,表现为连接正在被更新嵌入的词元(左侧)与所有被关注的单词(右侧)之间的线条。线条的颜色深度表现了注意力权重的大小,深色线条代表值接近于1,淡色线条代表值接近于0。
在这个例子中,输入由两个句子组成,[CLS]和[SEP]符号是我们在第2章中遇到的BERT的词元分析器中的特殊符号。从可视化结果中我们可以看到注意力权重最大的是属于同一句子的单词,这表明BERT能够判断出它应该关注同一句子中的单词。然而,对于单词"flies",我们可以看到BERT已经识别出在第一句中"arrow"是重要的,在第二句中"fruit"和"banana"是重要的。这些注意力权重使模型能够根据它所处的上下文来区分"flies"到底应该为动词还是名词!
至此我们已经讲述完注意力机制了,我们来看一下如何实现编码器层缺失的一部分:位置编码前馈神经网络。
2、 前馈层
编码器和解码器中的前馈子层仅是一个简单的两层全连接神经网络,但有一点小小的不同:它不会将整个嵌入序列处理为单个向量,而是独立处理每个嵌入。因此,该层通常称为位置编码前馈神经网络。有时候你还会看到它又被称为内核大小为1的1维卷积,这种叫法通常来自具有计算机视觉背景的人(例如,OpenAI GPT代码库就是这么叫的)。论文中的经验法则是第一层的隐藏尺寸应为嵌入尺寸的四倍,并且最常用的激活函数是GELU。这是大部分容量和记忆发生的地方,也是扩展模型时最经常进行缩放的部分。我们可以将其实现为一个简单的nn.Module,如下所示:
python
# 定义 FeedForward 类,继承自 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)
# 定义 GELU 激活函数
self.gelu = nn.GELU()
# 定义 Dropout 层,防止过拟合
self.dropout = nn.Dropout(config.hidden_dropout_prob)
def forward(self, x):
# 通过第一个线性层
x = self.linear_1(x)
# 通过 GELU 激活函数
x = self.gelu(x)
# 通过第二个线性层
x = self.linear_2(x)
# 通过 Dropout 层
x = self.dropout(x)
# 返回输出
return x
需要注意的是,像nn.Linear这样的前馈层通常应用于形状为(batch_size,input_dim)的张量上,它将独立地作用于批量维度中的每个元素。这对于除了最后一个维度之外的任何维度都是正确的,因此当我们将形状为(batch_size,seq_len,hidden_dim)的张量传给该层时,该层将独立地应用于批量和序列中的所有词元嵌入,这正是我们想要的。我们可以通过传递注意力输出来测试这一点:
python
# 创建一个 FeedForward 类的实例
feed_forward = FeedForward(config)
# 通过前馈神经网络计算注意力输出
ff_outputs = feed_forward(attn_outputs)
# 打印前馈神经网络输出的张量尺寸
ff_outputs.size()
运行结果:
torch.Size([1, 5, 768])
现在我们已经拥有了创建完整的Transformer编码器层的所有要素!唯一剩下的部分是决定在哪里放置跳跃连接和层规范化。我们看看这会如何影响模型架构。
- 添加层规范化
如前所述,Transformer架构使用了层规范化和跳跃连接。前者将批处理中的每个输入规范化为零均值和单位方差。跳跃连接直接将张量传给模型的下一层,而不做处理,只将其添加到处理的张量中。在将层规范化放置在Transformer的编码器或解码器层中时,论文提供了两种选项:
- 后 置 层 规 范 化
这是Transformer论文中使用的一种结构,它把层规范化置于跳跃连接之后。这种结构从头开始训练时会比较棘手,因为梯度可能会发散。因此,在训练过程中我们经常会看到一个称为学习率预热的概念,其中学习率在训练期间从一个小值逐渐增加到某个最大值。
- 前 置 层 规 范 化
这是论文中最常见的布局,它将层规范化置于跳跃连接之前。这样做往往在训练期间更加稳定,并且通常不需要任何学习率预热。
这两种方式的区别如图所示:
Transformer编码器层中层规范化的两种方式
这里我们将使用第二种方式,因此我们可以简单地将我们的基本构件粘在一起,如下所示:
python
# 创建一个 Transformer 编码器层的实例
encoder_layer = TransformerEncoderLayer(config)
# 打印输入嵌入的形状和通过编码器层后的输出形状
inputs_embeds.shape, encoder_layer(inputs_embeds).size()
运行结果:
(torch.Size([1, 5, 768]), torch.Size([1, 5, 768]))
在更高层面的术语中,自注意力层和前馈层称为置换等变的------如果输入被置换,那么层的相应输出将以完全相同的方式置换。
我们已经成功地从头开始实现了我们的第一个Transformer编码器层!然而,我们设置编码器层的方式存在一个问题:它们对于词元的位置是完全不变的。由于多头注意力层实际上是一种精致的加权和,因此词元位置的信息将丢失 。
幸运的是,有一种简单的技巧可以使用位置嵌入来整合位置信息。我们来看看。
3、位 置 嵌 入
位置编码基于一个简单但非常有效的想法:用一个按向量排列的位置相关模式来增强词元嵌入。如果该模式对于每个位置都是特定的,那么每个栈中的注意力头和前馈层可以学习将位置信息融合到它们的转换中。
有几种实现这个目标的方法,其中最流行的方法之一是使用可学习的模式,特别是在预训练数据集足够大的情况下。这与仅使用词元嵌入的方式完全相同,但是使用位置索引作为输入,而不是词元ID。通过这种方法,在预训练期间可以学习到一种有效的编码词元位置的方式。
我们创建一个自定义的Embeddings模块,它将输入的input_ids投影到密集的隐藏状态上,并结合Position_ids的位置嵌入进行投影。最终的嵌入层是两个嵌入层的简单求和:
python
class Embeddings(nn.Module):
def __init__(self, config):
super().__init__()
# 定义 token 嵌入层
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)
# 定义 dropout 层
self.dropout = nn.Dropout()
def forward(self, input_ids):
# 为输入序列创建位置 ID
seq_length = input_ids.size(1)
position_ids = torch.arange(seq_length, dtype=torch.long).unsqueeze(0)
# 创建 token 嵌入和位置嵌入
token_embeddings = self.token_embeddings(input_ids)
position_embeddings = self.position_embeddings(position_ids)
# 合并 token 嵌入和位置嵌入
embeddings = token_embeddings + position_embeddings
# 进行层归一化
embeddings = self.layer_norm(embeddings)
# 进行 dropout
embeddings = self.dropout(embeddings)
return embeddings
python
# 实例化 Embeddings 类
embedding_layer = Embeddings(config)
# 调用 embedding_layer,传入 inputs['input_ids'],并获取其输出的大小
embedding_layer(inputs['input_ids']).size()
运行结果:
torch.Size([1, 5, 768])
我们可以看到嵌入层现在为每个词元创建了一个密集的嵌入。
这种可学习的位置嵌入易于实现并广泛使用,除此之外,还有其他一些方法:
- 绝 对 位 置 表 示
Transformer模型可以使用由调制正弦和余弦信号组成的静态模式来编码词元的位置。当没有大量数据可用时,这种方法尤其有效。
- 相 对 位 置 表 示
通过结合绝对和相对位置表示的想法,旋转位置嵌入在许多任务上取得了优秀的结果。GPT-Neo是一个采用旋转位置嵌入的模型的例子。
尽管绝对位置很重要,但有观点认为,在计算嵌入时,周围的词元最为重要。相对位置表示遵循这种直觉,对词元之间的相对位置进行编码。这不能仅通过在开头引入新的相对嵌入层来设置,因为相对嵌入针对每个词元会因我们对序列的访问位置的不同而不同。对此,注意力机制本身通过添加额外项来考虑词元之间的相对位置。像DeBERTa等模型就使用这种表示 。
现在我们把所有内容整合起来,通过将嵌入与编码器层结合起来构建完整的Transformer编码器:
python
class TransformerEncoder(nn.Module):
def __init__(self, config):
super().__init__()
# 初始化 Embeddings 层
self.embeddings = Embeddings(config)
# 创建 num_hidden_layers 个 TransformerEncoderLayer,并将它们存储在 ModuleList 中
self.layers = nn.ModuleList([TransformerEncoderLayer(config)
for _ in range(config.num_hidden_layers)])
def forward(self, x):
# 通过嵌入层获取输入的嵌入表示
x = self.embeddings(x)
# 依次通过每一层 Transformer 编码层
for layer in self.layers:
x = layer(x)
# 返回编码后的表示
return x
我们检查编码器的输出形状:
python
# 实例化 TransformerEncoder 类
encoder = TransformerEncoder(config)
# 调用 encoder 的 forward 方法,传入 inputs['input_ids'],并获取其输出的大小
encoder(inputs['input_ids']).size()
运行结果:
torch.Size([1, 5, 768])
我们可以看到在每个批量中,我们为每个词元获取了一个隐藏状态。这种输出格式使得架构非常灵活,我们可以很容易地适应各种应用,例如在掩码语言建模中预测缺失的词元,或者在问答中预测回答的起始和结束位置。
4、添 加 分 类 头
Transformer模型通常分为与任务无关的主体和与任务相关的头。我们将在第4章讲述Hugging Face Transformers库的设计模式时会再次提到这种模式。到目前为止,我们所构建的都是主体部分的内容,如果我们想构建一个文本分类器,那么我们还需要将分类头附加到该主体上。每个词元都有一个隐藏状态,但我们只需要做出一个预测。有几种方法可以解决这个问题。一般来说,这种模型通常使用第一个词元来进行预测,我们可以附加一个dropout和一个线性层来进行分类预测。下面的类对现有的编码器进行了扩展以用于序列分类:
python
class TransformerForSequenceClassification(nn.Module):
def __init__(self, config):
super().__init__()
# 实例化 TransformerEncoder 类
self.encoder = TransformerEncoder(config)
# 定义 dropout 层,防止过拟合
self.dropout = nn.Dropout(config.hidden_dropout_prob)
# 定义分类器层,将隐藏状态映射到分类标签
self.classifier = nn.Linear(config.hidden_size, config.num_labels)
def forward(self, x):
# 将输入传递给 encoder,并选择 [CLS] 标记的隐藏状态
x = self.encoder(x)[:, 0, :]
# 应用 dropout 层
x = self.dropout(x)
# 应用分类器层
x = self.classifier(x)
return x
在初始化模型之前,我们需要定义希望预测的类数目:
python
# 设置分类标签的数量
config.num_labels = 3
# 实例化 TransformerForSequenceClassification 类
encoder_classifier = TransformerForSequenceClassification(config)
# 传递输入数据并获取输出的尺寸
encoder_classifier(inputs.input_ids).size()
运行结果:
torch.Size([1, 3])
这正是我们一直在寻找的。对于批处理中的每个样本,我们会得到输出中每个类别的非规范化logit值。这类似于我们在第2章中使用的BERT模型(用于检测推文中的情感)。
至此我们对编码器以及如何将其与具体任务的头相组合的部分就结束了。现在我们将注意力(双关语!)转向解码器。