在transformer学习笔记-自注意力机制(1)学习原理的时候,我们提到:
将句子从“苹果梨”,改成“梨苹果”,最终的到的新苹果和新梨,竟然是一样的,因为苹果和梨两个向量调换顺序后,对应计算的权重也换了顺序,因此最终计算出来的结果一样
也就是仅通过词嵌入和attention计算,并不会体现不同词token之间的位置关系,这也就是为何需要引入位置编码。
当然理解这一知识点,需要点空间物体移动的想象力。
还是以“我 喜欢 学习 Python”为例,我们可以怎么表示每个词token的位置:
我(位置0),喜欢(位置1),学习(位置2)、Python(位置3)
假设我们现在的词嵌入的维度是2,位置我们也用两位的二进制表示:
假设我们把位置增长方向当做一个坐标轴,这个xy平面的点,实际是按一个沿着token个数增长方向的三维空间运动(尝试用动态前进的点的轨迹,稍后我们还会再一次使用这种方法),虽然沿着增长方向很容易区分先后顺序,但是从xy二维平面很难直接观察出顺序(二维空间上不连续)。
如果这时我们有第五个点(“。”位置5),虽然在增长方向上不会跟现有的点重叠,但仅从xy平面来看,就跟位置0(0,0)重叠了。因此,我们需要增加一个维度z,当点在xy平面无法区分时,在zx平面区分出来:
原来的四个点,增多一个维度,该维度值都为0,而第五个点则为(0,0,1),从xy平面看到重叠的点(位置0,位置5),可以从xz平面看到区别((0,0,0),(0,0,1))
这个zy平面的点,实际也是按沿着增长方向的三维空间运动。
很难想象xy平面、zx平面同时垂直于token个数增长方向,但我们可以对多维空间,每两个维度拆出一个二维平面,然后与增长方向垂直。
问题来了,用二进制的方式,一个二维空间,最多能表示四个位置,三维空间,最能表示8个位置,空间严重浪费。
聪明的人又想出了一种针对词嵌入的位置编码方法,我们暂且叫它绝对位置编码:
一、 绝对位置编码
绝对位置编码为序列中(n行d列)的每个位置 i 分配一个固定的位置向量 P i P_i Pi,使模型能够区分同一词汇在不同位置表示的语义。对于一个包含k个token的句子(0,1,2,…k-1),对于每个位置 k和每个维度 i,有如下公式:
P ( k , 2 i ) = s i n ( k n 2 i / d ) P(k,2i) = sin(\frac{k}{n^{2i/d}}) P(k,2i)=sin(n2i/dk)
P ( k , 2 i + 1 ) = c o s ( k n 2 i / d ) P(k,2i+1) = cos(\frac{k}{n^{2i/d}}) P(k,2i+1)=cos(n2i/dk)
n是一个用户自定义的超参,我们暂且定为:10000, P ( k , 2 i ) P(k,2i) P(k,2i)表示第k个token的2i维度的值, P ( k , 2 i + 1 ) P(k,2i+1) P(k,2i+1)表示第k个token的2i维度下一个维度的值。
乍一看,这都啥乱七八糟的,别急,我们逐个理解下:
k:表示有k个词token
d:表示每个token词嵌入的维度,也就是位置编码的维度
i:表示列的索引,取值[0,d/2-1],每个i定位词token的(2i,2I+1)两个维度
那么第k个token的第i索引的两个维度的取值为:P(k,2I),P(k,2I+1),
再回到上面的公式,设a= k 1000 0 2 i / d , a 与 k 成正比 \frac{k}{10000^{2i/d}},a与k成正比 100002i/dk,a与k成正比:
P ( k , 2 i ) = s i n ( a ) P(k,2i) = sin(a) P(k,2i)=sin(a)
P ( k , 2 i + 1 ) = c o s ( a ) P(k,2i+1) = cos(a) P(k,2i+1)=cos(a)
可以看到这个公式把维度的列,分成了d/2-1组(sin(a),cos(a))
我们把其中一个分组的sin(a),cos(a)放入二维平面,以sin(a)为横轴,以cos(a)为纵轴,a随着k增大而增大,这时,点(sin(a),cos(a))的运动轨迹得到一个单位圆:
箭头就是随着a增大(K增大),点(sin(a),cos(a))运动方向
画圆的代码如下:
import numpy as np
import matplotlib.pyplot as plt
# 定义角度范围
a = np.linspace(0, 2 * np.pi, 1000)
# 计算 sin(a) 和 cos(a)
x = np.sin(a)
y = np.cos(a)
# 绘制图像
plt.figure(figsize=(6, 6))
plt.plot(x, y, label='Unit Circle')
plt.title('Plot of cos(a) vs sin(a)')
plt.xlabel('sin(a)')
plt.ylabel('cos(a)')
plt.axhline(0, color='black',linewidth=0.5)
plt.axvline(0, color='black',linewidth=0.5)
plt.grid(color = 'gray', linestyle = '--', linewidth = 0.5)
plt.legend()
plt.axis('equal') # 确保 x 和 y 轴的比例相同
plt.show()
想象一下:点(sin(a),cos(a))是随着a变化运动的,也就是说还有个隐藏的坐标轴表示a的变化方向,也就是说点(sin(a),cos(a))的运动轨迹,虽然从二维平面看是个圆,运动的点会周期性重叠,但在与a轴前进方向上,其实是个弹簧的形状,永远不会重叠。就像地球围着太阳转的时候,太阳也在银河系转时,地球的运动轨迹。所以在某个i对应的(sin(a),cos(a))平面,当有重叠的点,则需要通过下一个i的(sin(a),cos(a))平面来区分先后顺序,当然下一个平面的点也是在另一个a轴前进方向上的(a=
k
1000
0
2
i
/
d
\frac{k}{10000^{2i/d}}
100002i/dk,i变大,k不变的情况下,a也变大),想象一下,整个银河系也在绕着某个核心在转。
相当于当前的角度(i固定,a的前进方向)看的平面区分不出重叠的点,就换个角度看,最后,得到d/2-1个平面,每个平面都对应一个圆。
再回到a=
k
1000
0
2
i
/
d
\frac{k}{10000^{2i/d}}
100002i/dk,随着i变大,在K不变的情况下,a越来越小,也就是说,i越小,点(sin(a),cos(a))移动的速度越快,随着K的增大,P(k,2I),P(k,2I+1)的值变化越大;i越大,变化越不明显。又回到天体运行上,太阳沿着a轴的前进方向随k的改变速度,要比银河系沿着另一个a轴前进的方向要快得多,远的相对运动慢得感受不到位置移动变化,嗯,我们似乎找到了宇宙运行的奥秘。
用代码来感受下:
import torch
import math
import matplotlib.pyplot as plt
def get_absolute_position_encoding(max_len, d_model):
"""生成绝对位置编码"""
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe = torch.zeros(max_len, d_model)
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
return pe
# 参数设置
max_len = 512 # 最大序列长度
d_model = 64 # 嵌入维度
# 获取绝对位置编码
pe = get_absolute_position_encoding(max_len, d_model)
# 绘制不同维度的位置编码随位置变化的情况
plt.figure(figsize=(12, 6))
# 绘制前几个维度
for i in range(18,20):
plt.plot(pe[:, i], label=f'Low Dim {i}')
# 绘制后几个维度
high_dims = [d_model - 10 + i for i in range(2)]
for i in high_dims:
plt.plot(pe[:, i], label=f'High Dim {i}', linestyle='--')
plt.title('Absolute Position Encodings')
plt.xlabel('Position Index')
plt.ylabel('Embedding Value')
plt.legend()
plt.grid(True)
plt.show()
这里我们设置位置编码维度为64,token个数最多为512个,可以看到低维度的曲线周期性变化快,而高维度变化慢。
说明什么问题:
i较小时,i对应的平面内的P(k,2I),P(k,2I+1)对K敏感,k增大一点点,P(k,2I),P(k,2I+1)的值就有明显变化,但同时也容易出现轨迹重叠,后续出现的token,只能从更高的维度(i的值更大的平面)识别,因此低纬度的P(k,2I),P(k,2I+1)体现的相近的词token的位置关系,反之,高纬度的P(k,2I),P(k,2I+1)对K增长不敏感,需要K增长非常多,才有变化,体现长距离的位置关系。
现在来看看,位置距离对内积的影响:
初始化Q和K向量,Q固定在位置0,感受下K的位置逐渐远离时QK的内积变化。
import torch
import math
import matplotlib.pyplot as plt
def get_absolute_position_encoding(max_len, d_model):
"""生成绝对位置编码"""
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe = torch.zeros(max_len, d_model)
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
return pe
# 参数设置
max_len = 512 # 最大序列长度
d_model = 512# 嵌入维度
# 获取绝对位置编码
pe = get_absolute_position_encoding(max_len, d_model)
# 初始化 Q 和 K
Q = torch.randn(d_model)
K = torch.randn(d_model)
# 初始化 query 向量,固定在位置 0
query = pe[0]
# 初始化 key 向量,位置逐渐远离
keys = pe
# 计算 query 和 key 在不同位置上的内积
inner_products = []
for i in range(max_len):
inner_product = torch.dot(Q+query, K+keys[i])
inner_products.append(inner_product.item())
# 绘制内积变化图
plt.figure(figsize=(12, 6))
plt.plot(inner_products, label='Inner Product with Query at Position 0')
plt.title('Inner Product of Query and Key Vectors at Different Positions')
plt.xlabel('Position Index')
plt.ylabel('Inner Product Value')
plt.legend()
plt.grid(True)
plt.show()
可以看到,随着距离逐渐变远,内积先快速下降,然后逐渐收敛,这个逐渐收敛的过程叫远程衰减。体现近距离更强的依赖关系。
我不知道原作者是不是也是用这种理念设计的,但我用空间运动的方式理解这个公式的时候,心想这家伙是怎么想到的。
回到正题:
得到位置编码后,将原本的词嵌入与位置编码相加,得到包含位置信息的词嵌入。此时,句子从“苹果梨”,改成“梨苹果”,得到的新苹果和新梨,就不一样了。
注意: 我们可能会担心加入了位置编码后的词嵌入会影响原有的语义,这里主要考虑以下几点:
1、使用的sin和cos,取值[-1,1],数值小,对原有词嵌入的影响很少。
2、随着k的增长,sin和cos的值变化幅度很小,对整体矩阵结构(整体特征)的影响很少,
3、矩阵相加后的可分解性,由于计算位置编码的公式固定,在后续的处理中,能够分解出位置编码,并在多层神经网络结构中,对噪音进行优化处理。
实践也证明,位置编码对词嵌入本身的影响,微乎其微,相反模型因为位置编码的加入,增强了对词token在不同语境下的理解能力。
上面介绍的这种绝对位置编码方式叫:Sinusoidal位置编码。
当然还有一种绝对位置编码方式,叫训练式绝对位置编码,通过初始化一个n * d 的位置编码权重矩阵(n为最长token个数,d编码权重矩阵的维度)通过大量语料训练得到,此处不做介绍。
绝对位置编码也存在一些问题:
1、模型训练通过固定词token个数的序列长度完成训练(包括 W Q 、 W K 、 W V {W^Q}、W^K、W^V WQ、WK、WV等权重),当实际处理的序列超过模型训练的序列长度时,现有训练的权重参数等,不能有效处理。比如 W Q 、 W K 、 W V {W^Q}、W^K、W^V WQ、WK、WV的行数都是跟训练序列的行数一致,位置编码的行数也一致。
2、绝对位置编码更关注整体的位置编码,对局部的位置相对较弱,比如,“我喜欢学习Python”,和“近几年,我喜欢学习Python”,后面句更会对前面的“近几年”几个token投入过多关注,不利于识别关键信息。大多数时候,我们更关心距离较近的部分的位置信息,而不是整体的。
3、从局部看,后面句子的"我喜欢学习Python",会因前面的token位置编码发生较大变化,语义上这两句应该是相同的,但绝对位置认为不同,对模型处理的稳定性造成影响。
如何让模型能够更加关注局部的相对位置信息呢,这也是接下来需要学习的RoPE旋转位置编码所实现的目标:
二、RoPE旋转位置编码
先来看看二维空间中,向量旋转的一些特性:
首先,旋转后的向量长度不变,也就是特征能够保持,比如,小狗的图像经过旋转后还是小狗的图像;
其次,旋转得有多远可以通过夹角α表示。
假设现在有单位旋转角度θ,向量Q原始位置在0,然后移动到1,然后2,用向量旋转表示则如下:
此时Q0、Q1、Q2不仅可以通过0* θ、1* θ、2* θ 表示绝对距离,Q2与Q1之间还可以通过旋转角度2* θ - 1* θ表示,
RoPE位置编码就是这么干的。
RoPE通过对Q的位置下标m * 单位旋转角度θ,对K的位置下标n * 单位旋转角度θ,也就是Q旋转mθ,K旋转nθ,然后求旋转后的内积,从而使内积带上相对位置的信息,设旋转矩阵为R:
旋转前的内积: Q T ⋅ K Q^T ·K QT⋅K
旋转后的内积: Q T ⋅ R ( m − n ) ⋅ K Q^T ·R_{(m-n)}·K QT⋅R(m−n)⋅K(省略推导,直接说结论)
目前为止,我们讨论的都还是二维的的情况,对于高维的Q和K,应该如何处理:
RoPE采取了跟上面Sinusoidal位置编码类似的方式:
首先,还是将d个维度,分成d/2个分组:
分组索引为: i(0,1,2,…,d/2-1)
每个i分组旋转的单位角度 θ i θ_i θi: 1 n 2 i / d \frac{1}{n^{2i/d}} n2i/d1(n工程上一般取10000)
以上图为例,取i = 1, θ 1 θ_1 θ1= 1 1000 0 2 / d \frac{1}{10000^{2/d}} 100002/d1,序列的第m个token,旋转m倍 θ 1 θ_1 θ1
也就是d/2个分组对应d/2个平面,每个平面按照 m m mθ_i θ i θ_i θi各自转各自的。
更一般的,对于d维的第m个词嵌入,旋转可有如下表示
可以将上面的矩阵乘法,简化为一下逐乘然后相加的计算方式:
简化后的式子虽然方便了计算,但是不方便理解,理解还得看简化前的式子。
再回到式子 θ i θ_i θi = 1 1000 0 2 i / d \frac{1}{10000^{2i/d}} 100002i/d1,随着维度增大,i增大, θ i θ_i θi变小,i对应的平面的向量旋转的角度越小,当维度d较大时,i比较大的平面,m需要比较大才能有所反应。而维度低的平面,相邻的向量间旋转的夹角就能比较大。这也反应跟Sinusoidal位置编码相似的特性:低维度短距离依赖,高维度长距离依赖。
示例代码如下:
初始化Q和K向量,Q固定在位置0,感受下K的位置逐渐远离时QK的内积变化。
import numpy as np
import matplotlib.pyplot as plt
def apply_rotation(K, delta_k, d):
"""应用旋转矩阵"""
rotated_K = np.zeros_like(K)
for i in range(d // 2):
theta_i = 1 / (10000 ** (2 * i / d))
delta_theta_i = theta_i * delta_k
# 构造旋转矩阵
R = np.array([[np.cos(delta_theta_i), -np.sin(delta_theta_i)],
[np.sin(delta_theta_i), np.cos(delta_theta_i)]])
k_rotated = R @ np.array([K[2*i], K[2*i+1]])
rotated_K[2*i:2*i+2] = k_rotated
return rotated_K
def compute_inner_product(Q, K):
"""计算内积"""
return np.dot(Q, K)
# 参数设置
d_model = 512 # 嵌入维度
max_distance = 1024 # 最大距离
inner_products = []
# 初始化 Q 和 K
# Q = torch.randn(d_model)
K = torch.randn(d_model)
# 计算不同距离下的内积
for delta_k in range(max_distance):
rotated_Q = apply_rotation(K, 0, d_model)
rotated_K = apply_rotation(K, delta_k, d_model)
inner_products.append(compute_inner_product(rotated_Q, rotated_K))
# 绘制内积随距离变化的情况
plt.figure(figsize=(12, 6))
plt.plot(range(max_distance), inner_products, label='Inner Product')
plt.title('Inner Product of Rotated Q and K with Increasing Distance')
plt.xlabel('Distance (Δk)')
plt.ylabel('Inner Product Value')
plt.legend()
plt.grid(True)
plt.show()