Transformer 实现机制 · 第 6 课

位置编码 —— 整数、二进制与正弦波

⏱ 约 25 分钟 · 前置:第 5 课(余弦相似度) · 主干教材:Vizuara《The Transformers》1.5 后半(图 1.28–1.34) · 本课目标:说清注意力为什么天生不认识语序;亲手把三种位置编码方案的演进动机走一遍

第 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步)
00000
10001
20010
40100
81000
151111

两个漂亮性质:① 数值有界(只有 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 课全景再收拢)。

检索练习

本课主读材料

去读(约 12 分钟)

Vizuara《The Transformers》1.5 节后半(位置信息需求 → 整数 → 二进制 → 正弦),重点看图:1.29(整数尺度问题)、1.31(二进制位翻转)、1.32–1.33(正弦曲线按维度分层),与今天的滑块和热图互相对照。

出处打卡:《Attention Is All You Need》§3.5——正弦公式的原始出处,只需读这一小节(半页)。

我是你的老师,别客气 比如"为什么频率是 10000 的幂,不是别的数""可学习位置表和正弦表实战谁好",贴回来问。输入段至此全部通关,下一课正式进处理器:注意力。