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

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


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

1.1 Self-Attention 天生「失序」

Self-Attention 最大的卖点是完全并行处理序列——不管句子里的词怎么排,注意力矩阵的计算逻辑都只会考虑「词与词之间的语义相似度」,完全不管谁在前谁在后。

举个最直观的反例:

输入1词序:我 打 你 → 核心语义:我是施害方,你是受害方
输入2词序:你 打 我 → 核心语义:完全颠倒!
但 Self-Attention 算出来的结果,语义聚合、权重分配这些东西是一模一样的!

这时候的 Transformer,本质上就是个只会看词袋的“高级统计模型”——完全没法做文本摘要、对话生成、翻译这类「顺序直接决定输出正确与否」的任务。

1.2 最直接的解决方案:给词贴「位置标签」

既然词本身不带位置,那我们就人为给每个位置生成一个唯一的“位置向量标签”,然后和词的语义向量直接相加/拼接(原始 Transformer 和主流模型都选「直接相加」,因为拼接会把向量维度翻一倍,增加不必要的计算量)。

这样一来:

  • 词向量负责记住「这个词是什么意思」
  • 位置向量负责记住「这个词在句子的第几个位置」
  • 两者相加后的「最终表示向量」,就同时具备了语义和位置双重属性

2. 原始 Transformer 用的:正弦/余弦绝对位置编码

绝对位置编码的思路最简单粗暴——直接给「位置0」「位置1」……「位置N」分别分配固定的、完全不一样的向量。原始 Transformer 没有用随机生成的,而是用了一组数学性质非常好的正弦、余弦函数组合

2.1 完整 PyTorch 实现

我们直接搬用 PyTorch 官方教程里优化过的版本(加上了 Dropout 防止过拟合,还把位置向量存在 buffer 里,训练时不会被反向传播修改,但会随模型一起保存):

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: Transformer 输入/输出的固定向量维度(论文里用了 512)
            max_len: 预定义的最大序列长度(超过这个长度需要额外扩展,但原始论文的性质允许一定的泛化)
            dropout: 防止过拟合的 Dropout 比例
        """
        super().__init__()
        self.dropout = nn.Dropout(p=dropout)

        # 第一步:初始化一个全 0 的位置编码矩阵,形状是 (max_len, d_model)
        position_enc = torch.zeros(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)
        )

        # 第四步:分别填充正弦和余弦部分——偶数维度用 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_size, seq_len, d_model) 的输入词向量相加(PyTorch 的广播机制)
        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:
            加上位置编码后的最终表示向量,形状和输入完全一致
        """
        # 只取前 seq_len 个位置的编码(防止输入序列比预定义的 max_len 短)
        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 实现

用 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: Transformer 输入/输出的固定向量维度
            max_len: 预定义的最大序列长度(**推理时不能超过这个长度!** 这点不如正弦余弦灵活)
        """
        super().__init__()
        # 用 Embedding 层存储位置向量:输入是位置索引(0 到 max_len-1),输出是 d_model 维的位置向量
        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 需要的位置索引:0 到 seq_len-1,注意要和输入在同一个设备上(CPU/GPU/TPU)
        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. 现代 LLM 标配:RoPE(旋转位置编码)

绝对位置编码不管是固定的还是可学习的,都是直接加到词向量上——对于现在的超长上下文大模型(比如 LLaMA 3.1 的 128K、Claude 3.5 Sonnet 的 200K)来说,这种方式会有「长距离位置信息衰减」的问题。

RoPE(Rotary Position Embedding,旋转位置编码)是 2021 年苏剑林团队提出的,完美解决了这个问题——它不是加到词向量上,而是直接对 Attention 的 Q(查询)和 K(键)做“旋转”操作,天然就保留了长距离的相对位置关系。

4.1 核心简化逻辑(大白话)

我们不用管复杂的数学推导,只需要记住两点:

  1. 它把 Q/K 的向量两两分组(比如 d_model=512,分成 256 组)
  2. 每组都用不同的「角度」旋转——位置越靠后,旋转的角度越大

这样一来,当我们计算 Q 和 K 的点积(Attention 权重的核心步骤)时,相对位置差的信息就自动被包含进去了,而且衰减非常慢,特别适合超长上下文。

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: 多头注意力中每个头的向量维度(比如 d_model=512,num_heads=8,那么 head_dim=64)
            base: 频率衰减的基数(论文里用了 10000,LLaMA 系列也沿用了这个值)
        """
        super().__init__()
        # 预计算频率衰减因子,和正弦余弦编码里的 div_term 逻辑一样
        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: 当前序列的长度(如果 x 已经是完整的 Q/K,也可以直接取 x.size(2))
        Returns:
            cos_emb, sin_emb: 旋转用的余弦和正弦向量,形状是 (seq_len, head_dim)
        """
        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

5. 三种主流位置编码对比小结

特性正弦/余弦绝对位置编码可学习绝对位置编码RoPE(旋转位置编码)
长上下文支持有限但可泛化完全不能超过预定义优秀(200K+ 轻松搞定)
相对位置学习能力强(线性表示)弱(依赖训练数据)超强(天然融入点积)
计算效率高(无参数)中(有少量参数)高(无参数)
适用场景通用预训练、长文本泛化短文本特定任务现代 LLM 几乎全用它
代表模型原始 Transformer、BERTBERT-base 早期版本、GPT-1LLaMA 系列、ChatGLM 系列、Qwen 系列

💡 金句总结:没有位置编码的 Transformer = 高级词袋模型,顺序颠倒输出不变;2026 年想做大模型/长文本任务,直接上 RoPE!


🔗 扩展阅读