01
引言
本文是手撕Transformer系列的第二篇。它从头开始介绍位置编码。然后,它解释了 PyTorch 如何实现位置编码。
02
背景介绍
位置编码(Positional Encoding)多用于为序列中的每个标记Token提供相对位置信息。在阅读句子时,每个单词都依赖于其周围的单词。例如,有些单词在不同的语境中有不同的含义,因此模型应该能够理解这些变化以及每个单词所依赖的语境。比如单词"trunk "就是一个例子,在一种情况下,它可以指大象用鼻子来喝水。在另一种情况下,它可以指树的树干被闪电击中。
上述公式说明,对于每个位置编码向量,每两个元素中的偶数元素设置为 PE(k,2i),奇数元素设置为 PE(k,2i+1)。然后,重复上述步骤,直到该向量中有 d_model 个元素为止。
每个位置编码向量的维数 d_model 与嵌入向量的维数相同。k 代表位置,从 0 到 L-1。i 可以设置的最高值是 d_model 除以 2,因为位置嵌入公式中的每个元素的计算公式都是交替的。在下图中,以下参数用于计算 6 个标记序列的位置编码向量:
n = 10,000
L = 6
d_model = 4
k随嵌入矩阵中的每一行而变化,从0到5,最大长度为6。每个向量有d_model=4个元素。
03
简单实现
本实现将直接基于上一篇文章中的实现。下面代码的输出表示句子个数为bs=3个,每个句子的token数目为seq_len=6个,每个token的嵌入向量的维度d_model=4。
# tokenize the sequences
tokenized_sequences = [tokenize(seq) for seq in sequences]
# index the sequences
indexed_sequences = [[stoi[word] for word in seq] for seq in tokenized_sequences]
# convert the sequences to a tensor
tensor_sequences = torch.tensor(indexed_sequences).long()
# vocab size
vocab_size = len(stoi)
# embedding dimensions
d_model = 4
# create the embeddings
lut = nn.Embedding(vocab_size, d_model) # look-up table (lut)
# embed the sequence
word_embeddings = lut(tensor_sequences)
print(word_embeddings)
输出如下:
def gen_pe(max_length, d_model, n):
# generate an empty matrix for the positional encodings (pe)
pe = np.zeros(max_length*d_model).reshape(max_length, d_model)
# for each position
for k in np.arange(max_length):
# for each dimension
for i in np.arange(d_model//2):
# calculate the internal value for sin and cos
theta = k / (n ** ((2*i)/d_model))
# even dims: sin
2*i] = math.sin(theta)
# odd dims: cos
2*i+1] = math.cos(theta)
return pe
# maximum sequence length
max_length = 10
n = 100
encodings = gen_pe(max_length, d_model, n)
print(encodings)
上述代码运行后,输出为包含 10 个位置的位置编码向量。每个编码向量维度为d_model,结果如下:
如前文所述,上述例子中我们将max_length 设置为 10。它确保了如果另一个应用场景下的输入序列的长度为 7、8、9 或 10时,我们仍然可以使用相同的位置编码矩阵。只需将其切成适当的长度即可。下面展示了我们例子中的输入序列 seq_length=6时,我们切分后编码矩阵的代码如下:
# select the first six tokens
seq_length = word_embeddings.shape[1]
encodings[:seq_length]
得到切分后的编码矩阵为:
本例中的单词嵌入向量的维度为 (3,6,4),位置编码被切成(6,4),然后广播该矩阵,将二者相加,我们就可以创建下图中的 (3, 6, 4) 最终编码矩阵。
代码如下:
embeddings = word_embeddings + encodings[:seq_length]
print(embeddings)
单词编码向量和位置编码向量求和后的最终嵌入向量为:
该输出将被传递到模型的下一层,也就是下一篇文章将介绍的 "多头注意力"。然而,由于本文实现位置编码的过程使用了嵌套循环,这种实际应用时并不高效,尤其是在使用较大的 d_model 和 max_length 值时。取而代之的是Pytorch中更加高效的实现。
04
Pytorch高效实现
我们首先来回顾下对数的一些基本计算规则,如下图所示:
为了利用 PyTorch 的功能,需要利用对数规则修改原始方程,特别是除数操作。
要修改除数,首先利用指数性质,把 n 移入分子。然后,使用规则 7 将整个等式提升为 e 的指数。然后进行简化,得出结果
这一点非常重要,因为它可以用来一次性生成位置编码的所有除数。从下面可以看出,4 维嵌入只需要两个除数,因为除数每 2i 才会改变一次,其中 i 是维数。这种情况在每个位置上都重复出现:
由于 i 的最大取值只能是 d_model 除以 2,因此只需计算一次:d_model = 4
n = 100
div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(n) / d_model))
tensor([1.0000, 0.1000])
接着大家就可以通过利用 PyTorch 的索引功能,用几行代码来创建整个位置编码矩阵。我们首先利用以下代码来生成从 k 到 L-1 的每个位置表示。
max_length = 10
# generate the positions into a column matrix
k = torch.arange(0, max_length).unsqueeze(1)
print(k)
输出如下:
tensor([[0],
[1],
[2],
[3],
[4],
[5],
[6],
[7],
[8],
[9]]
k*div_term
计算的输出结果如上图所示。剩下要做的就是将上述结果输入到 cos 和 sin 函数中,并将其保存到矩阵中。
# generate an empty tensor
pe = torch.zeros(max_length, d_model)
print(pe)
现在,可以用 pe[:, 0::2] 来选择偶数列,也就是 sin的输入。这将告诉 PyTorch 选择每一行和每一偶数列。同样的方法也可以用于奇数列,即 cos的输入pe[:, 1::2]。这再次告诉 PyTorch 选择每一行和每一奇数列。k*div_term 的结果中存储了所有必要的输入,代码实现如下:
# set the odd values (columns 1 and 3)
pe[:, 0::2] = torch.sin(k * div_term)
# set the even values (columns 2 and 4)
pe[:, 1::2] = torch.cos(k * div_term)
# add a dimension for broadcasting across sequences: optional
pe = pe.unsqueeze(0)
print(pe)
可以看到,这些值与通过嵌套 for 循环获取的值完全相同。
看懂后,我们将上述过程封装成函数,如下所示:
def gen_pe(max_length, d_model, n):
# calculate the div_term
div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(n) / d_model))
# generate the positions into a column matrix
k = torch.arange(0, max_length).unsqueeze(1)
# generate an empty tensor
pe = torch.zeros(max_length, d_model)
# set the even values
, 0::2] = torch.sin(k * div_term) :
# set the odd values
, 1::2] = torch.cos(k * div_term) :
# add a dimension
pe = pe.unsqueeze(0)
# the output has a shape of (1, max_length, d_model)
return pe
05
官方实现
既然所有的艰苦工作都已完成,那么官方实现起来也就简单明了了。以下代码源自于 The Annotated Transformer 。
网址:https://nlp.seas.harvard.edu/annotated-transformer/#embeddings-and-softmax
请注意,这里n 的默认值是 10,000,默认 max_length 是 5,000。
这个官方的版本还包含了dropout层,它以给定的概率p随机清零其输入的一些元素。这有助于正则化,并防止过拟合。代码如下:
class PositionalEncoding(nn.Module):
def __init__(self, d_model: int, dropout: float = 0.1, max_length: int = 5000):
"""
Args:
d_model: dimension of embeddings
dropout: randomly zeroes-out some of the input
max_length: max sequence length
"""
# inherit from Module
super().__init__()
# initialize dropout
self.dropout = nn.Dropout(p=dropout)
# create tensor of 0s
pe = torch.zeros(max_length, d_model)
# create position column
k = torch.arange(0, max_length).unsqueeze(1)
# calc divisor for positional encoding
div_term = torch.exp(
torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model)
)
# calc sine on even indices
pe[:, 0::2] = torch.sin(k * div_term)
# calc cosine on odd indices
pe[:, 1::2] = torch.cos(k * div_term)
# add dimension
pe = pe.unsqueeze(0)
# buffers are saved in state_dict but not trained by the optimizer
self.register_buffer("pe", pe)
def forward(self, x: Tensor):
"""
Args:
x: embeddings (batch_size, seq_length, d_model)
Returns:
embeddings + positional encodings (batch_size, seq_length, d_model)
"""
# add positional encoding to the embeddings
x = x + self.pe[:, : x.size(1)].requires_grad_(False)
# perform dropout
return self.dropout(x)
点击上方小卡片关注我
添加个人微信,进专属粉丝群!