题前注明:
本文的数学证明主要借鉴YaRN论文中对于RoPE的表述,小白也能看得懂,看不懂那可能是我表达有问题,请尽情提出批评建议
关于后文的可视化代码会在评论区释出,请佬们帮忙review一下看有无问题。
是笔者初学长上下文的笔记,苏神的博客还没研究明白,后续研究懂了有问题会在本文更新
后续会继续看YaRN论文更新一个系列,主要是因为最近做Dynamic-NTK改底数之后微调性能变差,在找原因
RoPE代码
ROFORMER: ENHANCED TRANSFORMER WITH ROTARY POSITION EMBEDDING
class LlamaRotaryEmbedding(nn.Module):
def __init__(self, dim, max_position_embeddings=2048, base=10000, device=None):
super().__init__()
self.dim = dim
self.max_position_embeddings = max_position_embeddings
self.base = base
# `torch.arange(0, self.dim, 2)`: 生成一个从 `0` 开始,到 `self.dim` 结束(不包含 `self.dim`),步长为 `2` 的一维张量。这意味着张量将包含所有的偶数索引,直到小于 `self.dim` 的最大偶数
# 将上述生成的偶数索引除以 `self.dim`,即将每个索引标准化到 `[0, 1)` 区间内。这样的标准化可以帮助调整每个频率成分的增长速度,使其在整个维度范围内平滑分布
inv_freq = 1.0 / (self.base ** (torch.arange(0, self.dim, 2).float().to(device) / self.dim))
self.register_buffer("inv_freq", inv_freq, persistent=False)
# Build here to make `torch.jit.trace` work.
self._set_cos_sin_cache(
seq_len=max_position_embeddings, device=self.inv_freq.device, dtype=torch.get_default_dtype()
)
def _set_cos_sin_cache(self, seq_len, device, dtype):
self.max_seq_len_cached = seq_len
# torch.arange(start, end, step, device, dtype)
t = torch.arange(self.max_seq_len_cached, device=device, dtype=self.inv_freq.dtype)
# torch.einsum(equation, *operands)
# "i,j->ij" 将第一个输入的每个元素与第二个输入的每个元素进行组合,形成一个二维张量
# 操作数是 `t` 和 `self.inv_freq`。`t` 是一个一维张量,包含从 `0` 到 `self.max_seq_len_cached - 1` 的整数。`self.inv_freq` 是一个与模型的位置编码相关频率的倒数(inverse frequencies)。其实就是关于$\theta_i$的张量。
freqs = torch.einsum("i,j->ij", t, self.inv_freq)
# Different from paper, but it uses a different permutation in order to obtain the same calculation
# torch.cat(tensors, dim)
# `tensors` 是 `(freqs, freqs)`,也就是将同一个张量 `freqs` 连接两次。
# 参数 `dim=-1` 指定了拼接的维度。在 PyTorch 中,`-1` 表示最后一个维度。对于 `freqs` 如果是二维张量,比如形状为 `[n, m]`,那么 `dim=-1` 表示沿着每行的最内层维度进行连接。
emb = torch.cat((freqs, freqs), dim=-1)
# 此函数沿指定维度连接张量。这里, `freqs` 沿着最后一个维度与其自身连接(`dim=-1`)。此操作本质上使频率内容加倍,为同时进行余弦和正弦计算做好准备。通过复制频率,每对(余弦和正弦)并排设置,简化了后续操作。
self.register_buffer("cos_cached", emb.cos().to(dtype), persistent=False)
self.register_buffer("sin_cached", emb.sin().to(dtype), persistent=False)
# 张量注册为 PyTorch 模块内的缓冲区。缓冲区与参数类似,但在反向传播期间不会更新。它们用于存储模型用于计算的常量或状态。这`persistent=False`参数指定该缓冲区不应该是由保存的模型状态的一部分
def forward(self, x, seq_len=None):
# x: [bs, num_attention_heads, seq_len, head_size]
if seq_len > self.max_seq_len_cached:
self._set_cos_sin_cache(seq_len=seq_len, device=x.device, dtype=x.dtype)
return (
self.cos_cached[:seq_len].to(dtype=x.dtype),
self.sin_cached[:seq_len].to(dtype=x.dtype),
)
详解:
inv_freq = 1.0 / (self.base ** (torch.arange(0, self.dim, 2).float().to(device) / self.dim))
inv_freq =
inv_freq 用于生成不同位置的正弦和余弦编码的频率。通过调整基数和指数的标准化,这种方法能够生成一个丰富且分布均匀的频率集合,这些频率集合随后可以用于计算每个位置的正弦和余弦值
# torch.arange(start, end, step, device, dtype)
t = torch.arange(self.max_seq_len_cached, device=device, dtype=self.inv_freq.dtype)
# torch.einsum(equation, *operands)
# "i,j->ij" 将第一个输入的每个元素与第二个输入的每个元素进行组合,形成一个二维张量
# 操作数是 `t` 和 `self.inv_freq`。`t` 是一个一维张量,包含从 `0` 到 `self.max_seq_len_cached - 1` 的整数。`self.inv_freq` 是一个与模型的位置编码相关频率的倒数(inverse frequencies)。
freqs = torch.einsum("i,j->ij", t, self.inv_freq)
einsum 操作在这种情况下实质上是在进行外积计算。外积是一种矩阵运算,用于生成两个向量的所有可能的元素对的乘积组成的矩阵。在这个具体场景中:
t(假设其尺寸为N)和self.inv_freq(假设其尺寸为M)进行外积操作,结果是一个形状为N x M的矩阵。- 对于输出矩阵中的每一个元素
freqs[i, j],它的值是t[i] * self.inv_freq[j]。这意味着对于t中的每个时间步或位置索引,都与self.inv_freq中的每个频率值相乘,从而创建一个频率矩阵。
torch.einsum("i,j->ij", t, self.inv_freq) 操作执行以下步骤:
- 对于
t中的每个元素t[i],它与self.inv_freq中的每个元素self.inv_freq[j]相乘,得到一个二维张量,其中每个元素都是t[i] * self.inv_freq[j]。这个过程可以看作是在t的每个位置上,都有一个完整的self.inv_freq向量与之相乘。
最终结果是一个二维张量,其中 ij 位置上的元素是 t 的第 i 个元素与 self.inv_freq 的第 j 个元素的乘积,并对所有的 i 和 j 进行求和。
这种操作通常用于构建位置编码或者将顺序信息集成到模型中,这在自然语言处理中非常常见,特别是在处理序列数据时。在 LlamaRotaryEmbedding 类中,t 可以看作是时间步(或位置)的表示,而 self.inv_freq 则是提供每个时间步特定频率的向量。这个操作最终生成一个二维张量,它同时包含了时间(位置)信息和频率信息,被后续的 _set_cos_sin_cache 方法用于构建旋转嵌入。
举个例子方便理解:
self.base = 10000
self.dim = 8 # Example dimension
inv_freq = 1.0 / (self.base ** (torch.arange(0, self.dim, 2).float() / self.dim))
max_seq_len_cached = 4 # Example maximum sequence length
t = torch.arange(max_seq_len_cached, device=device, dtype=inv_freq.dtype)
此时
inv_freq = 1.0 / (10000 ** (torch.tensor([0, 2, 4, 6]).float() / 8))
inv_freq = tensor([1.0000, 0.1000, 0.0100, 0.0010])
t = tensor([0, 1, 2, 3])
result = torch.einsum("i,j->ij", t, inv_freq)
计算得到
result = torch.einsum("i,j->ij", torch.tensor([0, 1, 2, 3]), torch.tensor([1.0000, 0.1000, 0.0100, 0.0010]))
result = tensor([[0.0000, 0.0000, 0.0000, 0.0000],
[1.0000, 0.1000, 0.0100, 0.0010],
[2.0000, 0.2000, 0.0200, 0.0020],
[3.0000, 0.3000, 0.0300, 0.0030]])
# torch.cat(tensors, dim)
emb = torch.cat((freqs, freqs), dim=-1)
tensors 是 (freqs, freqs),也就是将同一个张量 freqs 连接两次。
参数 dim=-1 指定了拼接的维度。在 PyTorch 中,-1 表示最后一个维度。对于 freqs 如果是二维张量,比如形状为 [n, m],那么 dim=-1 表示沿着每行的最内层维度进行连接,也就是列维度。
为什么要拼接 freqs 两次
原因在于生成位置编码时通常需要同时使用正弦和余弦函数。为了生成完整的位置编码,你通常需要两组频率值:一组用于计算正弦值,另一组用于计算余弦值。通过将 freqs 与自身进行拼接,我们可以在一个张量中同时容纳用于计算正弦和余弦的频率值,从而简化后续的处理步骤。
如何使用这个拼接后的张量 emb
- 在这个例子中,拼接后的张量
emb将直接用于计算所有位置的正弦和余弦值。这是通过对emb应用.cos()和.sin()函数完成的。 - 由于
emb包含了每个位置的频率值两次,emb.cos()将为每个频率计算余弦值,emb.sin()将为每个频率计算正弦值。这意味着最终得到的余弦和正弦缓存(buffer)中,每个位置的编码将由相应的正弦和余弦值并列组成。
RoPE数学解释
给定一系列向量 按照RoFormer,也就是RoPE的论文 的符号表示,注意力层首先将向量转换为q和k
是维,是维
之后计算softmax :
(其实就是注意力机制那个计算方法)
where are considered as column vectors so that q is simply the Euclidean inner product.
以上都和我们之前学的东西没有任何区别。
In RoPE we first assume that is even and identify the embedding space and the hidden states as complex vector spaces:
但是在RoPE中,假设维度是偶数 ("even"),以及将实数向量空间与复教向量空间相同构 (cong)即
表示一个由 个实数组成的向量空间。
表示一个由 个复数组成的向量空间。
两个向量空间具有相同的结构和维度。这里的同构表示我们可以无损地从实数向量空间转换到复数向量空间,反之亦然。
在这种情况下
where the inner product becomes the real part of the standard Hermitian inner product Re(). More specifically, the isomorphisms interleave the real part and the complex part
首先解释什么是the standard Hermitian inner product 标准赫米特(Hermitian)内积:
标准赫米特(Hermitian)内积是一种用于定义两个复数向量之间相互作用的重要概念。对于两个复数向量和,其中元素 ,它们的标准赫米特内积定义为:
这里,表示的共轭复数。如果 (其中是实数,是虚数单位),则其共轭。
也就是说假设
然后,计算
将这些元素相乘并求和:
分别展开每个乘积:
将这些结果相加:
这里的实部和虚部分别是内积的实部和虚部,通常只关心实部,即赫米特内积的实部:
这个时候我们可以根据是赫米特内积的实部反向推出应该是
也就是说会把作为实部,作为虚部交错插入
就是将相邻的实数元素配对转换为复数
这样我们完成了下式的第一步推导
也即
下面我们开始第三步的推导
已知复数空间赫米特内积定义
则
是 的共轭复数
我们现在要把 和 旋转的联系起来得到
代表了一个二维平面上绕原点的逆时针旋转 弧度的变换。
设是任意复数
使用线代表示
因此这个变换在复数域中等价于复数乘法:
同时由于 是一个正交矩阵,它保持了向量的欧几里得长度(或范数)。这意味着旋转操作不改变向量的大小,只改变其方向。这在将信息编码到复数域时非常重要,因为它保证了原始数据的信息量(例如能量)在转换过程中得到保留。
我们可以使用实现线性变换 ,同时把上述的推广到所有
这样就得到
同理
In real coordinates, the RoPE can be written using the following function
so that
To convert embeddings into query and key vectors, we are first given -linear operators
那么 这个又是为什么呢?
应该是旋转的角度,也就是说这个旋转的角度是为什么这样选择呢?
保证了随着 (即维度索引)的增加,旋转角度呈指数级衰减,这反映了对远离初始位置(如序列起始位置)的敏感度逐渐降低。
底数的变化造成的影响
的图像
这是的图像
画出不同base下不同维度d的图像
base=10000 (d作为z轴,实部和虚部作为xy)
base = 500000
base = 1000000
我们来看三视图对比
可以看到旋转因子会随着底数变大,在低维度更稠密(变化的比较快),而高维度更稀疏,(变化少)
这个结论我也不知道对不对,佬们可以留言
其他
为什么需要是偶数?
1.配对实数构成复数:在复数中,每个数由实部和虚部组成,例如。当将 实数向量空间视为复数向量空间时,实际上是将每两个实数 (一个作为实部,另一个作为虚部)配对构成一个复数。这要求必须是偶数, 以确保所有维度都能被完整地配对。