【LLM学习之路】9月23日24日 第十、十一天 Attention代码解读
Transformer模型大致分为三类
- 纯 Encoder 模型(例如 BERT),又称自编码 (auto-encoding) Transformer 模型;
- 纯 Decoder 模型(例如 GPT),又称自回归 (auto-regressive) Transformer 模型;
- Encoder-Decoder 模型(例如 BART、T5),又称 Seq2Seq (sequence-to-sequence) Transformer 模型。
什么是 Transformer
语言模型
Transformer 模型本质上都是预训练语言模型,大都采用自监督学习 (Self-supervised learning) 的方式在大量生语料上进行训练,也就是说,训练这些 Transformer 模型完全不需要人工标注数据。
两个常用的预训练任务:
-
因果语言建模
- 基于句子的前 n 个词来预测下一个词,因为输出依赖于过去和当前的输入
- 就是统计语言模型
-
遮盖语言建模
- 基于上下文(周围的词语)来预测句子中被遮盖掉的词语 (masked word)
- 就是 Word2Vec 模型提出的 CBOW
这些语言模型虽然可以对训练过的语言产生统计意义上的理解,例如可以根据上下文预测被遮盖掉的词语,但是如果直接拿来完成特定任务,效果往往并不好。因此,我们通常还会采用迁移学习 (transfer learning) 方法,使用特定任务的标注语料,以有监督学习的方式对预训练模型参数进行微调 (fine-tune),以取得更好的性能。
迁移学习
将别人预训练好的模型权重通过迁移学习应用到自己的模型中,即使用自己的任务语料对模型进行“二次训练”,通过微调参数使模型适用于新任务。
迁移学习的好处:
- 预训练时模型很可能已经见过与我们任务类似的数据集,通过微调可以激发出模型在预训练过程中获得的知识,将基于海量数据获得的统计理解能力应用于我们的任务;
- 由于模型已经在大量数据上进行过预训练,微调时只需要很少的数据量就可以达到不错的性能;
- 换句话说,在自己任务上获得优秀性能所需的时间和计算成本都可以很小。
例如,我们可以选择一个在大规模英文语料上预训练好的模型,使用 arXiv 语料进行微调,以生成一个面向学术/研究领域的模型。这个微调的过程只需要很少的数据:我们相当于将预训练模型已经获得的知识“迁移”到了新的领域,因此被称为迁移学习。
在绝大部分情况下,我们都应该尝试找到一个尽可能接近我们任务的预训练模型,然后微调它,也就是所谓的“站在巨人的肩膀上”。
Transformer 的结构
- **Encoder(左边):**负责理解输入文本,为每个输入构造对应的语义表示(语义特征);
- **Decoder(右边):**负责生成输出,使用 Encoder 输出的语义表示结合其他输入来生成目标序列。
这两个模块可以根据任务的需求而单独使用:
-
**纯 Encoder 模型:**适用于只需要理解输入语义的任务,例如句子分类、命名实体识别;
-
**纯 Decoder 模型:**适用于生成式任务,例如文本生成;
-
Encoder-Decoder 模型或 **Seq2Seq 模型:**适用于需要基于输入的生成式任务,例如翻译、摘要。
注意力层
Transformer 模型的标志就是采用了注意力层 (Attention Layers) 的结构。注意力层的作用就是让模型在处理文本时,将注意力只放在某些词语上。
例如要将英文“You like this course”翻译为法语,由于法语中“like”的变位方式因主语而异,因此需要同时关注相邻的词语“You”。同样地,在翻译“this”时还需要注意“course”,因为“this”的法语翻译会根据相关名词的极性而变化。对于复杂的句子,要正确翻译某个词语,甚至需要关注离这个词很远的词。
Word2Vec 这些静态模型所解决不了的
原始结构
Transformer 模型本来是为了翻译任务而设计的。在训练过程中,Encoder 接受源语言的句子作为输入,而 Decoder 则接受目标语言的翻译作为输入。
在 Encoder 中,由于翻译一个词语需要依赖于上下文,因此注意力层可以访问句子中的所有词语;而 Decoder 是顺序地进行解码,在生成每个词语时,注意力层只能访问前面已经生成的单词。
假设翻译模型当前已经预测出了三个词语,我们会把这三个词语作为输入送入 Decoder,然后 Decoder 结合 Encoder 所有的源语言输入来预测第四个词语。
实际训练中为了加快速度,会将整个目标序列都送入 Decoder,然后在注意力层中通过 Mask 遮盖掉未来的词语来防止信息泄露。例如我们在预测第三个词语时,应该只能访问到已生成的前两个词语,如果 Decoder 能够访问到序列中的第三个(甚至后面的)词语,就相当于作弊了。
Decoder 中的第一个注意力层关注 Decoder 过去所有的输入,而第二个注意力层则是使用 Encoder 的输出
Transformer 家族
Encoder 分支
纯 Encoder 模型只使用 Transformer 模型中的 Encoder 模块,也被称为自编码 (auto-encoding) 模型。在每个阶段,注意力层都可以访问到原始输入句子中的所有词语,即具有**“双向 (Bi-directional)”注意力**
纯 Encoder 模型通常通过破坏给定的句子(例如随机遮盖其中的词语),然后让模型进行重构来进行预训练,最适合处理那些需要理解整个句子语义的任务,例如句子分类、命名实体识别(词语分类)、抽取式问答。
Decoder 分支
在每个阶段,对于给定的词语,注意力层只能访问句子中位于它之前的词语,即只能迭代地基于已经生成的词语来逐个预测后面的词语,因此也被称为自回归 (auto-regressive) 模型。
纯 Decoder 模型适合处理那些只涉及文本生成的任务。
Encoder-Decoder 分支
在每个阶段,
Encoder 的注意力层都可以访问初始输入句子中的所有单词,而 Decoder 的注意力层则只能访问输入中给定词语之前的词语(即已经解码生成的词语)。
Encoder-Decoder 模型适合处理那些需要根据给定输入来生成新文本的任务,例如自动摘要、翻译、生成式问答。
自注意力机制
Attention
NLP 神经网络模型的本质就是对输入文本进行编码,常规的做法是首先对句子进行分词,然后将每个词语 (token) 都转化为对应的词向量 (token embeddings),这样文本就转换为一个由词语向量组成的矩阵X=(x1,x2,…,xn),其中 xi 就表示第 i 个词语的词向量,故 X∈Rn×d。
在 Transformer 模型提出之前,对 token 序列 X 的常规编码方式是通过循环网络 (RNNs) 和卷积网络 (CNNs)
之后可以用Attention 机制编码整个文本,相比 RNN 要逐步递归才能获得全局信息(因此一般使用双向 RNN),而 CNN 实际只能获取局部信息,需要通过层叠来增大感受野,Attention 机制一步到位获取了全局信息
Scaled Dot-product Attention
虽然 Attention 有许多种实现方式,但是最常见的还是 Scaled Dot-product Attention。
就是之前看的那一套原理
注意力代码部分
这段代码的作用是创建一个嵌入层,并将输入的单词 ID 转换为嵌入向量/词向量
# 导入必要的库
from torch import nn # 导入PyTorch的神经网络模块,包含构建神经网络所需的类
from transformers import AutoConfig # 导入自动配置类,用于加载模型配置
from transformers import AutoTokenizer # 导入自动标记器类,用于处理文本数据
# 定义预训练模型的检查点路径
model_ckpt = "./bert-base-uncased" # 这里指定的是BERT模型的路径
# 从预训练模型中加载标记器,以便将文本转换为模型输入
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
# 定义待处理的文本字符串
text = "time flies like an arrow" # 需要进行编码的文本示例
# 使用标记器对文本进行编码,返回PyTorch张量形式的输入
# add_special_tokens=False表示不添加特殊的标记(如[CLS]和[SEP])
inputs = tokenizer(text, return_tensors="pt", add_special_tokens=True)
# 打印编码后的输入ID,显示文本对应的词汇索引
print(inputs.input_ids)
# 从预训练模型中加载配置,包括词汇大小和隐藏层大小等信息
config = AutoConfig.from_pretrained(model_ckpt)
# 创建一个嵌入层,用于将输入ID映射到词向量(嵌入向量)
# vocab_size表示词汇表的大小,hidden_size表示嵌入向量的维度
token_emb = nn.Embedding(config.vocab_size, config.hidden_size)
#嵌入层在训练过程中承担着将离散的文本数据转化为模型可以处理的连续数值向量的任务,会学习每个词汇索引对应的词向量,这些向量将捕捉词汇的语义信息。
# 打印嵌入层的参数,查看嵌入层的维度
print(token_emb)
# 将输入ID转换为嵌入向量,inputs.input_ids是一个张量,表示文本的索引
inputs_embeds = token_emb(inputs.input_ids)
# 打印嵌入向量的尺寸,通常为(batch_size, sequence_length, hidden_size)
print(inputs_embeds.size())
tensor([[ 2051, 10029, 2066, 2019, 8612]])
Embedding(30522, 768)
# 30522:这是词汇表的大小(vocab_size)。
# 在BERT模型中,词汇表包含30522个不同的词汇或标记。这意味着嵌入层能够处理的不同单词或标记的数量是30522。
# 768:这是嵌入向量的维度(hidden_size)。每个词汇或标记在嵌入层中都被映射为一个768维的向量。这些向量用于捕捉词汇的语义信息,768维的向量能够提供足够的空间来表示复杂的语义关系。
torch.Size([1, 5, 768])
词向量是指将词语映射到一个连续向量空间中形成的表示。其定义可以概括为以下几点:
- 数值表示:每个词用一个固定维度的实数向量表示,通常为高维向量(例如100维、300维等)。
- 语义关系:词向量能够捕捉到词与词之间的语义和语法关系,类似的词在向量空间中靠得更近,而不相似的词则距离较远。
- 训练方法:词向量可以通过不同的模型训练得到,如Word2Vec、GloVe、FastText等,或者通过深度学习模型(如BERT、GPT等)自动生成。
- 应用广泛:词向量广泛应用于自然语言处理任务,如文本分类、情感分析、机器翻译等,作为输入特征提高模型性能。
计算注意力分数α
import torch # 导入PyTorch库
from math import sqrt # 导入平方根函数
# 将输入的词向量作为查询(Q)、键(K)和值(V)
Q = K = V = inputs_embeds # 使用相同的嵌入向量作为 Q、K 和 V,这里是简化了实际会成三个不同的权重矩阵
# 获取键(K)的最后一维的大小,通常是词向量的维度
dim_k = K.size(-1)
# 计算注意力分数:使用批量矩阵乘法(bmm)计算 Q 和 K 的点积
# K.transpose(1, 2) 将 K 的最后两个维度交换,以便进行矩阵乘法
# 用词向量的维度的平方根进行缩放,以防分数过大
scores = torch.bmm(Q, K.transpose(1, 2)) / sqrt(dim_k)
# 打印注意力分数的尺寸,形状为 (batch_size, sequence_length, sequence_length)
print(scores.size())
torch.Size([1, 5, 5])
根据查询QKV的值计算注意力输出
掩码会被用来调整注意力分数,屏蔽掉不需要的部分
归一化得到最终的输出,反应词与词之间的相关性
import torch # 导入PyTorch库
import torch.nn.functional as F # 导入PyTorch的函数式API
from math import sqrt # 导入平方根函数
def scaled_dot_product_attention(query, key, value, query_mask=None, key_mask=None, mask=None):
"""
计算缩放点积注意力机制的函数。
参数:
- query: 查询张量,形状为 (batch_size, seq_length, dim_k)
- key: 键张量,形状为 (batch_size, seq_length, dim_k)
- value: 值张量,形状为 (batch_size, seq_length, dim_v)
- query_mask: 可选的查询掩码,形状为 (batch_size, seq_length)
- key_mask: 可选的键掩码,形状为 (batch_size, seq_length)
- mask: 可选的综合掩码,形状为 (batch_size, seq_length, seq_length)
返回:
- 输出张量,形状为 (batch_size, seq_length, dim_v)
"""
# 获取查询张量的最后一维的大小,即键的维度
dim_k = query.size(-1)
# 计算注意力分数:使用批量矩阵乘法计算查询和键的点积
# K 的转置用于适应矩阵乘法
# 用词向量的维度的平方根进行缩放,以防分数过大
scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k)
# 如果提供了查询掩码和键掩码,则计算综合掩码
if query_mask is not None and key_mask is not None:
mask = torch.bmm(query_mask.unsqueeze(-1), key_mask.unsqueeze(1))
# 如果有掩码,将对应位置的注意力分数设置为负无穷
if mask is not None:
scores = scores.masked_fill(mask == 0, -float("inf"))
# 计算注意力权重,使用 softmax 函数归一化分数
weights = F.softmax(scores, dim=-1)
# 将注意力权重与值张量相乘,得到最终输出
return torch.bmm(weights, value) # 返回的形状为 (batch_size, seq_length, dim_v)
多头注意力机制
所谓的“多头” (Multi-head),其实就是多做几次 Scaled Dot-product Attention,然后把结果拼接。
映射到特征空间,可以通过乘权重矩阵映射到高维的空间,这样就可以更好的表示信息
from torch import nn
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, query, key, value, query_mask=None, key_mask=None, mask=None):
attn_outputs = scaled_dot_product_attention(
self.q(query), self.k(key), self.v(value), query_mask, key_mask, mask)
return attn_outputs
定义了一个多头注意力机制的类 MultiHeadAttention
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
#创建注意力头 创建多个 AttentionHead 实例
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, query, key, value, query_mask=None, key_mask=None, mask=None):
x = torch.cat([
h(query, key, value, query_mask, key_mask, mask) for h in self.heads
], dim=-1)
x = self.output_linear(x)
return x