第 4 课埋的伏笔该还了。词元现在有了语义向量(嵌入),但进注意力之前还缺一样东西:语序。「猫咬狗」和「狗咬猫」的词元集合一模一样,意思却相反。谁来告诉模型谁在前谁在后?这就是位置编码(positional encoding)的使命——第 1 课地图输入段的最后一格。
一、注意力天生不认识"位置"
先看问题的根源。注意力做的事是"加权平均":每个词环顾四周,按相关度把大家的向量混进来。仔细想这个运算——它根本不关心谁在前谁在后,只关心"谁和谁相关"。数学上这叫置换等变:你把输入顺序打乱,输出也跟着同样打乱,每个词的输出值一字不变。
此刻你只需知道这些
注意力 = 每个词按"相关度"把其他词的信息加权混合进自己的表示。它为什么这样设计、Q/K/V 是什么,下一课正式从零介绍——本课只把它当一个"对顺序无感的混合器"来用。
空口无凭,跑一个真的注意力验证:
# ============================================================
# 【演示目标】证明"没有位置编码的注意力,无法区分语序":
# 同一个词,把它从句首挪到句尾,它的注意力输出一字不变。
# 【思路】
# 1. 三个词「猫/咬/狗」各有 4 维嵌入,组成输入矩阵 X(3 行 = 3 个位置)
# 2. 搭一个标准自注意力:Q/K/V 投影 → 打分 → softmax → 加权 V
# (每个零件的细节下一课起逐个拆;今天它只负责"当黑盒")
# 3. 把输入的「猫」和「狗」位置对调(乘一个置换矩阵 P),再算一遍
# 4. 对比:「猫」在位置 0 的输出 vs 「猫」在位置 2 的输出
# 读法: np.allclose 返回 True = 两个输出完全相同 → 模型眼里这俩场景没区别
# ============================================================
import numpy as np
rng = np.random.RandomState(7)
d = 4 # 嵌入维度(玩具尺寸)
X = np.array([[0.2, -0.1, 0.5, 0.1], # 猫(位置 0)
[0.9, 0.3, -0.2, 0.4], # 咬(位置 1)
[-0.3, 0.6, 0.2, -0.5]]) # 狗(位置 2)
# Q/K/V 投影矩阵:随机固定值(未训练,但注意力的"置换无感"与是否训练无关)
W_q = rng.randn(d, d) * 0.5
W_k = rng.randn(d, d) * 0.5
W_v = rng.randn(d, d) * 0.5
def attention(X):
# 标准自注意力:打分 → 缩放 → softmax → 加权 V(第 2 课讲过的三步)
Q, K, V = X @ W_q, X @ W_k, X @ W_v
scores = Q @ K.T / np.sqrt(d) # 缩放点积打分
scores = np.exp(scores - scores.max(axis=1, keepdims=True))
weights = scores / scores.sum(axis=1, keepdims=True)
return weights @ V
out_normal = attention(X) # 「猫咬狗」
P = np.array([[0., 0., 1.], [0., 1., 0.], [1., 0., 0.]]) # 置换矩阵:交换位置 0 和 2
out_swapped = attention(P @ X) # 「狗咬猫」
print("「猫」在位置 0 的输出:", out_normal[0].round(4))
print("「猫」挪到位置 2 后输出:", out_swapped[2].round(4))
print("两者完全相同?", np.allclose(out_normal[0], out_swapped[2]))
print("整体输出 = 置换后的整体输出?", np.allclose(out_swapped, P @ out_normal))
问题的本质
两行 True 说明:注意力眼里只有"词集合 + 词间关系",没有"位置"。对比 RNN——它按顺序逐词读入,位置感是天生的;Transformer 为了并行计算(第 13 课的优势来源)放弃了逐序处理,位置就必须额外喂进去。位置编码 = 给每个位置配一个向量,加到词嵌入上:x = 词嵌入 + 位置向量。
问题只剩一个:位置向量长什么样?原文 1.5 节展示了三次迭代,每一次都解决前一版的死穴——这是教科书级的方案演进,值得逐个走。
二、方案一:整数——直觉先行,败于尺度
最直白的方案:位置 0 就加 [0,0,0,…],位置 1 加 [1,1,1,…],位置 300 就加 [300,300,…,300]。手算看效果(嵌入取第 4 课的量级):
| 词 | 词嵌入 | + 位置向量(位置 300) | 合成输入 |
| cat | [0.2, −0.1, 0.5] | [300, 300, 300] | [300.2, 299.9, 300.5] |
| dog | [0.4, 0.3, −0.2] | [300, 300, 300] | [300.4, 300.3, 299.8] |
死穴:尺度爆炸
合成输入里,词与词的差异只有小数点后一位(±0.2),而位置底色是 300——cat 和 dog 的向量几乎相同,模型只看得见"这是第 300 个位置",看不见这是猫还是狗。位置信息没有"太多",是尺度压垮了语义(原文:位置 500 的整数撞上 0.23 量级的嵌入,图 1.29)。
三、方案二:二进制——分层的好想法,败在不连续
第二步迭代很聪明:别用整数本身,把位置数写成二进制,每一位当一个维度。0 到 15 的 4 位版本:
| 位置 | 位3(每8步翻转) | 位2(每4步) | 位1(每2步) | 位0(每1步) |
| 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 0 | 0 | 1 |
| 2 | 0 | 0 | 1 | 0 |
| 4 | 0 | 1 | 0 | 0 |
| 8 | 1 | 0 | 0 | 0 |
| 15 | 1 | 1 | 1 | 1 |
两个漂亮性质:① 数值有界(只有 0 和 1,不再爆炸);② 天然的分层——位 0 每步翻转(细粒度:"挨着谁"),位 3 每 8 步才翻(粗粒度:"在全句前半还是后半")。不同维度 = 不同频率,位置信息有了层次。
但死穴也在这:不连续。位置 7(0111)走到 8(1000),四个维度同时翻转——相邻位置之间是悬崖式跳变,整张表全是阶梯。梯度优化喜欢"挪一小步就好一点",阶梯没有坡可下(原文图 1.31 正是这个位翻转图案)。
四、方案三:正弦——把方波磨成波
现在只差最后一步转念:二进制每一位的"翻转节奏"本质是不同频率的方波;把方波换成平滑的 sin/cos,按频率从高到低铺满所有维度——不连续消失了,分层保住了。这就是 2017 年原论文的正弦位置编码:
公式(脚注)
$$PE(pos,\,2i) = \sin\!\left(pos \,/\, 10000^{2i/d}\right) \qquad PE(pos,\,2i{+}1) = \cos\!\left(pos \,/\, 10000^{2i/d}\right)$$
pos = 位置,i = 维度对编号,d = 嵌入维度(GPT-2 是 768)。维度对 2i/2i+1 共享同一频率,频率随 i 指数递减——从"每步振荡"到"1024 个位置才走完一圈"。
一个直觉模型(nano-ai.tech 系列的好类比):把它想成一块机械表。秒针转得最快(低维),分针慢一档,时针最慢(高维);单看秒针,一分钟内指针位置会重复,信息有歧义——但秒针+分针+时针的组合状态,在 12 小时内任意时刻几乎唯一。64 维"指针"拼在一起,就是每个位置独有的指纹。
# ============================================================
# 【演示目标】看见正弦位置编码的"频率分层":
# 低维(左)随位置快速振荡——管局部;高维(右)几乎不动——管全局
# 【思路】
# 1. 按原论文公式生成 64 个位置 × 64 维的 PE 矩阵
# 2. 左图:整张热图(横轴位置,纵轴维度)——左边花纹密,右边平缓
# 3. 右图:抽 3 个维度画"随位置的曲线"——维度 0 高频,维度 30 低频
# 顺带验证:所有值都在 [-1, 1] 之间(有界),曲线平滑(连续可导)
# ============================================================
import numpy as np
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore") # AGG 后端提示会污染输出区,静音
d = 64 # 嵌入维度
positions = np.arange(64) # 位置 0..63
pe = np.zeros((64, d))
for p in positions:
for i in range(d // 2):
pe[p, 2 * i] = np.sin(p / 10000 ** (2 * i / d)) # 偶数维:sin
pe[p, 2 * i + 1] = np.cos(p / 10000 ** (2 * i / d)) # 奇数维:cos
print("PE 全部值域: [%.2f, %.2f] ← 有界,不再有 300 那样的巨数" % (pe.min(), pe.max()))
diff = np.abs(pe[1] - pe[0]) # 位置 0 → 1,各维度的变化量
print("相邻位置的变化量: 第 0 维 %.2f(剧烈) 第 40 维 %.3f(几乎不动)" % (diff[0], diff[40]))
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.2))
im = ax1.imshow(pe.T, aspect="auto", cmap="RdBu_r", vmin=-1, vmax=1)
ax1.set_xlabel("position (0-63)")
ax1.set_ylabel("dimension (0-63)")
ax1.set_title("Sinusoidal PE matrix")
fig.colorbar(im, ax=ax1, fraction=0.046)
for dim, color in [(0, "#c0392b"), (10, "#1a7f37"), (30, "#5b5bd6")]:
ax2.plot(positions, pe[:, dim], lw=2, color=color, label="dim %d" % dim)
ax2.set_xlabel("position")
ax2.set_ylabel("PE value")
ax2.set_title("Different dims = different frequencies")
ax2.legend(fontsize=8)
ax2.grid(True, alpha=0.25)
plt.tight_layout()
plt.show()
左:整张 PE 矩阵——每个位置一行 64 维"指纹",左侧低维条纹密(高频),右侧平缓(低频)。右:维度 0 的曲线 64 个位置振荡了好几圈;维度 30 只走了小半圈。低维管"局部顺序",高维管"全局在哪"。
最后,把这张表装成可操作的工具——拖动滑块,盯住蓝条哪一端在动:
为什么它是最终答案:五个性质全占
对比三版方案:① 有界:所有值在 [−1, 1],不会像整数那样压垮词义;② 连续平滑:相邻位置的向量只差一点点(你刚在滑块里看到的),梯度有好坡下,不像二进制的悬崖;③ 零参数:公式直接算,不占训练成本;④ 可外推:比训练时更长的位置也能算(至少理论上);⑤ 可预计算:整张表提前算好存成查表,训练推理都只做加法。原文 1.5 节的原话:这五个性质让它成为 Transformer 的基石之一。
论文里的两个设计理由(Vaswani §3.5,进阶可选)
① 相对位置可学:对任意固定偏移 k,PE(pos+k) 都能表示成 PE(pos) 的线性函数——模型更容易学"隔 5 个位置"这类相对关系,而不是死记第 37 个位置;② 为什么不用可学习的位置向量:论文试过,效果和固定正弦几乎一样(Table 3(E)),最终选正弦是因为它对任意位置都能直接算——推理时可能遇上比训练更长的序列,固定公式外推更稳。再补一个易混点:嵌入矩阵的数值是训练出来的,无界;正弦编码恒在 [−1, 1]——相加之后,位置信息永远数量级可控,不喧宾夺主。
五、装回地图,再看一眼现实
至此输入段凑齐:词元(第 2–3 课)→ 语义向量(第 4 课)→ 位置向量(本课),两者逐位相加得到进注意力的初始表示——第 1 课地图上「词嵌入 + 位置编码」那一格,现在是完整公式了。
现实中的一句补充:正弦编码不是唯一的现代方案。GPT-2 实际用的是可学习位置表(同样是一张 ID→向量的查表,但数值靠训练学出来,且最大位置数固定);更晚的模型还有旋转位置编码(RoPE)等变体。但"给每个位置一个向量、多频率分层"的思想一脉相承——理解了本课,那些变体只是换个姿势(第 13 课全景再收拢)。
检索练习
本课主读材料
我是你的老师,别客气
比如"为什么频率是 10000 的幂,不是别的数""可学习位置表和正弦表实战谁好",贴回来问。输入段至此全部通关,下一课正式进处理器:注意力。