了解位置编码背后的数学原理和直觉

Transformer是一种深度学习架构,它利用注意力机制来学习数据元素之间的关系。它由一个编码器和一个解码器组成,与传统的循环神经网络(RNN)或卷积神经网络(CNN)不同,它可以并行处理输入序列,而不依赖于顺序处理。Transformer模型的一个重要组成部分是位置编码。这种方法能够将位置信息添加到词嵌入中,使模型能够理解序列中单词的顺序。这一点至关重要,因为默认情况下,Transformer是并行处理模型,它本身并不理解语言的顺序性。在本文中,我们将描述位置编码背后的直觉。此外,我们旨在使用Python对位置编码背后的数学概念进行可视化理解。

位置编码

在自然语言处理中,句子中单词的位置或顺序非常重要,因为它规定了句子的语法和语义。循环神经网络(RNN)按顺序处理句子,这使它们能够考虑单词的顺序。然而,这种顺序处理存在一些缺点。它可能会使训练RNN的计算成本很高,特别是对于长句子。此外,RNN可能会受到梯度消失问题的影响,即在网络处理更多单词时,句子前面部分的信息会被稀释或丢失。

相反,Transformer架构使用多头自注意力机制,这使得它能够同时处理句子中的所有单词,而不是按顺序处理。这种并行处理可以使训练过程更快,并且不太容易受到梯度消失问题的影响。然而,由于所有单词都是同时处理的,Transformer模型无法感知句子中单词的顺序,因此它需要一个额外的机制来跟踪单词的顺序。

位置编码是一种将单词顺序信息注入Transformer模型的关键机制。图1显示了位置编码在Transformer架构中的位置。它分别应用于输入和输出嵌入,在它们进入编码器和解码器之前。

它向每个嵌入添加一个向量,该向量表示单词在句子中的位置,如图2所示。输入句子首先进行分词,也就是说,它被分解为单个单词或子词单元。然后,每个词元被转换为一个嵌入向量,该向量表示该词元的含义。接下来,对于每个词元,生成一个位置编码向量。这个向量仅由词元在句子中的位置决定,因此,不同的位置会得到不同的唯一向量。

嵌入向量和相应的位置编码向量按元素相加。结果是一个具有位置感知的嵌入向量,它同时携带了词元的含义和位置信息。这些具有位置感知的向量随后被发送到Transformer的编码器和解码器部分(图1)。

现在让我们看看位置编码是如何定义的。我们知道位置编码是一个添加到词元嵌入向量的向量。因此,它们应该具有相同数量的元素。让我们假设嵌入向量的维度是一个由d_model表示的偶数。所以,位置编码向量将具有相同的维度。假设pos是一个整数变量,表示序列中词元的位置。我们还假设它从0开始(图3)。

PE_pos为位置pos处词元的位置编码向量。我们知道它是一个具有d_model个元素的向量。假设j是这个向量中元素的索引,并且让j从0开始。现在,PE_pos在索引j = 2ij = 2i + 1处的元素定义如下:

由于j从0开始,i的范围是(0 \dots d_{model}/2 - 1)。使用这些公式,我们现在可以为词元创建整个位置编码向量:

但是为什么我们要使用三角函数来构建位置编码向量呢?为什么我们要在这个向量中配对正弦和余弦函数呢?三角函数具有周期性,这意味着它们在规则的间隔内重复其值。周期性可以用来设计一个计数器。

老式的汽油泵通常配备机械计数器来显示已分配的燃油量。这些计数器使用齿轮和轮子等物理机制来跟踪燃油流量,以加仑或升为单位显示总量。计数器本身通常有一系列旋转的轮子,其边缘标有数字0到9。最右边的轮子在每次事件发生时移动一个增量。当它完成一次旋转时,它会回到0,但会使左边的下一个轮子移动一个增量(图4)。同样,当每个轮子达到9并移动到0时,左边相邻的轮子会移动一个增量。因此,每个轮子在从9回到0时都表现出周期性行为。

图5展示了一个类似的机械计数器机制,其中使用指针而不是轮子来显示每个数字。当然,所有指针都同时移动,类似于时钟的指针。然而,每个指针的移动速度都比左边相邻的指针快。当每个指针完成一次旋转并回到0时,左边的下一个指针会移动一个增量。

公式2中的位置编码向量实现了一种类似的机制来对序列中的词元进行计数。为了理解这种机制,我们首先绘制一个以原点为中心、半径为1的单位圆,如图6所示。

设(p_x)和(p_y)表示单位圆上一点(p)的坐标,并且设从原点到(p)的射线与正(x)轴形成一个角度(\theta)。现在我们可以使用(\theta)来找到(p)的坐标:

我们现在可以使用这个概念来理解位置编码公式。在公式1中,每对余弦和正弦函数可以表示单位圆上一个点的位置(图7)。因此,位置编码向量在某种程度上类似于图5中所示的机械计数器。在这里,每对正弦和余弦函数扮演着图5中指针的角色。

现在我们已经理解了位置编码,我们可以在Python中实现它。清单1中的positional_encoding()函数创建给定序列的位置编码向量。请注意,我们只需要知道序列的长度(由max_len表示的词元数量)和嵌入向量的维度(d_model),就可以创建公式1中的位置编码向量。词元本身及其相应的嵌入向量不会改变位置编码向量。

# 清单1
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle

def positional_encoding(d_model, max_len):
    pos_arr = np.arange(0, max_len).reshape(-1, 1)
    i_arr = np.arange(0, d_model, 2)
    w = 1 / 10000**(2*i_arr/d_model)
    pe = np.zeros((max_len, d_model))
    pe[:, 0::2] = np.sin(pos_arr*w)
    pe[:, 1::2] = np.cos(pos_arr*w)
    return pe

接下来,我们可以使用这个函数来创建一个有5个词元且d_model = 6的序列的位置编码向量。

# 清单2
pe = positional_encoding(6, 5)
np.round(pe, 4)
array([[ 0.    ,  1.    ,  0.    ,  1.    ,  0.    ,  1.    ],
       [ 0.8415,  0.5403,  0.0022,  1.    ,  0.    ,  1.    ],
       [ 0.9093, -0.4161,  0.0043,  1.    ,  0.    ,  1.    ],
       [ 0.1411, -0.99  ,  0.0065,  1.    ,  0.    ,  1.    ],
       [-0.7568, -0.6536,  0.0086,  1.    ,  0.    ,  1.    ]])

positional_encoding()的输出是一个二维数组,其中每一行给出了序列中一个词元的位置编码向量。清单3可视化了一个长度为7且d_model = 20的给定序列的位置编码向量。我们采用与图7相同的可视化方法,结果如图8所示。

# 清单3
d_model = 20
max_len = 7
pe = positional_encoding(d_model, max_len)
fig, ax = plt.subplots(nrows=max_len, ncols=int(d_model/2),
                       figsize=(12, 11), sharex=True,sharey=True)
fig.subplots_adjust(wspace=0.1, hspace=0.005)
ax = ax.flatten()
r_array = np.arange(1, d_model/2+1)
i = 0
for token in pe:
    points = token.reshape(-1,2)
    for j in range(len(points)):
        ax[i].axhline(0, color='grey', linewidth=0.5)
        ax[i].axvline(0, color='grey', linewidth=0.5)
        circle = Circle((0, 0), 1, facecolor='none',
                        edgecolor='black', linewidth=0.5)
        ax[i].add_patch(circle)
        ax[i].scatter(points[j, 0], points[j,1], s=15)
        ax[i].set_aspect('equal')
        ax[i].set_aspect('equal')
        i += 1
for i in range(0, max_len):
    ax[i*int(d_model/2)].set_ylabel('Pos='+str(i), labelpad=20,
                                     fontsize=10, rotation=0)
for i in range(0, int(d_model/2)):
    ax[i].set_title('j={},{}'.format(2*i, 2*i+1), pad=12, fontsize=10)
plt.show()

如你所见,它是一个由圆圈组成的二维数组。这个数组中的每一行代表一个词元的位置编码向量。因此,第一行代表第一个词元(Pos=0),第二行代表第二个词元(Pos=1),依此类推。在每一行中,我们有10个圆圈(因为d_model = 20),并且每个圆圈代表位置编码向量中的一对正弦和余弦函数(参考图7)。例如,最左边一列的圆圈代表词元位置编码向量的前两个元素(图7中j = 0,1),最左边最后一列的圆圈代表这个向量的最后两个元素(图7中j = d_model - 2, d_model - 1)。

我们还观察到,随着词元位置(pos)的增加,第一个圆圈上的点(对应于j = 0,1)开始移动,并且它的移动速度比第二个圆圈上的点(对应于j = 2,3)快。实际上,在每一列中,圆圈上的点的移动速度都比右边相邻列的点快。这是因为在公式1中,正弦和余弦参数与i成反比,并且增加i会降低正弦和余弦函数的频率。

位置编码的数学性质

公式2中定义的位置编码向量具有一些有趣的数学性质,我们将在本节中讨论。首先,让我们计算位置编码向量的长度。像

这样的向量的长度定义为:

现在,我们可以计算位置编码向量的长度,如公式2所示:

其中使用了以下三角恒等式来推导公式3:

结果表明,位置编码向量的长度与pos无关,并且对于所有词元都是相同的。由于所有向量的长度都相同,重要的是它们之间的角度。假设我们有两个向量(\mathbf{u})和(\mathbf{v}),它们之间的角度是(\theta)。那么有:

这里,(\mathbf{u} \cdot \mathbf{v})是(\mathbf{u})和(\mathbf{v})的点积,也可以写成

其中(\mathbf{u}^T)是(\mathbf{u})的转置。(\cos(\theta))项称为向量(\mathbf{u})和(\mathbf{v})的余弦相似度。现在,我们可以计算

我们从公式4开始:

为了计算这个公式的分子,我们可以写:

这个结果可以使用和差化积恒等式进行简化:

得到:

我们得出结论,公式5的分子与pos无关。我们还看到它的分母与pos无关。因此,PE_posPE_(pos + k)之间的角度仅取决于kd_model,而与pos无关:

这意味着,对于给定的(k)值,(PE_{pos})和(PE_{(pos + k)})之间的夹角始终保持不变,而与(pos)的值无关。让我们来看一个例子以阐明这些结论。清单4将一个长度为12且(d_model = 2)的给定序列的位置编码向量进行了可视化呈现。在这种情况下,每个词元的位置编码向量仅由两个元素组成,这使得整个向量可以表示为单位圆上的一个点。这次,所有词元对应的点都绘制在同一个圆上。结果如图9所示。

# 清单4
margin = 0.03
pe = positional_encoding(2, 12)  
fig = plt.figure(figsize=(7,7))
ax = fig.add_subplot(1, 1, 1) 
circle = Circle((0, 0), 1, facecolor='none', edgecolor='black', linewidth=0.5)
ax.add_patch(circle)
for i in range(len(pe)):
    plt.scatter(pe[i, 0], pe[i,1], s=50, label=str(i))
    plt.plot([0, pe[i, 0]],[0,pe[i,1]])
    plt.text(pe[i, 0]+margin, pe[i,1]+margin, "PE"+str(i), fontsize=14)
ax.set_aspect('equal')
plt.xlim([-1.2, 1.2])
plt.ylim([-1.2, 1.2])
plt.show()

对于每一个位置编码向量,我们可以看到:

我们还发现,对于所有的(pos)值,(PE_{pos})和(PE_{(pos + k)})之间的夹角都是相同的。例如,(PE_0)和(PE_1)之间的夹角与(PE_3)和(PE_4)之间的夹角是一样的。同样,(PE_0)和(PE_2)之间的夹角等于(PE_1)和(PE_3)之间的夹角。当(d_model>2)时,我们无法绘制位置编码向量。然而,本节中得到的结论仍然是有效的。

(PE_{pos})和(PE_{(pos + k)})之间的夹角取决于(k)且与(pos)无关这一事实,使得Transformer模型能够通过相对位置来学习关注。例如,当前词元与下一个词元之间的夹角总是相同的,无论当前词元的位置或者整个序列的长度如何。以下是引入Transformer模型的原始论文[1]中的一段引用:

但是这句话的数学含义是什么呢?假设我们有公式2中给出的位置编码向量。我们知道这个向量有(d_model)个元素。现在,基于上述结论,存在一个(d_model×d_model)的矩阵(M_k),它取决于(k)(以及(d_model)),但与(pos)无关,并且满足以下等式:

这个矩阵就是上述引用中提到的线性函数。我们可以很容易地证明,这个结论也可以推导出公式6。实际上,公式7表明,对于给定的(k)值,(PE_{pos})和(PE_{(pos + k)})之间的夹角保持不变,而与(pos)的值无关。在公式7中,(M_k)是一个旋转矩阵,它将(PE_{pos})旋转一定的角度(该角度取决于(k)),并将其转换为(PE_{pos + k})。图10展示了一个例子。在这里,矩阵(M_2)将(PE_0)旋转到(PE_2)。由于这个矩阵与(pos)无关,它会以相同的角度旋转任何其他位置编码向量。例如,它以相同的角度旋转(PE_3)来得到(PE_5)。因此,(PE_{pos})和(PE_{pos + 2})之间的夹角总是相同的。

清单5创建了一个热图,展示了一个长度为20且(d_model = 64)的给定序列中所有位置编码向量的成对点积。该热图如图9所示。

# 清单5
pe = positional_encoding(64, 20)  
dist = pe @ pe.T
plt.imshow(dist, cmap='jet', interpolation='nearest')  
plt.colorbar()
plt.title('Heatmap of Pairwise Dot Products')
plt.xlabel('Pos')
plt.ylabel('Pos')
plt.xticks(np.arange(0, 20, step=2))
plt.yticks(np.arange(0, 20, step=2))
plt.show()

这个热图是公式6的一种体现。由于所有的位置编码向量长度相同,我们从公式5可以得出:

因此,一对位置编码向量的点积与它们之间的夹角成反比(因为(\cos(\theta))与(\theta)成反比)。我们可以看到,热图矩阵中每条对角线上的元素颜色是相同的。这是因为对于特定的(k)值,(PE_{pos})和(PE_{(pos + k)})之间的夹角保持不变,所以它们的点积也不变。如图12所示,每条对角线代表一个特定的(k)值。

这个热图还有另一个有趣的特征。当两个词元的位置距离变远时,我们预期它们的位置编码向量之间的夹角会增大,而它们的点积会减小(点积与(\cos(\theta))成正比,而(\cos(\theta))与(\theta)成反比)。然而,热图显示,两个词元的位置编码向量的点积并不总是随着它们位置距离的变远而减小。

图13展示了一个例子。如果我们沿着热图矩阵的第一行观察,点积呈现出周期性变化,并且会表现出振荡行为。这意味着这些向量之间的夹角会反复增大和减小。这可能看起来有悖直觉,但原因在于正弦和余弦函数的周期性。

为了解释原因,我们绘制了图8中位置编码向量的成对点积的热图,其中序列长度为7,且(d_model = 20)。在这里,对应于((j = 0,1))的圆上的点移动得很快。在(PE_3)中,这个点与它在(PE_0)中的原始位置相距很远,因此,(PE_0)和(PE_3)的点积很小(向量之间的夹角很大)。然而,在(PE_6)中,这个点几乎回到了它的原始位置,而第二个圆((j = 2,3))上的点并没有移动那么多。结果,(PE_0)和(PE_6)之间的夹角变小了,并且它们的点积相比(PE_0)和(PE_3)的点积增大了。

增大(d_model)可以减轻这种周期性行为。图15展示了一个长度为20且(d_model = 512)的给定序列中所有位置编码向量的成对点积的热图。现在,当两个向量的(pos)差值增大时,它们的点积会持续减小。

参考文献

[1] Vaswani, Ashish; Shazeer, Noam; Parmar, Niki; Uszkoreit, Jakob; Jones, Llion; Gomez, Aidan N; Kaiser, Łukasz; Polosukhin, Illia. 2017.Attention is All you Need .In Advances in Neural Information Processing Systems . Vol. 30. Curran Associates, Inc.arXiv :1706.03762 .