查看全课大纲(第 21 / 40 节)
- 1.从规则到学习:深度学习解决什么问题
- 2.表示学习与端到端训练
- 3.张量、计算图与自动微分
- 4.训练三要素:数据、模型、目标函数
- 5.从感知机到多层感知机
- 6.反向传播:手推一个两层网络
- 7.激活函数与初始化
- 8.优化器:SGD → Momentum → Adam
- 9.正则化:权重衰减、Dropout、BN/LN
- 10.卷积的直觉与参数共享
- 11.池化、感受野与下采样
- 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
- 13.残差连接与归一化为什么有效
- 14.迁移学习与特征可视化
- 15.RNN 与 BPTT
- 16.LSTM/GRU:门控如何缓解梯度问题
- 17.Seq2Seq 与编码器-解码器
- 18.注意力前史:Bahdanau 加性注意力
- 19.Self-Attention:QKV 与缩放点积
- 20.多头注意力:并行的多种关系
- 21.位置编码:正弦与可学习
- 22.残差、LayerNorm 与前馈层
- 23.从零实现一个最小 Transformer block
- 24.训练稳定性、复杂度与常见误区
- 25.自监督与语言建模目标
- 26.BERT:掩码语言模型与双向编码
- 27.GPT:自回归生成与缩放定律
- 28.指令微调与对齐:SFT → RLHF/DPO
- 29.参数高效微调:LoRA 与适配器
- 30.自编码器与 VAE
- 31.GAN:对抗训练与模式崩塌
- 32.扩散模型:前向加噪与反向去噪
- 33.扩散模型的应用与采样加速
- 34.对比学习与 CLIP
- 35.ViT:把图像当序列
- 36.多模态架构:对齐、融合与生成
- 37.能力边界、幻觉与评测
- 38.PyTorch 2.x 训练循环与混合精度
- 39.实验管理、评测与复现
- 40.推理部署与成本:量化/蒸馏概览
位置编码:正弦与可学习
小象实战讲义 · 深度学习基础:从神经网络到 Transformer
上一节我们把单个注意力头扩成了多头,但所有头加起来仍然只做一件事:拿内容去和内容比。这带来一个问题——自注意力本身是排列等变的。如果打乱输入序列的顺序,输出也只会跟着同样地打乱,模型无法感知“哪个词在哪个位置”。为了解决这个问题,Transformer 引入了位置编码。本节我们看两种主流方案:原始论文提出的正弦位置编码和实践中常用的可学习位置编码,理解它们如何为模型注入位置信息。
先修:缩放点积注意力的四步(本章 5.1 节)、张量广播规则、nn.Embedding 的用法。后续:下一节讲残差、LayerNorm 与前馈层,之后用这些零件从零拼出一个完整的 Transformer block。
💡 核心导读
- 问题的根源:自注意力机制是排列等变的,无法区分
[我,爱,你]和[你,爱,我],因此需要显式地加入位置信息。 - 正弦编码原理:使用一组固定、预先计算好的正弦和余弦函数来生成位置向量,其设计保证了模型能轻松学习到相对位置关系。
- 可学习编码实现:将每个位置索引视为一个可学习的嵌入向量,通过训练来适应任务需求,这是 BERT 等现代预训练模型的常见选择。
- 编码的融合:位置编码与词嵌入通过简单的相加进行融合,共同作为 Transformer 层的输入。
- 选择与权衡:理解两种方案的优缺点,以及在不同场景下的适用性。
🧭 前置知识
- Transformer 自注意力机制:了解
Query、Key、Value的计算过程。 - 词嵌入:理解将离散的 token 映射为连续向量的概念。
- 正弦/余弦函数:了解其周期性。
- 矩阵/张量加法:理解广播机制。
为什么需要位置编码?
自注意力通过计算所有词对之间的关联度来工作。对于输入序列 (n 为序列长度,d 为模型维度),其输出 。如果我们对输入序列的行(即词的位置)做任意置换 (左乘一个置换矩阵),那么输出也会被同样置换:。
这意味着,模型仅根据词的内容(嵌入)计算注意力,而完全忽略了词的顺序。在自然语言中,“猫追老鼠”和“老鼠追猫”的含义截然不同,因此必须将位置信息注入模型。
正弦位置编码:一种固定的方案
在《Attention Is All You Need》(Vaswani et al., 2017,arXiv:1706.03762)中,作者提出了一种基于正弦和余弦函数的固定位置编码方法。对于位置 和维度索引 ,编码值 的计算如下:
这里, 是模型的隐藏维度(例如 512)。为什么这样设计?
- 唯一性:每个位置都有唯一的编码向量。
- 相对位置可学习:对于固定的偏移量 , 可以表示为 的线性函数(旋转一个与 有关的角度,不依赖 ),这使得模型能够轻松学习到相对位置信息。
- 值域有界:正弦和余弦函数的值域为 ,逐元素加到词嵌入上不会淹没语义信号。
让我们通过代码实现并观察其特性:
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…)使用余弦函数。频率项 使得不同维度具有不同的波长(从 到 ),从而编码了从细粒度到粗粒度的位置信息。
可学习位置编码:一种自适应的方案
虽然正弦编码具有优雅的数学性质,但在许多现代 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])。 | 对词嵌入进行适当的缩放(如乘以 ),或使用与位置编码尺度匹配的初始化。 |
在可学习编码中,max_seq_len 设置过小 | 推理时遇到更长的序列会报 IndexError。 | 嵌入层索引越界。 | 根据任务可能的最大序列长度设置 max_seq_len,或实现序列截断/分块策略。 |
| 混淆了位置编码的“位置” | 在批处理中,错误地为每个样本计算不同的位置编码。 | 误解了位置编码应在批次间共享的概念。 | 位置编码应仅依赖于序列中的索引位置,与批次中的样本无关。使用广播机制正确相加。 |
本章小结
- 位置编码的必要性:自注意力机制本身不具备位置感知能力,必须显式注入位置信息。
- 正弦位置编码:使用预设的正弦和余弦函数生成,具有确定性和良好的相对位置外推性,但模式固定。
- 可学习位置编码:将每个位置索引视为可学习的嵌入向量,通过训练自适应,灵活性高,是现代预训练模型的主流选择。
- 融合方式:位置编码与词嵌入通过逐元素相加的方式结合,共同构成 Transformer 的输入。
- 选择依据:根据任务对序列长度外推的需求以及训练数据的多少,在正弦编码的鲁棒性和可学习编码的灵活性之间做出权衡。
📋 行动清单
- 运行正弦位置编码的代码,尝试将
seq_len改为 20,观察不同频率维度的波形变化。 - 修改可学习位置编码的示例,初始化后打印
model.pe.weight的形状,并手动计算前向传播验证结果。 - 思考:如果在一个模型中同时使用正弦编码和可学习编码(例如相加),可能会有什么效果?(提示:这类似于残差连接中的“高速公路”)
延伸阅读
- Attention Is All You Need(Vaswani et al., 2017,arXiv:1706.03762)—— 3.5 Positional Encoding 章节。
- BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding(Devlin et al., 2018,arXiv:1810.04805)—— 位置嵌入是可学习的(随预训练一起更新),最大序列长度 512。
—— 小象教研组