查看全课大纲(第 21 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

位置编码:正弦与可学习

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

上一节我们把单个注意力头扩成了多头,但所有头加起来仍然只做一件事:拿内容去和内容比。这带来一个问题——自注意力本身是排列等变的。如果打乱输入序列的顺序,输出也只会跟着同样地打乱,模型无法感知“哪个词在哪个位置”。为了解决这个问题,Transformer 引入了位置编码。本节我们看两种主流方案:原始论文提出的正弦位置编码和实践中常用的可学习位置编码,理解它们如何为模型注入位置信息。

先修:缩放点积注意力的四步(本章 5.1 节)、张量广播规则、nn.Embedding 的用法。后续:下一节讲残差、LayerNorm 与前馈层,之后用这些零件从零拼出一个完整的 Transformer block。

💡 核心导读

  1. 问题的根源:自注意力机制是排列等变的,无法区分 [我,爱,你] 和 [你,爱,我],因此需要显式地加入位置信息。
  2. 正弦编码原理:使用一组固定、预先计算好的正弦和余弦函数来生成位置向量,其设计保证了模型能轻松学习到相对位置关系。
  3. 可学习编码实现:将每个位置索引视为一个可学习的嵌入向量,通过训练来适应任务需求,这是 BERT 等现代预训练模型的常见选择。
  4. 编码的融合:位置编码与词嵌入通过简单的相加进行融合,共同作为 Transformer 层的输入。
  5. 选择与权衡:理解两种方案的优缺点,以及在不同场景下的适用性。

🧭 前置知识

  1. Transformer 自注意力机制:了解 Query、Key、Value 的计算过程。
  2. 词嵌入:理解将离散的 token 映射为连续向量的概念。
  3. 正弦/余弦函数:了解其周期性。
  4. 矩阵/张量加法:理解广播机制。

为什么需要位置编码?

自注意力通过计算所有词对之间的关联度来工作。对于输入序列 X∈Rn×dX \in \mathbb{R}^{n \times d}(n 为序列长度,d 为模型维度),其输出 Z=Attention(XWQ,XWK,XWV)Z = \mathrm{Attention}(XW^Q, XW^K, XW^V)。如果我们对输入序列的行(即词的位置)做任意置换 PP(左乘一个置换矩阵),那么输出也会被同样置换:Z′=Attention((PX)WQ,(PX)WK,(PX)WV)=PZZ^{\prime} = \mathrm{Attention}((PX)W^Q, (PX)W^K, (PX)W^V) = PZ。

这意味着,模型仅根据词的内容(嵌入)计算注意力,而完全忽略了词的顺序。在自然语言中,“猫追老鼠”和“老鼠追猫”的含义截然不同,因此必须将位置信息注入模型。

正弦位置编码:一种固定的方案

在《Attention Is All You Need》(Vaswani et al., 2017,arXiv:1706.03762)中,作者提出了一种基于正弦和余弦函数的固定位置编码方法。对于位置 pospos 和维度索引 ii,编码值 PE(pos,i)PE_{(pos, i)} 的计算如下:

PE(pos,2i)=sin⁡(pos100002i/dmodel)PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right) PE(pos,2i+1)=cos⁡(pos100002i/dmodel)PE_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right)

这里,dmodeld_{\text{model}} 是模型的隐藏维度(例如 512)。为什么这样设计?

  1. 唯一性:每个位置都有唯一的编码向量。
  2. 相对位置可学习:对于固定的偏移量 kk,PEpos+kPE_{pos+k} 可以表示为 PEposPE_{pos} 的线性函数(旋转一个与 kk 有关的角度,不依赖 pospos),这使得模型能够轻松学习到相对位置信息。
  3. 值域有界:正弦和余弦函数的值域为 [−1,1][-1, 1],逐元素加到词嵌入上不会淹没语义信号。

让我们通过代码实现并观察其特性:

import torch
import math

torch.manual_seed(0)

def sinusoidal_positional_encoding(seq_len, d_model):
    """
    生成正弦位置编码矩阵。
    参数:
        seq_len: 序列最大长度
        d_model: 模型隐藏维度
    返回:
        pe: 形状为 (seq_len, d_model) 的位置编码矩阵
    """
    pe = torch.zeros(seq_len, d_model)
    position = torch.arange(0, seq_len, dtype=torch.float).unsqueeze(1) # (seq_len, 1)
    div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
    pe[:, 0::2] = torch.sin(position * div_term)  # 偶数维度用sin
    pe[:, 1::2] = torch.cos(position * div_term)  # 奇数维度用cos
    return pe

# 生成一个示例位置编码
seq_len = 10
d_model = 16
pe = sinusoidal_positional_encoding(seq_len, d_model)

print("位置编码矩阵形状:", pe.shape)
print("\n前3个位置,前8个维度的编码值(四舍五入到4位小数):")
for pos in range(3):
    print(f"位置 {pos}: {[round(v, 4) for v in pe[pos, :8].tolist()]}")

# 验证「移位可线性表示」:只看第 0 对维度(i=0),它的角频率 w = 1 rad/位置
# 三角恒等式:sin((pos+1)w) = cos(w)·sin(pos·w) + sin(w)·cos(pos·w)
w = 1.0
delta = pe[1, 0] - (math.cos(w) * pe[0, 0] + math.sin(w) * pe[0, 1])
print("\n验证移位线性关系(第 0 对维度,w=1 rad/位置):")
print(f"|PE(1,0) - [cos(w)*PE(0,0) + sin(w)*PE(0,1)]| = {abs(delta).item():.6f}")
位置编码矩阵形状: torch.Size([10, 16])

前3个位置,前8个维度的编码值(四舍五入到4位小数):
位置 0: [0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0]
位置 1: [0.8415, 0.5403, 0.311, 0.9504, 0.0998, 0.995, 0.0316, 0.9995]
位置 2: [0.9093, -0.4161, 0.5911, 0.8066, 0.1987, 0.9801, 0.0632, 0.998]

验证移位线性关系(第 0 对维度,w=1 rad/位置):
|PE(1,0) - [cos(w)*PE(0,0) + sin(w)*PE(0,1)]| = 0.000000

可以看到,位置编码矩阵的每个位置都有一个独特的向量。偶数维度(0,2,4…)使用正弦函数,奇数维度(1,3,5…)使用余弦函数。频率项 1100002i/d\frac{1}{10000^{2i/d}} 使得不同维度具有不同的波长(从 2π2\pi 到 2π∗100002\pi * 10000),从而编码了从细粒度到粗粒度的位置信息。

可学习位置编码:一种自适应的方案

虽然正弦编码具有优雅的数学性质,但在许多现代 Transformer 模型(如 BERT(Devlin et al., 2018,arXiv:1810.04805))中,更常使用的是可学习的位置编码。其思想非常简单:为序列中的每个可能位置(例如,从 0 到最大序列长度 512)分配一个可学习的向量,就像词嵌入一样。

import torch
import torch.nn as nn

torch.manual_seed(0)

class LearnablePositionalEncoding(nn.Module):
    def __init__(self, max_seq_len, d_model):
        super().__init__()
        # 定义一个可学习的嵌入层,每个位置索引对应一个 d_model 维的向量
        self.pe = nn.Embedding(max_seq_len, d_model)

    def forward(self, x):
        """
        参数:
            x: 输入张量,形状为 (batch_size, seq_len, d_model)
        返回:
            添加了位置编码的输入,形状不变
        """
        batch_size, seq_len, d_model = x.shape
        # 生成位置索引 [0, 1, ..., seq_len-1],并扩展到批次维度
        positions = torch.arange(seq_len, device=x.device).expand(batch_size, seq_len)
        # 获取位置编码并加到输入上
        positional_embeddings = self.pe(positions)
        return x + positional_embeddings

# 模拟一个前向传播过程
batch_size = 2
seq_len = 5
d_model = 8
max_seq_len = 512

model = LearnablePositionalEncoding(max_seq_len, d_model)
# 随机生成一批词嵌入(模拟)
word_embeddings = torch.randn(batch_size, seq_len, d_model)
output = model(word_embeddings)

print("输入词嵌入形状:", word_embeddings.shape)
print("输出(词嵌入+位置编码)形状:", output.shape)
print("\n输出与输入的差值(即位置编码)的形状:", (output - word_embeddings).shape)
print("同一批次内,不同样本在相同位置的位置编码是否相同?")
print("位置0的编码(样本1 vs 样本2):", torch.allclose(output[0,0] - word_embeddings[0,0], output[1,0] - word_embeddings[1,0]))
输入词嵌入形状: torch.Size([2, 5, 8])
输出(词嵌入+位置编码)形状: torch.Size([2, 5, 8])

输出与输入的差值(即位置编码)的形状: torch.Size([2, 5, 8])
同一批次内,不同样本在相同位置的位置编码是否相同?
位置0的编码(样本1 vs 样本2): True

可以看到,LearnablePositionalEncoding 模块内部维护了一个 nn.Embedding 层。在 forward 过程中,它根据输入序列的实际长度,取出对应位置的可学习向量,然后通过广播机制加到批次中的每一个样本上。同一位置在不同样本中的编码是相同的,这正是我们想要的绝对位置信息。

正弦 vs 可学习:如何选择?

特性正弦位置编码可学习位置编码
确定性固定,无需学习。随机初始化,通过训练学习。
外推性理论上可以处理任意长的序列(通过函数计算)。受限于预定义的 max_seq_len,对更长的序列需要裁剪或外推策略。
相对位置内建了相对位置关系(线性可表示),易于模型捕获。需要模型从数据中自行学习相对位置模式。
实践流行度原始 Transformer 论文使用。BERT、GPT 系列、RoBERTa 等绝大多数现代预训练模型使用。
灵活性较低,模式固定。较高,可以适应特定任务或领域的顺序模式。

一个容易被忽略的细节:原论文其实两种都试过。3.5 节写着 “We also experimented with using learned positional embeddings instead, and found that the two versions produced nearly identical results”(Table 3 row (E)),最终选正弦只是因为“它可能让模型外推到训练时没见过的更长序列”。所以别把这张表读成“正弦一定更好”或“可学习一定更好”——在训练长度固定的任务上,两者差距很小。

选择建议:

  • 序列长度变化很大、或明确需要外推时,选正弦编码:它是唯一有可能“算到训练长度之外”的固定方案,代价是外推效果在实践中并不理想。
  • 预训练-微调范式、训练长度固定时,选可学习编码:更灵活,也让位置表示直接从数据里学出来。BERT 就是这么做的。
  • 补充一句:2021 年之后的大模型(LLaMA、PaLM 等)大多改用 RoPE、ALiBi 这类相对位置方案,出发点正是正弦编码“外推只是理论上可行”这件事。本节只看这两种基础方案,相对位置的细节不在本课范围。

📝 动手练一练

问题:假设我们使用可学习位置编码,最大序列长度设为 8,模型维度为 8。在训练过程中,我们遇到一个长度为 10 的序列。直接使用现有的 LearnablePositionalEncoding 模块会遇到什么问题?请提出一种简单的解决方案(不涉及复杂的外推算法)。

👉 点击查看参考答案

问题分析: LearnablePositionalEncoding 内部 nn.Embedding 的参数矩阵形状为 (max_seq_len, d_model),即 (8, 8)。合法索引只有 0–7,当请求位置索引 9(0-based)时,会引发 IndexError,因为索引超出了嵌入层的范围。

简单解决方案: 一种常见的实践是对超长序列进行截断。在将序列输入模型之前,确保其长度不超过 max_seq_len。如果必须保留全部信息,可以考虑其他策略如滑动窗口,但最简单的仍是截断。

代码示例:

import torch
import torch.nn as nn

torch.manual_seed(0)

class LearnablePositionalEncoding(nn.Module):
    def __init__(self, max_seq_len, d_model):
        super().__init__()
        self.pe = nn.Embedding(max_seq_len, d_model)

    def forward(self, x):
        batch_size, seq_len, _ = x.shape
        positions = torch.arange(seq_len, device=x.device)
        return x + self.pe(positions.expand(batch_size, seq_len))

def safe_add_positional_encoding(model, x, max_allowed_len):
    if x.shape[1] > max_allowed_len:
        print(f"警告:序列长度 {x.shape[1]} 超过最大允许长度 {max_allowed_len},已截断。")
        x = x[:, :max_allowed_len, :]
    return model(x)

long_embeddings = torch.randn(1, 10, 8)          # 长度 10 > 8
model = LearnablePositionalEncoding(max_seq_len=8, d_model=8)
output = safe_add_positional_encoding(model, long_embeddings, max_allowed_len=8)
print("处理后输出形状:", output.shape)
警告:序列长度 10 超过最大允许长度 8,已截断。
处理后输出形状: torch.Size([1, 8, 8])

常见错误

错误理解或写法现象或报错原因正确做法
忘记添加位置编码模型在涉及顺序的任务(如机器翻译、文本分类)上性能极差。自注意力是排列等变的,模型无法区分顺序。确保在将词嵌入输入 Transformer 层之前,与位置编码相加。
位置编码与词嵌入尺度不匹配训练不稳定,梯度爆炸或消失。词嵌入可能经过 LayerNorm 或缩放,而位置编码值域固定(如正弦编码为[-1,1])。对词嵌入进行适当的缩放(如乘以 dmodel\sqrt{d_{model}}),或使用与位置编码尺度匹配的初始化。
在可学习编码中,max_seq_len 设置过小推理时遇到更长的序列会报 IndexError。嵌入层索引越界。根据任务可能的最大序列长度设置 max_seq_len,或实现序列截断/分块策略。
混淆了位置编码的“位置”在批处理中,错误地为每个样本计算不同的位置编码。误解了位置编码应在批次间共享的概念。位置编码应仅依赖于序列中的索引位置,与批次中的样本无关。使用广播机制正确相加。

本章小结

  1. 位置编码的必要性:自注意力机制本身不具备位置感知能力,必须显式注入位置信息。
  2. 正弦位置编码:使用预设的正弦和余弦函数生成,具有确定性和良好的相对位置外推性,但模式固定。
  3. 可学习位置编码:将每个位置索引视为可学习的嵌入向量,通过训练自适应,灵活性高,是现代预训练模型的主流选择。
  4. 融合方式:位置编码与词嵌入通过逐元素相加的方式结合,共同构成 Transformer 的输入。
  5. 选择依据:根据任务对序列长度外推的需求以及训练数据的多少,在正弦编码的鲁棒性和可学习编码的灵活性之间做出权衡。

📋 行动清单

  • 运行正弦位置编码的代码,尝试将 seq_len 改为 20,观察不同频率维度的波形变化。
  • 修改可学习位置编码的示例,初始化后打印 model.pe.weight 的形状,并手动计算前向传播验证结果。
  • 思考:如果在一个模型中同时使用正弦编码和可学习编码(例如相加),可能会有什么效果?(提示:这类似于残差连接中的“高速公路”)

延伸阅读

  1. Attention Is All You Need(Vaswani et al., 2017,arXiv:1706.03762)—— 3.5 Positional Encoding 章节。
  2. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding(Devlin et al., 2018,arXiv:1810.04805)—— 位置嵌入是可学习的(随预训练一起更新),最大序列长度 512。

—— 小象教研组