一.背景。
在此模型之前,序列到序列的任务(如机器翻译、文本摘要等)通常采用循环神经网络(RNN)或卷积神经网络(CNN)。然而,RNN 在处理长距离依赖时存在一定的局限性(举个例子:处理第Kt个词时,需要用到K1到Kt-1的词的输出作为输入),训练时也比较耗时。而 CNN 在处理序列数据时难以捕捉到全局的依赖关系。然而这篇文章介绍的模型Transformer完全基于注意力机制,与CNN,RNN,LSTM模型对比更加简单并且高效。
二.模型架构。
Transformer 模型采用了编码器-解码器架构。先上一个论文里面的架构图,再逐步介绍其中的各个部分。
1.Embedding
Embedding是什么:
为了对字符进行计算,我们首先需要将字符(或单词)转换成一种数值表示形式。独热编码(One-Hot Encoding)是一种常用的方法之一,例如词汇表 {'猫': 0, '狗': 1, '苹果': 2}
(此处的索引012一般是根据某个词典获得,即某词典0号索引处为单词‘猫’),‘ 猫’ 的独热编码就是 [1, 0, 0]
,‘狗’ 的独热编码就是 [0, 1, 0]
,但是这样的缺点就是向量维度高且稀疏,计算效率低。如果词汇表有 10,000 个单词,那么每个独热向量的维度就是 10,000,并且是稀疏的,即大部分元素都是 0,只有一个位置是 1。
所以在深度学习特别是自然语言处理(NLP)中,我们通常会采用更加高效的嵌入表示(Embedding)。通过Embedding,每个字符(或单词)被表示为一个低维的密集向量。这些向量是通过训练得到的,可以捕捉字符(或单词)之间的语义关系。例如通过嵌入层,单词“猫”可能被表示为一个 5 维(维数由我们定义)向量 [0.0376, -0.2343, 0.1655, -0.0053, 0.1353]
。可见其特点是维度低且密集,计算效率高,并且能够捕捉语义信息。
Embedding的例子:
在 PyTorch 中,有一个函数torch.nn.Embedding(num_embedings, embedding_dim)
,其中 num_embedding 表示词表总的长度,embedding_dim 表示单词嵌入的维度,此函数会创建一个嵌入矩阵(通常是随机的),其形状为 (num_embedding , embedding_dim),给定输入张量(通常是单词索引),其形状为(batch_size, sequence_length),该层会将每个索引映射到对应的嵌入向量,返回一个形状为 (batch_size, sequence_length, embedding_dim) 的张量。
以下是例子代码:
import torch
import torch.nn as nn
# 定义词汇表大小和嵌入向量维度
vocab_size = 10 #词汇表大小
embedding_dim = 5 #向量维度
# 创建嵌入层
embedding_layer = nn.Embedding(num_embeddings=vocab_size, embedding_dim=embedding_dim)
# 输入张量(单词索引)
input_tensor = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.long) # 示例输入,形状为 (batch_size, sequence_length)
# 通过嵌入层得到嵌入向量
embedding_output = embedding_layer(input_tensor)
print(f"Input Tensor Shape: {input_tensor.shape}")
print(f"Embedding Output Shape: {embedding_output.shape}")
print(f"Embedding Output:\n{embedding_output}")
'''
输出:
Input Tensor Shape: torch.Size([2, 3])
Embedding Output Shape: torch.Size([2, 3, 5])
Embedding Output:
tensor([[[ 0.0069, 0.0465, -0.0205, 0.0080, -0.0114],
[-0.0244, 0.0404, 0.0452, -0.0027, -0.0307],
[ 0.0024, -0.0043, 0.0340, 0.0370, -0.0400]],
[[ 0.0057, -0.0015, -0.0154, -0.0306, -0.0375],
[ 0.0317, -0.0275, 0.0160, 0.0283, 0.0040],
[-0.0331, -0.0061, 0.0452, 0.0484, -0.0350]]], grad_fn=<EmbeddingBackward0>)
'''
Transformer中的Embedding
论文原文:(在嵌入层中,我们将这些权重乘以√dmodel)
在Embedding中使用 math.sqrt(self.d_model)
(即
d
\sqrt[]{d}
d) 进行缩放,是在实际实现中的一种实践,可以保持数值稳定性,确保在随后的计算中,尤其是在与模型其他部分进行交互时,不会出现数值过大或过小的问题。
- 数学解释:
以下是复现代码:(这里暂定input跟output的Embedding是一样的)
class Embedding(nn.Module):
def __init__(self, vocab_size, d_model):
# vocab_size:词表长度 d_model:嵌入维度
super(Embedding, self).__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.d_model = d_model
def forward(self, x):
# x:输入张量
# 乘以 根号dk 保持数据稳定性
return self.embedding(x) * math.sqrt(self.d_model)
2.Positional Encoding
为什么需要Positional Encoding
论文原文:(由于我们的模型不包含递归和卷积,为了使模型利用序列的顺序,我们必须注入一些关于序列中标记的相对或绝对位置的信息)
也就是attention没有时序信息,需要我们自己加入。(RNN的做法是上一个时刻的输出作为此时刻的输入以此引入时序信息)
Positional Encoding的实现
论文原文:
其中,pos 即 position,意为 token 在句中的位置,i为向量的某一维度。借助此公式再结合三角函数的性质
可以得到:
可以看出,对于 pos+k 位置的位置向量某一维 2i 或 2i+1 而言,可以表示为,pos 位置与k位置的位置向量的2i与 2i+1维的线性组合,这样的线性组合意味着位置向量中蕴含了相对位置信息。具体可以参考视频讲解。
以下是复现代码:
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout=0.1, max_len=5000):
'''初始化函数,三个参数分别是:
d_model:词嵌入维度; dropout:置0比率(位置编码与输入嵌入相加后一起作为模型的输入。模型在学习过程中会学习如何利用这些位置信息。如果位置编码没有经过 dropout 的正则化处理,模型可能会过度依赖这些位置信息,从而对训练数据记忆过深,导致在处理未见数据时表现不佳。)
max_len:每个句子的最大长度。
'''
super(PositionalEncoding, self).__init__()
#实例化dropout层,并传入参数
self.dropout = nn.Dropout(p=dropout)
#初始化一个位置编码矩阵,全为0,大小是max_len * d_model
pe = torch.zeros(max_len, d_model)
#初始化一个绝对位置矩阵,词的绝对位置即索引位置
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
#接下来就是把位置信息加入到位置编码矩阵中去,也就是把max_len * 1的position绝对位置矩阵变换成max_len * d_model形状,然后覆盖初始矩阵
#也就是max_len * 1 的矩阵去乘以一个 1 * d_modl 的变换矩阵div_term,然后再进行覆盖,这里因为位置编码可以分成奇数和偶数两部分,故可以将变换矩阵更改为 1 * (d_model / 2)的形状
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)
pe[:, 1::2] = torch.cos(position * div_term)
#这样子就得到了位置编码矩阵pe,但是要和embedding的输出相加就必须拓展一个维度
pe = pe.unsqueeze(0)
#因为无论我们输入的是什么,这个位置编码都不会改变,也就是所有的输入是公用一个位置编码的,所以这边使用self.register_buffer,其是一个用于将张量注册为模型的一部分的方法。它的主要用途是注册一些不作为模型参数的持久状态,例如在训练和推理过程中不需要更新的固定数据。
self.register_buffer('pe', pe)
def forward(self,x):
#因为一个句子有长有短,所以可以位置编码只截取到句子的实际长度即可。
x = x + self.pe[:, :x.size(1)]
#最后使用dropout防止过拟合,并返回结果。
return self.dropout(x)
实际例子
通过一个超参数比较小的例子输出并展示还是比较容易理解每一步骤的做法的。
import torch
import torch.nn as nn
import math
max_len = 10
d_model = 6
# 初始化位置编码矩阵
pe = torch.zeros(max_len, d_model)
print(pe)
'''
tensor([[0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0., 0.]])
'''
# 初始化绝对位置矩阵
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
print(position)
'''
tensor([[0.],
[1.],
[2.],
[3.],
[4.],
[5.],
[6.],
[7.],
[8.],
[9.]])
'''
# 计算变换矩阵 div_term
div_term = torch.exp(torch.arange(0, d_model, 2).float() * -(math.log(10000.0) / d_model))
print(div_term)
'''
Div term matrix:
tensor([1.0000, 0.0464, 0.0022])
'''
# 计算位置编码矩阵
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
print(pe)
'''
tensor([[ 0.0000, 1.0000, 0.0000, 1.0000, 0.0000, 1.0000],
[ 0.8415, 0.5403, 0.0464, 0.9989, 0.0022, 1.0000],
[ 0.9093, -0.4161, 0.0927, 0.9957, 0.0043, 1.0000],
[ 0.1411, -0.9900, 0.1388, 0.9903, 0.0065, 1.0000],
[-0.7568, -0.6536, 0.1846, 0.9828, 0.0086, 1.0000],
[-0.9589, 0.2837, 0.2300, 0.9732, 0.0108, 0.9999],
[-0.2794, 0.9602, 0.2749, 0.9615, 0.0129, 0.9999],
[ 0.6570, 0.7539, 0.3192, 0.9477, 0.0151, 0.9999],
[ 0.9894, -0.1455, 0.3629, 0.9318, 0.0172, 0.9999],
[ 0.4121, -0.9111, 0.4057, 0.9140, 0.0194, 0.9998]])
'''
#最后再添加一个维度
pe = pe.unsqueeze(0)
print(pe)
'''
tensor([[[ 0.0000, 1.0000, 0.0000, 1.0000, 0.0000, 1.0000],
[ 0.8415, 0.5403, 0.0464, 0.9989, 0.0022, 1.0000],
[ 0.9093, -0.4161, 0.0927, 0.9957, 0.0043, 1.0000],
[ 0.1411, -0.9900, 0.1388, 0.9903, 0.0065, 1.0000],
[-0.7568, -0.6536, 0.1846, 0.9828, 0.0086, 1.0000],
[-0.9589, 0.2837, 0.2300, 0.9732, 0.0108, 0.9999],
[-0.2794, 0.9602, 0.2749, 0.9615, 0.0129, 0.9999],
[ 0.6570, 0.7539, 0.3192, 0.9477, 0.0151, 0.9999],
[ 0.9894, -0.1455, 0.3629, 0.9318, 0.0172, 0.9999],
[ 0.4121, -0.9111, 0.4057, 0.9140, 0.0194, 0.9998]]])
'''
3.编码器Encoding
论文原文中对此架构的描述如下:
也就是说Transformer中编码器由6个相同的层堆叠而成,接下来就复现一下其中各个部分的代码。
3.1 Multi-head Self-attention Mechanism
3.1.1单头注意力机制Scaled Dot-Product Attention
在 Transformer 的编码器中,输入通常是一个完整的序列。这些输入序列通常在一个批次(batch)中处理,并且可以一次性看到整个序列。这意味着在编码器中,每个位置的输入都可以与序列中的所有其他位置进行自注意力计算。因此,编码器通常不需要掩码(Mask)。而在解码器中,是逐步生成输出序列的,为了确保生成的每个词只依赖于当前词及其之前的词,而不会看到未来的词,解码器需要使用Mask才处理掉未来的词。具体的处理过程在下面代码复现中可以看到。
论文原文中的注意力机制计算公式如下:
可以看到,注意力机制的核心思想就是基于查询(Query,Q)和键(Key,K)之间的相似度来加权值(Value,V)(当query = key = value时就是自注意力机制),也就是点乘注意力机制,与其不同的是除以了一个√d,论文原文提到说当d_model维度比较大时,这一个步骤可以减弱softmax之后的两极分化的情况(两极分化:softmax处理后会呈现相似度高的权重接近1,而相似度低的权重接近0),这样子可以防止梯度过小而导致收敛过慢。
以下是复现代码:(注释会提到Mask的做法以及过程中处理的是哪个维度)
def attention(query, key, value, mask=None):
'''
按照论文原文公式以及架构所示,输入分别为query,key,value。mask是掩码张量,在编码器中并为用到,所以设置为None
'''
#取query的最后一维的大小为d_k,也就是我们的词嵌入维度d_model
d_k = query.shape[-1]
#按照公式所示,讲query与key的转置相乘,再除以缩放系数。我们输入的维度通常是 (batch_size, seq_length, d_model),故转置的是后两个维度
scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
#判断是否需要Mask
if mask is not None:
#如果需要的话就使用msaked_fill方法,将掩码张量与scores张量每个位置一一比较,如果掩码张量处为0的,对应的scores张量就用一个很小的值来替换,这样可以让其softmax之后的结果为0
scores = scores.masked_fill(mask == 0, -1e9)
#接下来对其最后一个维度进行softmax操作
'''
为什么是最后一个维度?
在query与key的转置相乘后,也就是(batch_size,seq_length,d_model) * (batch_size,d_model,seq_length)我们得到的应该是一个(batch_siez,seq_length,seq_length)的张量,
第一个seq_length维度表示的是查询位置的数量,而第二个seq_length维度表示的则是键位置的数量,根据矩阵相乘的规律便可得知。此时按照矩阵计算的方式还可得知,其第二个seq_length维度还是点积的结果,也就是相似度分数。故对其最后一个维度进行softmax
'''
p_attn = F.softmax(scores, dim=-1)
#最后就是根据公式将softmax后的结果与value张量相乘并返回,同时返回注意力权重(可供查看)
return torch.matmul(p_attn, value), p_attn
3.1.2 多头注意力机制Multi-Head Attention
这里的多头并不是指一整个完全输入的序列经过几个不同的Scaled Dot-Product Attention之后再组合起来,而是将输入的序列最后一维平均分割成h份去进行Scaled Dot-Product Attention,再结合起来。这么做的好处是在上面讲到的单个头的注意力机制中并没有过多的参数可以学习到,而使用这样的多头注意力机制,在投射到低维的时候就有h组参数可以学习,可以应付更多种不同的输入情况,增强了模型的表示能力和学习能力。
论文原文计算方式如下:
复现代码如下:
class MultiHeadedAttention(nn.Module):
def __init__(self, h, d_model, dropout=0.1):
'''
:param h: 头个数
:param d_model: 词嵌入的维度
:param dropout:置0比率默认0.1
'''
super(MultiHeadedAttention, self).__init__()
self.d_model = d_model
self.h = h
#得到每个头获得的分割词向量维度d_k “多头”的操作
self.d_k = d_model // h
#先定义Linear,它的内部变换矩阵是d_model * d_model,一个方阵可以让输入输出维度匹配。然后按照架构图所示我们需要四个
self.linears = nn.ModuleList(nn.Linear(d_model, d_model) for _ in range(4))
#设置为None是因为这个代表最后得到的注意力张量,而现在还没有
self.attn = None
#置0比率
self.dropout = nn.Dropout(dropout)
def forward(self, query, key, value, mask=None):
'''
:param query:输入参数
:param key: 输入参数
:param value:输入参数
:param mask: 是否进行掩码,跟上述Attention中讲到的Mask是一个东西
'''
#掩码判断
if mask is not None:
#这一行代码的作用是增加掩码张量的一个维度。假设 mask 的形状为 (batch_size, seq_len),经过 unsqueeze(1) 操作后,
# 形状变为 (batch_size, 1, seq_len)。增加维度的原因是为了与后续计算中需要匹配的维度一致。
mask = mask.unsqueeze(1)
#接着获取一个batch_size的变量,可以通过query的维度获得
batch_size = query.size(0)
'''
根据架构图所示,首先是先经过Linear层进行线性变换 即分割成多头
先将q,k,v(原始形状为 (batch_size, seq_len, d_model))变换为(batch_size, seq_len, h, d_k)
再将维度seq_len 与 维度h 互换维度位置变成(batch_size, h, seq_len, d_k),这样做的目的是将每个头的数据集中在一起,以便后续计算注意力分数时每个头可以独立进行处理。
'''
query, key, value = [l(x).view(batch_size, -1, self.h, self.d_k).transpose(1, 2) for l, x in zip(self.linears[:3], (query, key, value))]
#接下来就是进入Attention,直接调用之前实现的Attention函数
x, self.attn = attention(query, key, value, mask=mask)
#此时的注意力张量x还是四维的,需要变换为原来的形状
#进行transpose操作之后需要先使用contiguous方法才能进行view方法,这是因为 view 方法要求张量在内存中是连续的,而 transpose 等操作可能会导致张量在内存中不再是连续的。
x = x.transpose(1, 2).contiguous().view(batch_size, -1, self.h * self.d_k)
#最后通过一个Linear层并返回
return self.linears[-1](x)
接下来再提供一个实例:
# 初始化参数
h = 8 # 头的个数
d_model = 512 # 词嵌入的维度
dropout = 0.1
# 实例化 MultiHeadedAttention 类
mha = MultiHeadedAttention(h, d_model, dropout)
# 创建示例数据
batch_size = 64
seq_len = 10
query = torch.randn(batch_size, seq_len, d_model)
key = torch.randn(batch_size, seq_len, d_model)
value = torch.randn(batch_size, seq_len, d_model)
mask = None
# 前向传播
output = mha(query, key, value, mask)
# 打印输出形状
print(output.shape)
'''
torch.Size([64, 10, 512])
'''
3.2 Position-wise Feed-Forward Networks
这里其实就是一个没有隐藏层的MLP,使模型表达能力更强,并且每个位置独立处理,不依赖其他位置的输入。
论文原文公式如下:
复现代码如下:
class PositionwiseFeedForward(nn.Module):
def __init__(self, d_model, d_ff, dropout=0.1):
'''
:param d_model:第一个线性层的输入维度
:param d_ff: 第二个线性层的输入维度
:param dropout: 置0比率
'''
super(PositionwiseFeedForward, self).__init__()
#定义两个线性层
self.l1 = nn.Linear(d_model, d_ff)
self.l2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
#根据论文公式得到,这里的relu(x)函数就是max(0,x)
return self.l2(self.dropout(F.relu(self.l1(x))))
3.3 LayerNorm
LN与BN的区别:
两者都是归一化,但是适用不同的场景,对应一个输入为(batch_size,seq_length,features)时,BN是对每个 mini-batch 对每个样本的所有特征维度进行归一化。画一张图直观的表示一下:
这里使用LN的原因
因为文本输入的每一个seq_length是不等长的,这会导致BN每一次的归一化的值可能差距过大,而LN会好很多。
复现代码如下:
class LayerNorm(nn.Module):
def __init__(self, features, eps=1e-6):
super(LayerNorm, self).__init__()
self.eps = eps
self.gamma = nn.Parameter(torch.ones(features))
self.beta = nn.Parameter(torch.zeros(features))
def forward(self, x):
# 计算均值
mean = x.mean(-1, keepdim=True)
# 计算标准差
std = x.std(-1, keepdim=True)
# 归一化
normalized_x = (x - mean) / (std + self.eps)
# 缩放和平移
return self.gamma * normalized_x + self.beta
3.4残差连接
论文原文公式如下: