PyTorch 2.x 深度学习专题【左扬精讲】—— 位置编码详解:正弦位置编码、相对位置编码、旋转位置编码(RoPE)

PyTorch 2.x 深度学习专题【左扬精讲】—— 位置编码详解:正弦位置编码、相对位置编码、旋转位置编码(RoPE)

torch.nn.Embedding                ← 可学习位置编码的底层实现
  torch.nn.functional.scaled_dot_product_attention  ← 现代 Attention 支持相对位置偏置
  huggingface/transformers          ← RotaryEmbedding / RoPE 实现(LLaMA / Mistral / Qwen 等)
  facebookresearch/flash-attention  ← 高效注意力,支持 RoPE

位置编码 Transformer Attention RoPE 正弦位置编码 相对位置编码 PyTorch 2.x

本文学习重点

  • 必须掌握:三种位置编码的数学原理、Why 动机、PyTorch 中的标准实现方式
  • 需要理解:正弦位置编码的周期性特征、相对位置编码如何捕获 token 间距离、RoPE 如何通过旋转矩阵实现位置信息注入
  • 建议了解:不同位置编码在长序列场景下的优劣对比

一、导语:为什么 Transformer 需要位置编码

What — 位置编码是什么?

位置编码(Positional Encoding)是 Transformer 架构中用于为序列中的每个 token 注入位置信息的模块。由于 Transformer 的核心结构——自注意力机制(Self-Attention)——对输入序列是 permutation equivariant 的,即打乱输入序列的顺序不会改变注意力分数的计算结果,这意味着模型本身无法区分 "token A 在位置 1" 和 "token A 在位置 10"。

Why — 为什么需要位置编码?

问题一:Attention 机制本身不感知位置

在自注意力计算中:

Attention(Q, K, V) = softmax(QK^T / sqrt(d_k))V

Q、K、V 的计算完全基于 token 的内容特征,不包含任何位置信息。因此 "我爱你" 和 "你爱我" 经过注意力计算会产生完全相同的输出(除非 embedding 本身包含位置暗示)。

没有位置编码会发生什么?

  • 模型无法区分词语的前后关系,丢失语序信息
  • 在机器翻译、文本生成等任务上性能严重下降
  • 模型退化为"词袋模型",失去序列建模能力
How — 位置编码的基本形式

位置编码的核心思想是将位置信息编码为与 token embedding 同维度的向量,然后与 token embedding 相加(additive)或拼接(concatenation)。形式化表达为:

x_final = x_token + PE(position)

其中 PE(position) 是位置编码向量,常见实现方式包括:

  • 正弦位置编码:使用不同频率的 sin/cos 函数生成
  • 可学习位置编码:将位置编码作为可训练参数学习
  • 相对位置编码:编码 token 之间的相对距离而非绝对位置
  • 旋转位置编码(RoPE):通过旋转矩阵将位置信息注入 Query 和 Key

本节要点

  • Self-Attention 本身是 permutation equivariant 的,不感知 token 位置
  • 位置编码通过注入位置信息解决这一根本问题
  • 主流位置编码分为绝对位置编码和相对位置编码两大类

二、正弦位置编码(Sinusoidal PE)

2.1 理论背景

What — 什么是正弦位置编码?

正弦位置编码源自 Google 2017 年发表的经典论文 "Attention Is All You Need"(Vaswani et al., 2017)。它使用不同频率的正弦和余弦函数为每个位置生成唯一的编码向量。

Why — 为什么选择正弦函数?

动机一:支持任意长度的序列

正弦函数是周期函数,可以泛化到训练时未见过的序列长度。如果训练时最长序列是 512 个 token,测试时来了 1000 个 token,正弦位置编码依然能生成位置 513、514、...、999 的编码向量,因为它只是数学公式,算到哪就生成到哪。而可学习的位置编码在初始化时就固定好了 (max_len, d_model) 的查找表,超出 max_len 的位置根本没有对应的可学习向量,根本无法处理。

动机二:相对位置可以通过线性变换表示

这是正弦位置编码最精妙的设计。观察正弦位置编码的公式:

PE(pos, 2i) = sin(pos / 10000^(2i/d)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d))

可以证明,PE(pos + k) 可以写成 PE(pos) 乘以一个与 k 相关的矩阵 M_k 的形式,即 PE(pos + k) = M_k * PE(pos)。这意味着什么呢?模型不需要直接"记住"位置 10 和位置 5 的编码分别是多少,它只需要学会"当前 token 距离目标 token 3 个位置"这个相对关系,就能通过简单的线性变换(矩阵乘法)将当前编码转换为目标的编码。换句话说,模型学会了"翻译"相对距离,而不需要死记硬背每个绝对位置。

没有正弦位置编码会发生什么?

  • 模型无法处理变长序列,必须在训练时固定最大长度
  • 丢失相对位置信息,句子中词语间的距离关系无法被利用
  • 模型退化为词袋模型,无法区分语序
How — PyTorch 实现正弦位置编码

标准实现来自 Hugging Face Transformers 库的 get_sinusoidal_positional_encoding 函数,核心逻辑如下:

import torch
  import math
  
  def get_sinusoidal_positional_encoding(seq_len, embed_dim):
      # 创建位置编码矩阵,形状为 (seq_len, embed_dim)
      pe = torch.zeros(seq_len, embed_dim)
      # position 表示位置索引,从 0 到 seq_len-1
      position = torch.arange(0, seq_len).unsqueeze(1).float()  # (seq_len, 1)
      # div_term 用于生成不同频率的缩放因子
      # embed_dim 必须为偶数,才能均匀分配给 sin 和 cos
      div_term = torch.exp(
          torch.arange(0, embed_dim, 2).float() * (-math.log(10000.0) / embed_dim)
      )  # (embed_dim/2,)
      # 偶数维度:sin(position * div_term)
      pe[:, 0::2] = torch.sin(position * div_term)
      # 奇数维度:cos(position * div_term)
      pe[:, 1::2] = torch.cos(position * div_term)
      return pe  # 返回形状 (seq_len, embed_dim) 的位置编码张量
  
  # 示例:生成 512 位置、512 维的位置编码
  seq_len, embed_dim = 512, 512
  pos_encoding = get_sinusoidal_positional_encoding(seq_len, embed_dim)
  print(f"位置编码形状: {pos_encoding.shape}")  # torch.Size([512, 512])
  print(f"位置 0 的编码前 8 维: {pos_encoding[0, :8]}")  # 全为 0
  print(f"位置 1 的编码前 8 维: {pos_encoding[1, :8]}")  # sin(div_term) 值

设计意图:使用指数衰减的频率(div_term),低频维度编码长距离关系,高频维度编码短距离关系,这与自然语言的局部性和长程依赖特性相吻合。

2.2 与 Token Embedding 的结合

What — 如何将位置编码加入 Transformer?

在原始 Transformer 架构中,位置编码与 token embedding 直接相加(additive),然后送入 Encoder 或 Decoder。

How — 在 PyTorch 中集成位置编码
import torch
  import torch.nn as nn
  import math
  
  class SinusoidalPositionalEncoding(nn.Module):
      def __init__(self, d_model, max_len=5000):
          super().__init__()
          # 预计算并注册为 buffer(不参与梯度计算但会随模型保存/加载)
          pe = get_sinusoidal_positional_encoding(max_len, d_model)
          self.register_buffer('pe', pe)  # 注册为 buffer,不计入梯度
  
      def forward(self, x):
          # x 形状: (batch_size, seq_len, d_model)
          seq_len = x.size(1)
          # 直接相加:token_embedding + positional_encoding
          x = x + self.pe[:seq_len, :]
          return x
  
  # 使用示例
  batch_size, seq_len, d_model = 2, 128, 512
  token_embeds = torch.randn(batch_size, seq_len, d_model)  # 模拟 token embedding
  pos_encoder = SinusoidalPositionalEncoding(d_model, max_len=5000)
  output = pos_encoder(token_embeds)
  print(f"输出形状: {output.shape}")  # torch.Size([2, 128, 512])

本节要点

  • 正弦位置编码使用 sin/cos 交替函数生成,支持任意长度泛化
  • 低频维度编码长距离位置,高频维度编码短距离位置
  • 通过与 token embedding 相加的方式注入位置信息

三、可学习位置编码(Learnable PE)

What — 什么是可学习位置编码?

可学习位置编码(Learnable Positional Encoding)将位置编码向量作为可训练的参数,使用 torch.nn.Embedding 创建一个形状为 (max_len, d_model) 的查找表,模型在训练过程中自动学习最优的位置表示。

Why — 什么时候选择可学习位置编码?

优点:

  • 灵活性高:位置编码可以适应具体任务数据分布
  • 实现简单:直接复用 nn.Embedding 接口
  • BERT、GPT 等主流模型广泛采用

缺点与局限:

  • 无法泛化到训练时设定的 max_len 之外的序列长度
  • 需要更多训练数据来学习位置表示

没有可学习位置编码会发生什么?

  • 位置编码的表示能力受限于手工设计的函数形式
  • 无法针对特定任务数据自适应调整位置表示
How — PyTorch 实现可学习位置编码

可学习位置编码的实现非常直接,利用 nn.Embedding 的查找表机制:

import torch
  import torch.nn as nn
  
  class LearnablePositionalEncoding(nn.Module):
      def __init__(self, d_model, max_len=5000, dropout=0.1):
          super().__init__()
          # 创建可学习的位置编码表,形状 (max_len, d_model)
          # 初始化为正态分布,均值为 0,标准差与 d_model 相关
          self.position_embeddings = nn.Embedding(max_len, d_model)
          nn.init.trunc_normal_(self.position_embeddings.weight, std=0.02)
          self.dropout = nn.Dropout(p=dropout)
  
      def forward(self, x):
          # x 形状: (batch_size, seq_len, d_model)
          batch_size, seq_len, d_model = x.size()
          # 生成位置索引:[0, 1, 2, ..., seq_len-1]
          position_ids = torch.arange(seq_len, device=x.device).unsqueeze(0).expand(batch_size, -1)
          # 通过 Embedding 查找获取位置编码
          pos_embeddings = self.position_embeddings(position_ids)  # (batch_size, seq_len, d_model)
          # 与 token embedding 相加
          x = x + pos_embeddings
          x = self.dropout(x)
          return x
  
  # 使用示例
  batch_size, seq_len, d_model = 2, 128, 512
  token_embeds = torch.randn(batch_size, seq_len, d_model)
  pos_encoder = LearnablePositionalEncoding(d_model, max_len=5000)
  output = pos_encoder(token_embeds)
  print(f"输出形状: {output.shape}")  # torch.Size([2, 128, 512])

设计意图:位置编码作为模型参数,通过反向传播自动优化,适配具体任务的数据分布。注意初始化使用截断正态分布(trunc_normal_),避免过大或过小的初始值影响训练稳定性。

本节要点

  • 可学习位置编码使用 nn.Embedding 实现,参数随训练更新
  • 灵活性高,但受限于 max_len 的设定
  • BERT、GPT 系列模型广泛采用此方式

四、相对位置编码(Relative PE)

4.1 理论背景

What — 什么是相对位置编码?

相对位置编码(Relative Positional Encoding)由 Shaw et al. 在 2018 年的论文 "Self-Attention with Relative Position Representations" 中提出。与绝对位置编码编码 token 的绝对位置不同,相对位置编码编码 token 之间的相对距离,即 "token i 和 token j 之间的距离是 k"。

Why — 为什么需要相对位置编码?

动机一:语言中的相对位置比绝对位置更重要

在自然语言中,"主语在动词左边 3 个词" 比 "主语在位置 5" 更具有语义意义。相对位置直接编码词语之间的距离关系。

动机二:提升长序列建模能力

绝对位置编码在长序列时位置值会变得很大,而相对位置编码的距离值是有界的(通常 clip 到 [-K, K]),更稳定。

没有相对位置编码会发生什么?

  • 模型难以学习词语间的距离依赖关系
  • 长序列场景下位置值过大,编码质量下降
  • 在需要精确相对距离的任务(如代码理解)上表现不佳
How — PyTorch 实现相对位置编码(经典 Shaw 方法)

相对位置编码的核心修改在 Attention 计算中引入相对位置偏置项:

import torch
  import torch.nn as nn
  import torch.nn.functional as F
  import math
  
  class RelativePositionEmbedding(nn.Module):
      def __init__(self, embed_dim, max_distance=128, num_heads=8):
          super().__init__()
          self.embed_dim = embed_dim
          self.num_heads = num_heads
          self.max_distance = max_distance
          # 创建相对位置的键值对 (2 * max_distance + 1) 个
          self.relative_attention_bias = nn.Embedding(2 * max_distance + 1, num_heads)
  
      def compute_relative_positions(self, seq_len):
          # 生成相对位置矩阵:position_i - position_j
          # 形状: (seq_len, seq_len)
          position_ids = torch.arange(seq_len, device='cuda' if torch.cuda.is_available() else 'cpu')
          relative_positions = position_ids.unsqueeze(1) - position_ids.unsqueeze(0)  # (seq_len, seq_len)
          # 将相对位置 clip 到 [-max_distance, max_distance]
          relative_positions = relative_positions.clamp(-self.max_distance, self.max_distance)
          # 转换为非负索引:[0, 2*max_distance]
          relative_positions = relative_positions + self.max_distance
          return relative_positions  # (seq_len, seq_len)
  
      def forward(self, seq_len):
          # 获取相对位置矩阵
          relative_positions = self.compute_relative_positions(seq_len)  # (seq_len, seq_len)
          # 通过 Embedding 获取相对位置偏置,形状 (seq_len, seq_len, num_heads)
          relative_attention_bias = self.relative_attention_bias(relative_positions)  # (seq_len, seq_len, num_heads)
          # 转换为 Attention 兼容的形状 (1, num_heads, seq_len, seq_len)
          return relative_attention_bias.permute(2, 0, 1).unsqueeze(0)
  
  # 使用示例
  embed_dim, num_heads, seq_len = 512, 8, 128
  relative_pos = RelativePositionEmbedding(embed_dim, max_distance=64, num_heads=num_heads)
  bias = relative_pos(seq_len)  # (1, num_heads, seq_len, seq_len)
  print(f"相对位置偏置形状: {bias.shape}")  # torch.Size([1, 8, 128, 128])

4.2 相对位置编码在现代 Transformer 中的应用

What — 什么是注意力偏置(Attention Bias)?

torch.nn.functional.scaled_dot_product_attention 中,可以通过 attn_mask 参数引入相对位置偏置。现代模型如 Swin Transformer、DeiT 等广泛使用此机制。

How — 使用 SDPA 实现带相对位置偏置的 Attention
import torch
  import torch.nn as nn
  import torch.nn.functional as F
  import math
  
  class RelativeAttention(nn.Module):
      def __init__(self, embed_dim, num_heads, max_distance=128):
          super().__init__()
          self.embed_dim = embed_dim
          self.num_heads = num_heads
          self.head_dim = embed_dim // num_heads
          # QKV 投影层
          self.q_proj = nn.Linear(embed_dim, embed_dim)
          self.k_proj = nn.Linear(embed_dim, embed_dim)
          self.v_proj = nn.Linear(embed_dim, embed_dim)
          self.out_proj = nn.Linear(embed_dim, embed_dim)
          # 相对位置偏置
          self.relative_pos = RelativePositionEmbedding(self.head_dim, max_distance, num_heads)
  
      def forward(self, x):
          batch_size, seq_len, _ = x.size()
          # QKV 投影
          Q = self.q_proj(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
          K = self.k_proj(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
          V = self.v_proj(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
          # 获取相对位置偏置
          attn_bias = self.relative_pos(seq_len)  # (1, num_heads, seq_len, seq_len)
          # 使用 SDPA 计算注意力,加入相对位置偏置
          attn_mask = attn_bias  # 直接作为 attn_mask 传入
          attn_output = F.scaled_dot_product_attention(
              Q, K, V, attn_mask=attn_mask
          )
          # 合并多头输出
          attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, seq_len, self.embed_dim)
          return self.out_proj(attn_output)
  
  # 使用示例
  batch_size, seq_len, embed_dim = 2, 64, 512
  x = torch.randn(batch_size, seq_len, embed_dim)
  attn = RelativeAttention(embed_dim, num_heads=8, max_distance=32)
  output = attn(x)
  print(f"输出形状: {output.shape}")  # torch.Size([2, 64, 512])

本节要点

  • 相对位置编码编码 token 之间的相对距离,而非绝对位置
  • 通过在 Attention 计算中加入相对位置偏置实现
  • 相对距离通常 clip 到固定范围(如 [-64, 64]),避免参数爆炸
  • 现代 Swin Transformer、DeiT 等模型广泛采用

五、旋转位置编码(RoPE)

5.1 理论背景

What — 什么是旋转位置编码(RoPE)?

旋转位置编码(Rotary Positional Encoding,简称 RoPE)由 Su et al. 在 2022 年的论文 "RoFormer: Enhanced Transformer with Rotary Position Embedding" 中提出。RoPE 的核心思想是通过对 Query 和 Key 向量进行旋转操作,将位置信息编码到向量空间中,而不是简单地在 embedding 上叠加。

Why — RoPE 为什么有效?

动机一:内积形式的旋转不变性

RoPE 的关键洞察是:在自注意力中,我们实际计算的是 Q 和 K 的点积。通过对 Q 和 K 应用旋转矩阵,可以使得旋转后的点积包含位置信息:

RoPE(q_m, k_n) = (R_m q_m)^T (R_n k_n) = q_m^T R_m^T R_n k_n = q_m^T R_{m-n} k_n

这意味着旋转后的点积只依赖于相对位置 m-n,而不依赖于绝对位置。

动机二:无需显式添加偏置

与相对位置编码需要在 Attention 计算中添加偏置项不同,RoPE 通过修改 Q 和 K 本身来注入位置信息,计算更加高效。

动机三:支持长上下文

RoPE 天然支持任意长度的序列,无需预设 max_len,是 LLaMA、Mistral、Qwen 等主流 LLM 的核心位置编码方案。

没有 RoPE 会发生什么?

  • 无法获得理论上无限的上下文长度支持
  • 长序列场景下位置编码质量下降
  • 无法享受 FlashAttention 等高效注意力机制的加速
How — PyTorch 实现旋转位置编码(RoPE)

RoPE 将向量按维度两两配对,每对应用二维旋转矩阵:

import torch
  import torch.nn as nn
  import math
  
  def precompute_freqs_cis(dim, end_idx, theta=10000.0):
      # 预计算旋转角度频率
      # dim: embedding 维度(应为偶数)
      # theta: 缩放因子,控制频率衰减速度
      freqs = 1.0 / (theta ** (torch.arange(0, dim, 2).float() / dim))  # (dim/2,)
      # 生成角度:position * freqs
      idx = torch.arange(0, end_idx, device=freqs.device)  # (end_idx,)
      angle = idx.unsqueeze(1) * freqs.unsqueeze(0)  # (end_idx, dim/2)
      # 复数形式:cos + i*sin
      freqs_cis = torch.polar(torch.ones_like(angle), angle)  # (end_idx, dim/2) 复数
      return freqs_cis  # 返回复数形式的角度因子
  
  def apply_rotary_emb(x, freqs_cis):
      # x: (batch_size, seq_len, num_heads, head_dim) 或 (batch_size, seq_len, head_dim)
      # freqs_cis: (seq_len, head_dim/2) 复数
      x_complex = torch.view_as_complex(x.float().reshape(*x.shape[:-1], -1, 2))  # 转换为复数形式
      freqs_cis = freqs_cis.unsqueeze(0).unsqueeze(0)  # (1, 1, seq_len, head_dim/2)
      # 旋转:乘以 freqs_cis(复数乘法实现旋转)
      x_rotated = x_complex * freqs_cis  # (batch_size, seq_len, num_heads, head_dim/2) 复数
      # 转回实数形式
      x_out = torch.view_as_real(x_rotated).flatten(-2)  # (batch_size, seq_len, num_heads, head_dim)
      return x_out.type_as(x)  # 保持原始 dtype
  
  class RotaryPositionalEmbedding(nn.Module):
      def __init__(self, dim, max_seq_len=4096, theta=10000.0):
          super().__init__()
          self.dim = dim
          self.max_seq_len = max_seq_len
          self.theta = theta
          # 注册为 buffer,与模型一起保存/加载
          self.register_buffer('freqs_cis', precompute_freqs_cis(dim, max_seq_len, theta))
  
      def forward(self, seq_len):
          # 返回对应序列长度的频率复数
          return self.freqs_cis[:seq_len]
  
  # 使用示例
  batch_size, seq_len, num_heads, head_dim = 2, 128, 8, 64
  x = torch.randn(batch_size, seq_len, num_heads, head_dim)  # Q 或 K
  rope = RotaryPositionalEmbedding(head_dim, max_seq_len=2048)
  freqs_cis = rope(seq_len)  # (seq_len, head_dim/2)
  x_rotated = apply_rotary_emb(x, freqs_cis)
  print(f"输入形状: {x.shape}, 输出形状: {x_rotated.shape}")  # torch.Size([2, 128, 8, 64])

5.2 RoPE 在 LLM 中的实际应用

What — LLaMA 等模型如何使用 RoPE?

在实际的 LLM 训练框架(如 Hugging Face Transformers)中,RoPE 被集成到 Attention 模块中,在计算 Q、K 投影后立即应用旋转,然后进行注意力计算。

How — LLM 中完整的 RoPE Attention 实现
import torch
  import torch.nn.functional as F
  import math
  
  class RoPEAttention(nn.Module):
      def __init__(self, embed_dim, num_heads, max_seq_len=4096, rope_theta=10000.0):
          super().__init__()
          self.embed_dim = embed_dim
          self.num_heads = num_heads
          self.head_dim = embed_dim // num_heads
          assert self.head_dim % 2 == 0, "RoPE 要求 head_dim 为偶数"
          # QKV 投影
          self.q_proj = nn.Linear(embed_dim, embed_dim)
          self.k_proj = nn.Linear(embed_dim, embed_dim)
          self.v_proj = nn.Linear(embed_dim, embed_dim)
          self.out_proj = nn.Linear(embed_dim, embed_dim)
          # RoPE 位置编码
          self.rotary_emb = RotaryPositionalEmbedding(self.head_dim, max_seq_len, rope_theta)
  
      def forward(self, x, attention_mask=None, position_ids=None):
          batch_size, seq_len, _ = x.size()
          # QKV 投影
          Q = self.q_proj(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
          K = self.k_proj(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
          V = self.v_proj(x).view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)
          # 对 Q 和 K 应用 RoPE
          freqs_cis = self.rotary_emb(seq_len)
          Q = apply_rotary_emb(Q, freqs_cis)  # 对 Query 旋转
          K = apply_rotary_emb(K, freqs_cis)  # 对 Key 旋转
          # 使用 SDPA 计算注意力
          attn_output = F.scaled_dot_product_attention(Q, K, V, attn_mask=attention_mask)
          # 合并多头输出
          attn_output = attn_output.transpose(1, 2).contiguous().view(batch_size, seq_len, self.embed_dim)
          return self.out_proj(attn_output)
  
  # 使用示例:模拟 LLaMA 的 Attention 层
  batch_size, seq_len, embed_dim = 1, 256, 4096
  x = torch.randn(batch_size, seq_len, embed_dim)
  attn = RoPEAttention(embed_dim, num_heads=32, max_seq_len=8192, rope_theta=10000.0)
  output = attn(x)
  print(f"输入形状: {x.shape}, 输出形状: {output.shape}")  # torch.Size([1, 256, 4096])

RoPE 与其他位置编码的关键区别

  • 正弦位置编码和可学习位置编码是加到 embedding 上的绝对位置编码
  • 相对位置编码是在注意力分数上添加的偏置项
  • RoPE 是对 Q 和 K 本身应用旋转操作,直接将位置信息编码到向量中
  • RoPE 使得旋转后的 Q、K 点积天然包含相对位置信息,无需额外偏置

本节要点

  • RoPE 通过对 Q、K 应用旋转矩阵实现位置编码
  • 旋转后的点积天然包含相对位置信息(m-n)
  • 支持理论上的无限上下文长度,是 LLaMA、Mistral 等 LLM 的标准选择
  • 与 FlashAttention 等高效注意力机制完美兼容

六、三种位置编码的对比与适用场景

What — 如何选择合适的位置编码?

不同的位置编码方案各有优劣,需要根据具体任务和模型架构进行选择。

特性正弦位置编码可学习位置编码相对位置编码RoPE
位置类型 绝对位置 绝对位置 相对位置 相对位置
序列长度泛化 支持任意长度 不支持(固定 max_len) 支持(有界距离 clip) 支持任意长度
计算开销 低(预计算) 低(Embedding 查找) 中等(额外偏置计算) 低(旋转操作)
与 FlashAttention 兼容 需要特殊处理 完美兼容
典型应用 原始 Transformer BERT、GPT-2 Swin Transformer LLaMA、Mistral、Qwen

选型建议

  • 短序列 + 分类任务:可学习位置编码或正弦位置编码即可
  • 长上下文 LLM:优先选择 RoPE,支持长序列且计算高效
  • 视觉 Transformer:相对位置编码(如 Swin Transformer 的 Shifted Windows)
  • 代码生成模型:RoPE,对精确的相对位置依赖更强

本节要点

  • 正弦位置编码适合简单场景,可泛化到任意长度
  • 可学习位置编码灵活性高,但受限于 max_len
  • 相对位置编码在视觉 Transformer 中广泛使用
  • RoPE 是当前 LLM 的主流选择,平衡了性能和效率

七、FAQ(20 组)

以下是关于位置编码的常见问题解答,分为三个部分:基础概念、与正弦/可学习编码相关、RoPE 进阶问题。

第一部分:基础概念

Q1. Transformer 为什么要使用位置编码?

一句话结论:Self-Attention 本身不感知 token 位置,需要额外注入位置信息。 自注意力机制基于矩阵乘法 QK^T 计算相关性,无论 token 出现在序列的哪个位置,只要内容相同,注意力分数就相同。位置编码通过在输入层面或计算过程中注入位置信息,解决了这一根本问题。

Q2. 位置编码可以直接拼接(Concatenation)而不是相加(Addition)吗?

一句话结论:技术上可以,但相加是更常见且高效的选择。 原始 Transformer 论文选择相加而非拼接,主要考虑是参数效率——相加不增加维度,而拼接会使维度翻倍。更深层的原因是可学习的位置编码可以自然地通过线性变换融入 token 表示。

Q3. 位置编码必须和 Token Embedding 放在同一维度空间吗?

一句话结论:不一定,但通常是。 在原始 Transformer 中,PE 和 token embedding 维度相同,可以直接相加。如果维度不同,需要通过线性投影将 PE 投影到 embedding 空间,或将拼接后的向量投影到统一维度。

Q4. 为什么正弦位置编码选择 sin 和 cos 交替的形式?

一句话结论:奇偶维度的 sin/cos 交替使得相邻位置在每个维度上都有不同的表示。 如果全部使用 sin,则偶数和奇数维度完全相同(只差一个相位);交替使用 sin/cos 确保每个位置的编码向量在所有维度上都不同。

Q5. 位置编码可以作用在 Attention 输出上吗?

一句话结论:标准做法是作用在输入层,但理论上可以作用于任何位置。 原始 Transformer 在输入 embedding 后立即添加位置编码,使得整个网络在处理过程中都带有位置信息。一些变体如 "Attention with Relative Position Embeddings" 将相对位置编码加入注意力分数计算。

Q6. 为什么说 Self-Attention 是 permutation equivariant 而不是 permutation invariant?

一句话结论:permutation equivariant 表示输出会随输入顺序变化而相应变化(一一对应),permutation invariant 表示输出完全不变。 对于 Self-Attention,输入 token 打乱顺序后,输出的 token 顺序也跟着打乱——每个输出位置的 token 身份发生了变化(对应到新位置的内容),但整体对应关系保持一致。这是 equivariant 的定义,而非 invariant(完全不变)。例如输入 [A, B, C] 对应输出 [out_A, out_B, out_C],打乱输入顺序后,输出顺序也随之打乱。

Q7. 位置编码的值域范围是多少?

一句话结论:标准正弦位置编码的值域在 [-1, 1] 之间。 由于 sin 和 cos 函数的输出范围是 [-1, 1],所以正弦位置编码的每个维度都在这个范围内。可学习位置编码的值域取决于初始化和训练动态。

Q8. 位置编码和位置嵌入(Positional Embedding)是同一个概念吗?

一句话结论:在大多数语境下是同义词,但严格来说"编码"强调生成方式。 "位置编码"(Positional Encoding)强调通过特定函数(如 sin/cos)生成位置表示;"位置嵌入"(Positional Embedding)强调将位置作为可学习参数。在 Transformer 语境下,两者通常可以互换。

第二部分:正弦与可学习位置编码

Q9. 正弦位置编码可以学习吗?

一句话结论:原始正弦编码不可学习,但可以修改为半可学习版本。 原始 Vaswani 论文中的正弦编码使用固定公式,不包含可学习参数。实践中可以修改为让波长参数 theta 成为可学习的,或在相加后接一个线性变换层引入可学习性。

Q10. 可学习位置编码如何处理训练时未见过的序列长度?

一句话结论:无法直接处理,通常需要外推或微调。 常见策略包括:(1) 在训练时随机 drop 一些位置编码,增强泛化能力;(2) 使用位置插值(Position Interpolation)技术在推理时处理更长序列;(3) 在长序列上进行继续预训练。

Q11. 正弦位置编码为什么使用指数衰减的频率?

一句话结论:指数衰减使得低频维度编码长距离关系,高频维度编码短距离关系。 div_term = theta^(-2i/d) 使得随着 i 增大(向高位维度),频率指数下降。低频(长波长)对应远距离位置,高频(短波长)对应近距离位置,这与语言中局部性和长程依赖的双重需求相匹配。

Q12. 为什么可学习位置编码通常用正态分布初始化?

一句话结论:正态分布初始化保证初始位置编码的稳定性和多样性。 截断正态分布(trunc_normal)确保值不会过大,同时标准差通常设为与 d_model 相关(如 0.02 * sqrt(2/d_model)),使得位置编码的初始量级与 token embedding 相当。

Q13. 位置编码需要 Dropout 吗?

一句话结论:可以加,但不是必须的。 在 transformer 原始实现中,位置编码与 embedding 相加后通常会过 Dropout,但这不是严格的。在大多数预训练模型中,embedding 层的 Dropout 已经覆盖了位置编码。

Q14. 为什么 BERT 使用可学习位置编码而不是正弦编码?

一句话结论:可学习编码更灵活,且 BERT 主要用于分类任务,不需要外推到超长序列。 BERT 的预训练任务是 MLM,不需要生成超长文本,且序列长度固定为 512。可学习编码的参数效率高,实验表明在这类任务上表现良好。

第三部分:RoPE 进阶问题

Q15. RoPE 的旋转角度是如何计算的?

一句话结论:旋转角度 = position * (theta^(-2i/d)),其中 theta 通常为 10000。 角度计算公式源自苏剑林等人的论文,用于生成不同维度的旋转频率。theta=10000 是原始论文的默认值,类似于正弦编码中的 10000。

Q16. RoPE 为什么要求 head_dim 为偶数?

一句话结论:因为 RoPE 需要将向量按 2 维配对进行二维旋转。 二维旋转矩阵作用于 2D 向量,所以 head_dim 必须能被 2 整除。如果 head_dim 是奇数,最后一维无法配对,通常需要将最后一维直接保留或单独处理。

Q17. RoPE 和复数有什么关系?

一句话结论:RoPE 的旋转操作在复数空间中实现更高效。 二维旋转本质上等价于复数乘法:(cosθ + i*sinθ) * (x + iy)。使用复数实现可以避免显式的三角函数计算,利用 torch.polar 和 view_as_complex/view_as_real 实现高效旋转。

Q18. 为什么 RoPE 在长序列场景下优于其他方案?

一句话结论:RoPE 无需预设 max_len,且与 FlashAttention 等高效注意力机制完美兼容。 可学习位置编码受限于 max_len,正弦编码虽然支持外推但效率较低。RoPE 通过预计算频率因子支持任意长度,同时旋转操作可以融合到注意力计算中,享受 FlashAttention 的融合 kernel 加速。

Q19. LLaMA 2 和 LLaMA 3 的 RoPE 有什么改进?

一句话结论:主要改进是增大 rope_theta 参数以提升频率分辨率。 LLaMA 2 将 rope_theta 从默认的 10000 提升到 50000,LLaMA 3 进一步提升。更高的 theta 意味着更慢的频率衰减速度(theta^(-2i/d) 中的分母更大),使得低频维度对应更长波长,模型在更长距离上仍能保持有效的位置感知能力,从而提升长上下文利用率。

Q20. RoPE 可以和其他位置编码结合使用吗?

一句话结论:理论上可以,但实践中通常单独使用 RoPE。 混合位置编码(Hybrid PE)如 "RoPE with ALiBi" 等方案存在,但主流 LLM 选择单一方案以简化实现和保证稳定性。RoPE 本身已经能够很好地捕获相对位置信息。

FAQ 总纲

  • 位置编码解决 Self-Attention 不感知位置的根本问题
  • 正弦编码支持外推,可学习编码更灵活但受限于 max_len
  • RoPE 是当前 LLM 主流方案,支持长序列且计算高效
  • 位置编码选择需要根据具体任务和模型架构决定

八、Roadmap 预告

后续专题预告

  • 专题十七:FlashAttention 1/2/3 详解:从注意力计算到 IO 优化
  • 专题十八:Attention 变体大盘点:Multi-Query Attention、Grouped Query Attention、FlashAttention
  • 专题十九:Transformer 训练技巧:从梯度裁剪到混合精度,从 Warmup 到 Cosine Annealing
  • 专题二十:大模型分布式训练:DeepSpeed ZeRO、PyTorch FSDP、模型并行策略

欢迎持续关注,共同深入探索深度学习的核心技术。


posted @ 2026-07-24 15:58  左扬  阅读(22)  评论(0)    收藏  举报