手撕Transformer之Positional Encoding

文摘   科技   2024-09-25 20:49   江苏  
点击蓝字
 
关注我们










01


引言



本文是手撕Transformer系列的第二篇。它从头开始介绍位置编码。然后,它解释了 PyTorch 如何实现位置编码。

闲话少说,我们直接开始吧!








02


背景介绍


位置编码(Positional Encoding)多用于为序列中的每个标记Token提供相对位置信息。在阅读句子时,每个单词都依赖于其周围的单词。例如,有些单词在不同的语境中有不同的含义,因此模型应该能够理解这些变化以及每个单词所依赖的语境。比如单词"trunk "就是一个例子,在一种情况下,它可以指大象用鼻子来喝水。在另一种情况下,它可以指树的树干被闪电击中。

由于模型使用长度为 d_model 的嵌入向量来表示每个单词,因此任何位置编码都必须兼容该向量。使用整数似乎很自然,第一个标记Token的位置编码为 0,第二个标记Token的位置编码为 1,以此类推。但是,这个数字很快就会增大,而且不容易被添加到嵌入矩阵中。取而代之的是为每个位置创建一个位置编码向量,这意味着可以创建一个位置编码矩阵来表示一个单词可能出现的所有位置。
为了确保每个位置都有一个唯一的表示,《Attention is all you need》的作者使用正弦和余弦函数为序列中的每个位置生成一个唯一的向量。虽然这看起来很奇怪,但有几个原因可以说明它的用处。首先,正弦和余弦的输出范围为 [-1, 1],这是经过归一化处理后的。它不会像整数那样增长到难以管理的大小。其次,由于每个位置都会生成唯一的表示,因此无需进行额外的训练。

用于生成位置编码向量的方程下图所示看似复杂,但它们只是正弦和余弦函数的变种版。位置嵌入向量可以表示的最大位置数将用 L 表示:


上述公式说明,对于每个位置编码向量,每两个元素中的偶数元素设置为 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 sequencestokenized_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 tensortensor_sequences = torch.tensor(indexed_sequences).long()# vocab sizevocab_size = len(stoi)# embedding dimensionsd_model = 4
# create the embeddingslut = nn.Embedding(vocab_size, d_model) # look-up table (lut)# embed the sequenceword_embeddings = lut(tensor_sequences)print(word_embeddings)

输出如下:

下一步是通过位置编码对每个token在每个句子序列中的位置进行编码。下面的函数沿用了上面变量的定义。唯一值得一提的是,L 被记为 max_length。它通常被设置为一个以千/万为单位的整数值,以确保几乎每个序列都能被适当编码。这确保了相同的位置编码矩阵可以适用于不同长度的输入序列。
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 pe[k, 2*i] = math.sin(theta)
# odd dims: cos pe[k, 2*i+1] = math.cos(theta)
return pe
# maximum sequence lengthmax_length = 10n = 100encodings = 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 tokensseq_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 = 4n = 100div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(n) / d_model))
这段简短的代码可以用来生成所有需要的除数。在本例中,d_model 设置为 4,n 设置为 100。输出结果是两个除数:
tensor([1.0000, 0.1000])

接着大家就可以通过利用 PyTorch 的索引功能,用几行代码来创建整个位置编码矩阵。我们首先利用以下代码来生成从 k 到 L-1 的每个位置表示。

max_length = 10# generate the positions into a column matrixk = torch.arange(0, max_length).unsqueeze(1)   print(k)

输出如下:

tensor([[0],        [1],        [2],        [3],        [4],        [5],        [6],        [7],        [8],        [9]]


有了位置表示和相应的除数,就可以轻松计算正弦和余弦函数的输入了,如下:


此时,只需要将 k 和 div_term 相乘,可以计算出每个位置的输入。PyTorch 会自动广播矩阵,以便进行乘法运算。如下图所示:

k*div_term

计算的输出结果如上图所示。剩下要做的就是将上述结果输入到 cos 和 sin 函数中,并将其保存到矩阵中。

我们可以先创建一个适当大小的空矩阵:
# generate an empty tensorpe = 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 pe[:, 0::2] = torch.sin(k * div_term) # set the odd values pe[:, 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
虽然它更为复杂,但 PyTorch 使用这种实现方式可以增强代码运行时的性能。





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)

有了上面的介绍,希望大家都可以看懂位置编码的代码实现。该系列的下一篇文章是 "多头注意力层"。

请不要忘记点赞和关注,以获取更多信息!





点击上方小卡片关注我




添加个人微信,进专属粉丝群!



AI算法之道
一个专注于深度学习、计算机视觉和自动驾驶感知算法的公众号,涵盖视觉CV、神经网络、模式识别等方面,包括相应的硬件和软件配置,以及开源项目等。
 最新文章