Transformer自注意力机制详解
1.《Attention Is All You Need》论文精读
以前的方法(例如RNN、LSTM等):
- 主流序列转导模型基于复杂的CNN或RNN,包括编码器和解码器。
- 有的模型使用注意力机制连接编码器和解码器,达到了最优性能。
缺点:
- 难以并行
- 时序中过早的信息容易被丢弃
- 内存开销大
之前语言模型和机器翻译的方法和不足之处
方法: RNN、LSTM、GRU、Encoder-Decoder
不足:
- 从左到右一步步计算,因此难以并行计算
- 过早的历史信息可能被丢弃,时序信息一步一步向后传递
- 内存开销大,训练时间慢
CNN代替RNN
优点:
- 减小时序计算
- 可以输出多通道
问题:
卷积的感受野是一定的,距离间隔较远的话就需要多次卷积才能将两个远距离的像素结合起来,所以对长时序来讲比较难。
自注意力机制:
有时也称为内部注意力,是一种将单个序列的不同位置关联起来以计算序列表示的注意机制。 自注意力已成功用于各种任务,包括阅读理解、抽象摘要、文本蕴涵和学习与任务无关的句子表示。‘
端到端记忆网络:
基于循环注意机制而不是序列对齐循环,并且已被证明在简单语言问答和语言建模任务中表现良好。
Transformer优点:
用注意力机制可以直接看一层的数据,就规避了CNN的那个缺点。
2.自注意力机制
向量的内积:
向量的内积(也称为点积或数量积)是向量代数中的一种运算,用于将两个向量映射到一个标量(即实数)。其表征两个向量的夹角,表征一个向量在另一个向量上的投影。
一个矩阵 𝑊与其自身的转置相乘的意义:
可以表示这两个列向量之间的相似程度或相关性。矩阵可以看作由一些向量组成,一个矩阵乘以它自己转置的运算,其实可以看成这些向量分别与其他向量计算内积。
键值对(Key-Value)注意力:
向量的内积表示两个向量的夹角,投影值大,意味两个向量相关度高。考虑,如果两个向量夹角是90°,那么这两个向量线性无关,完全没有相关性。更进一步,这两个向量是词向量,是词在高维空间的数值映射。词向量之间相关度高表示什么?是不是在一定程度上(不是完全)表示,在关注词A的时候,应给予词B更多的关注?
softmax的意义就是归一化处理,softmax之后,这些数字的和为1了,那么attention的核心机制是什么?那不就是加权求和么?那么权重怎么来的呢?就是这些归一化之后的数字。
完整键值对(Key-Value)注意力计算过程:
这里的为自己设置数据的键值对注意力计算过程,首先输入向量(A, B, C)分别代表三个词向量,通过WQ、WK和WV分别计算出Q、K和V,其中WQ、WK和WV为模型参数,需要训练得到。然后将Q和K的转置相乘得到Query和Key之间的相似性,之后将Q(KT)做softmax操作,得到加权求和部分。之后将这部分乘以V得到最终输出。
代码实现:
#自注意力机制与上图对应,这里为了清楚没有使用/ np.sqrt(d_k)操作
class ScaledDotProductAttention(nn.Module):
def __init__(self):
super(ScaledDotProductAttention, self).__init__()
def forward(self, Q, K, V, attn_mask=None):
d_k = Q.size(-1)
scores = torch.matmul(Q, K.transpose(-1, -2)) #/ np.sqrt(d_k)
if attn_mask is not None:
scores.masked_fill_(attn_mask, -1e9)
attn = nn.Softmax(dim=-1)(scores)
context = torch.matmul(attn, V)
return context, attn
# 多头注意力机制的实现
class MultiHeadAttention(nn.Module):
def __init__(self):
super(MultiHeadAttention, self).__init__()
# W_Q:用于将输入转换为查询向量(Query),输出维度为d_k * n_heads。
# W_K:用于将输入转换为键向量(Key),输出维度为d_k * n_heads。
# W_V:用于将输入转换为值向量(Value),输出维度为d_v * n_heads。
# fc:用于将多头注意力机制的输出连接并转换回d_model维度。
self.W_Q = nn.Linear(d_model, d_k * n_heads, bias=False)
self.W_K = nn.Linear(d_model, d_k * n_heads, bias=False)
self.W_V = nn.Linear(d_model, d_v * n_heads, bias=False)
self.fc = nn.Linear(n_heads * d_v, d_model, bias=False)
def forward(self, input_Q, input_K, input_V, attn_mask):
residual, batch_size = input_Q, input_Q.size(0)
Q = self.W_Q(input_Q).view(batch_size, -1, n_heads, d_k).transpose(1, 2)
K = self.W_K(input_K).view(batch_size, -1, n_heads, d_k).transpose(1, 2)
V = self.W_V(input_V).view(batch_size, -1, n_heads, d_v).transpose(1, 2)
attn_mask = attn_mask.unsqueeze(1).repeat(1, n_heads, 1, 1)
context, attn = ScaledDotProductAttention()(Q, K, V, attn_mask)
context = context.transpose(1, 2).reshape(batch_size, -1, n_heads * d_v)
output = self.fc(context)
return nn.LayerNorm(d_model).to(device)(output + residual), attn
3.位置编码解释
在Transformer出现以前,NLP任务大多是以RNN、LSTM为代表的循环处理方式,即一个token一个token的输入到模型当中。模型本身是一种顺序结构,天生就包含了token在序列中的位置信息。但是这种模型有很多天生的缺陷,比如:
1.会出现“遗忘”的现象,无法支持长时间序列,虽然LSTM在一定程度上缓解了这种现象,但是这种缺陷仍然存在;
2.句子越靠后的token对结果的影响越大;
3.只能利用上文信息,不能获取下文信息;
4.计算的时间复杂度比较高,循环网络是一个token一个token的输入的,也就是句子有多长就要循环多少遍;
为了解决上述问题,Transformer出现了,Transformer将token序列一次性输入到模型,不使用循环的形式。因为是一次性接收所有token作为输入进行并行处理,“遗忘”的问题没有了、所有的token都一视同仁了、上下文的信息能同时获取到、时间复杂度也降下来了。但是这又出现了新的问题,因为所有token一视同仁了,模型就没有办法知道每个token在句子中的相对和绝对的位置信息,而位置关系对于NLP任务来说是有着决定性影响的。
为了解决这个问题,Transformer把token的顺序信号加到词向量上帮助模型学习这些信息,这就位置编码(Positional Encoding)。
位置编码分类:
位置编码用来标记token的前后顺序,总的来说,位置编码分为两个类型:绝对位置编码和相对位置编码。
1.绝对位置编码
绝对位置编码为序列中的每个位置分配一个唯一的编码,这种编码直接反映了元素在序列中的绝对位置。
最简单的想法就是从1开始向后排列,我们使用单字分词,如:
我 把 小 姐 姐 按 在 地 上 摩 擦
1 2 3 4 5 6 7 8 9 10 11
但是这种方式存在很大的问题,句子越长,后面的值越大,数字越大说明这个位置占的权重也越大,这样的方式无法凸显每个位置的真实的权重,所以这种方式基本没有人用。
基于正弦和余弦函数的编码:
由Transformer模型提出,使用正弦和余弦函数的不同频率来为序列中的每个位置生成唯一的编码。这种方法的优点是能够支持到任意长度的序列,并且模型可以从编码中推断出位置信息。
可学习的位置编码:
一些模型选择通过训练过程中学习位置编码,而不是使用预定义的函数。这种方法允许模型自适应地优化位置编码,以最适合特定任务的方式。但是这种方式一般要求固定输入长度,而且可解释性差,所以使用的也不多。
2.相对位置编码
函数型位置编码通过输入token的位置信息,得到相应的位置编码。这种编码方式使模型能够更关注元素之间的相对位置关系。
虽然原始的Transformer模型使用的是绝对位置编码,但后续的研究和变体模型中已经引入了相对位置编码的概念,以期望提高模型对序列中元素之间相对位置关系的理解能力。
一个好的位置编码方式通常需要满足以下条件:
1.它应当为每个时间步(单词在句子中的位置)输出唯一的编码。
2.在不同长度的句子中,任何两个时间步之间的距离都应保持一致。
3.这个方法应当能够推广到任意长的句子,即位置编码的数值应当是有界的。
4.位置编码应当是确定的,即对于相同长度的输入,应当输出相同的位置编码。
Transformer 中的位置编码:
Transformer 中的位置编码方式满足上述所有条件,是一种简单而有效的位置编码方式。它没有为每个时间步输出单一的数字,而是为每个时间步输出一个 d 维向量,这个向量的维度与 Transformer 的词向量维度相同,这个向量被加到输入的单词向量中,从而为单词向量添加了位置信息。
代码实现:
import torch
import math
def positional_encoding_norm(max_len, d_model):
# 创建一个[max_len, d_model]的全0张量
pe = torch.zeros(max_len, d_model)
# 创建一个[max_len, 1]的float张量
position = torch.arange(0, max_len).unsqueeze(1).float()
# 计算div_term,div_term为10000^(2i/d)
div = 10000.0 ** (torch.arange(0, d_model, 2).float() / d_model)
pe[:, 0::2] = torch.sin(position / div) # 切片操作 0::2 表示从索引0开始,每隔2个取一个元素。
pe[:, 1::2] = torch.cos(position / div) # 切片操作 1::2 表示从索引1开始,每隔2个取一个元素。
# 输出结果
return pe
def positional_encoding_optimize(max_len, d_model):
# 创建一个[max_len, d_model]的全0张量
pe = torch.zeros(max_len, d_model)
# 创建一个[max_len, 1]的float张量
position = torch.arange(0, max_len).unsqueeze(1).float()
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term) # 切片操作 0::2 表示从索引0开始,每隔2个取一个元素。
pe[:, 1::2] = torch.cos(position * div_term) # 切片操作 1::2 表示从索引1开始,每隔2个取一个元素。
# 输出结果
return pe
# 示例
max_len = 4 # 序列长度
d_model = 6 # 模型维度
pe = positional_encoding_optimize(max_len, d_model)
print(pe)