位置编码 (Positional Encoding):给没有顺序的矩阵注入"位置感"

📂 所属阶段:第三阶段 — Transformer 革命(核心篇)
🔗 相关章节多头注意力 (Multi-Head Attention) · Transformer 完整架构


1. 为什么我们必须加「位置信号」?

1.1 Self-Attention 天生「失序」

Transformer 中 Self-Attention 最让人惊叹的一点,是它可以一口气并行处理整个序列。无论一个句子有多长,所有词都能同时计算彼此之间的注意力权重,彻底摆脱了 RNN “一个词接一个词”的串行限制。

但高效的代价是:注意力矩阵本身完全不知道词的先后顺序。它在做运算时只会关心“这个词和那个词语义上是否相似”,至于谁是主语、谁是宾语、谁在句首、谁在句尾——它一概不问。

举个最直观的例子:

输入1:我 打 你   → 我是施害方,你是受害方
输入2:你 打 我   → 意思完全颠倒!

如果把这两个句子分别输入一个没有位置信息的 Self-Attention,计算出来的注意力权重、语义向量几乎一模一样。模型无法分辨究竟是“我打你”还是“你打我”。这时 Transformer 本质上只是一个 “高级词袋模型”,没法完成翻译、摘要、对话这类必须考虑词序的任务。

1.2 最直接的解决方案:给词贴上“位置标签”

既然 Self-Attention 天生没有顺序感,那我们干脆人工为每个位置生成一个独一无二的 “位置向量” ,然后把它和词本身的语义向量结合起来。这样一来:

  • 词向量 负责记住“这个词是什么意思”
  • 位置向量 负责记住“这个词在句子的第几个位置”
  • 两者相加后,就形成了一种同时具备语义和位置双重信息的最终表示

在原始 Transformer 以及后续大多数模型中,都选择了 直接相加 的方式(而不是拼接),因为拼接会让向量维度翻倍,增加额外的计算量。


2. 原始 Transformer 的经典方案:正弦/余弦绝对位置编码

绝对位置编码的思路非常直白:给「位置0」「位置1」一直到「位置 N」分别分配一个固定的、唯一的向量。原始 Transformer 的设计者们没有随机生成这些向量,而是使用了一组数学性质极好的正弦与余弦函数组合。

2.1 完整 PyTorch 实现

我们直接搬出一个在实践中优化过的版本:将位置编码存在 buffer 中,既不参与反向传播,又会随模型一起保存;同时加上 Dropout 防止过拟合。

import torch
import torch.nn as nn
import math

class SinCosPositionalEncoding(nn.Module):
    """原始 Transformer 论文中的正弦/余弦绝对位置编码"""
    def __init__(self, d_model: int, max_len: int = 5000, dropout: float = 0.1):
        """
        Args:
            d_model: 每个词的向量维度(论文中为 512)
            max_len: 预设的最大序列长度
            dropout: 防止过拟合
        """
        super().__init__()
        self.dropout = nn.Dropout(p=dropout)

        # 初始化一个全 0 的位置编码矩阵,形状 (max_len, d_model)
        position_enc = torch.zeros(max_len, d_model)

        # 位置索引序列 [0, 1, 2, ..., max_len-1] → 形状 (max_len, 1)
        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)
        )

        # 偶数维用 sin,奇数维用 cos,实现不同位置向量的唯一性
        position_enc[:, 0::2] = torch.sin(position * div_term)
        position_enc[:, 1::2] = torch.cos(position * div_term)

        # 扩展为 (1, max_len, d_model),方便与 batch 输入相加
        position_enc = position_enc.unsqueeze(0)

        # 注册为 buffer:不参与训练,但保存到模型文件中
        self.register_buffer("position_enc", position_enc)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Args:
            x: 输入词向量,形状 (batch_size, seq_len, d_model)
        Returns:
            加上位置编码后的向量
        """
        # 只取当前序列长度对应的位置编码
        x = x + self.position_enc[:, :x.size(1), :]
        return self.dropout(x)

2.2 为什么偏偏选正弦和余弦?

你可能会问:“为什么不直接用一组随机生成的固定向量呢?多省事。” 答案是,正弦/余弦函数有三个随机向量不具备的关键优势:

  1. 相对位置关系可线性表示
    这种设计让模型能够较为容易地学到“位置 3 比位置 0 多往后挪了 3 步”这种相对信息。对于翻译、摘要这类需要关注前后词距离的任务,这个特性至关重要。

  2. 可以泛化到未见过的更长序列
    即使训练时设定的 max_len 是 5000,推理时突然来个 6000 词的句子,也完全不用慌——直接用同一套公式算出后面 1000 个位置的编码即可,无需重新训练。

  3. 计算高效且零额外参数
    位置编码在整个训练过程中是固定的,不会产生梯度,也不会占用反向传播的计算资源,推理速度也很快。


3. 更贴合任务的方案:可学习绝对位置编码

正弦/余弦编码虽好,但它毕竟是人工设计好的固定数值,不一定能完美适配所有任务。为此,一种更灵活的方式出现了:直接把位置向量也当成模型参数,和词向量一起端到端训练

3.1 极简 PyTorch 实现

利用 nn.Embedding,几行代码就能搞定:

import torch
import torch.nn as nn

class LearnedPositionalEncoding(nn.Module):
    """端到端可学习的绝对位置编码"""
    def __init__(self, d_model: int, max_len: int = 5000):
        """
        Args:
            d_model: 向量维度
            max_len: 最大序列长度(推理时不能超过此值!)
        """
        super().__init__()
        # 使用 Embedding 存储可学习的位置向量
        self.position_emb = nn.Embedding(max_len, d_model)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Args:
            x: 输入词向量,形状 (batch_size, seq_len, d_model)
        Returns:
            加上位置编码后的向量
        """
        batch_size, seq_len, _ = x.shape

        # 生成位置索引,并在 batch 维度上复制
        positions = torch.arange(seq_len, device=x.device).unsqueeze(0).repeat(batch_size, 1)

        # 取出对应的位置向量并相加
        position_vectors = self.position_emb(positions)
        return x + position_vectors

3.2 适用场景与局限

在短文本任务(如文本分类、命名实体识别)上,可学习位置编码的效果往往比正弦/余弦编码更好,因为它能根据数据特点自动调整位置表示。

但它有一个硬伤:预设的 max_len 就是天花板。推理时如果出现更长的序列,只能截断或填 0,再也没法像正弦/余弦那样随意外推。


4. 现代大语言模型的标配:RoPE(旋转位置编码)

前面两种绝对位置编码,无论固定还是可学习,都是直接把位置向量“加到”词向量上。当序列长度被拉到几万甚至几十万(如 LLaMA 3.1 的 128K、Claude 3.5 Sonnet 的 200K)时,这种操作容易导致长距离位置信息衰减。

2021 年苏剑林团队提出的 RoPE(Rotary Position Embedding,旋转位置编码) 给出了一个优雅的解法:它不再去“加”一个位置向量,而是直接用旋转矩阵去“转动”注意力机制中的 Q(查询)和 K(键),让相对位置信息自然融入点积运算中。

4.1 核心逻辑(大白话版)

不用纠结复杂的数学推导,掌握两点即可:

  • 把 Q 和 K 的向量两两分组(比如维度 512,就分成 256 对)
  • 每一对向量都按照各自的角度进行旋转,位置越靠后,旋转角度越大

这样,当我们计算 Q 和 K 的点积(这是注意力权重的核心步骤)时,位置差信息就已经自动被包含在里面了,而且随着距离增大的衰减极其缓慢,特别适合超长上下文场景。

4.2 极简 PyTorch 实现(旋转核心)

import torch
import torch.nn as nn

class RotaryPositionalEmbedding(nn.Module):
    """现代 LLM 标配的 RoPE 旋转位置编码(核心简化版)"""
    def __init__(self, head_dim: int, base: int = 10000):
        """
        Args:
            head_dim: 每个注意力头的向量维度
            base: 频率衰减基数(沿用原始 Transformer 的 10000)
        """
        super().__init__()
        # 预计算频率衰减因子
        self.inv_freq = 1.0 / (base ** (torch.arange(0, head_dim, 2).float() / head_dim))

    def forward(self, x: torch.Tensor, seq_len: int = None) -> tuple[torch.Tensor, torch.Tensor]:
        """
        Args:
            x: Q/K 向量,形状 (batch_size, num_heads, seq_len, head_dim)
            seq_len: 当前序列长度
        Returns:
            cos_emb, sin_emb: 用于旋转的余弦和正弦向量
        """
        if seq_len is None:
            seq_len = x.size(2)

        # 生成位置序列
        t = torch.arange(seq_len, device=x.device).type_as(self.inv_freq)

        # 计算每个位置、每个维度对的旋转角度
        freqs = torch.einsum("i,j->ij", t, self.inv_freq)

        # 扩展成与 Q/K 一样的形状
        cos_emb = torch.cat([freqs.cos(), freqs.cos()], dim=-1)
        sin_emb = torch.cat([freqs.sin(), freqs.sin()], dim=-1)

        return cos_emb, sin_emb

💡 实际使用时,我们会用返回的 cos_embsin_emb 对 Q、K 向量进行旋转,然后再计算注意力。由于这部分属于 Attention 层内部的实现细节,这里不再展开,感兴趣的同学可以查阅文末的原始论文。


5. 三种主流位置编码对比一览

特性正弦/余弦绝对位置编码可学习绝对位置编码RoPE(旋转位置编码)
长上下文支持有限但可泛化到更长序列完全不能超过预定义最大长度优秀,轻松应对 200K+ 超长上下文
相对位置学习能力强(可通过线性关系学到)弱(高度依赖训练数据的覆盖度)超强(位置信息天然融入点积计算)
计算效率高(无额外参数,无需梯度)中(引入少量可学习参数)高(无额外参数,仅需旋转操作)
适用场景通用预训练、需要泛化到长文本的任务短文本特定任务(分类、NER 等)现代开源/商用大语言模型几乎全用
代表模型原始 Transformer、BERTBERT-base 早期版本、GPT-1LLaMA 系列、ChatGLM 系列、Qwen 系列

💡 一句话总结:没有位置编码的 Transformer 就等于高级词袋模型,连“你打我”和“我打你”都分不清;到了 2026 年,想做长文本、大模型,直接上 RoPE 就对了。


🔗 扩展阅读