第 1 周 · Day 2:Transformer 块、位置编码与整体架构

Transformers and Large Language Models · Week 1 · Day 2。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

时长 8 小时:§1 FFN(1h)· §2 块的组装(1h)· §3 位置编码(2h)· §4 编码器-解码器与三种架构(1h)· §5 参数与 FLOPs 的整块账(1h)· §6 分词三家(1h)· §A–§C(1h)

对应正课 Lecture 1 的 Transformer 架构部分,以及开头的分词。Lecture 2 的位置编码(固定、可学习)也在这里打基础,RoPE 留到 Day 5。

配套代码 w1day2_checks.py(纯 numpy):FFN 是键值记忆、SwiGLU 的 2/3 规则与 11008、GELU 近似误差、残差流随深度增长与 1/2L1/\sqrt{2L}、正弦编码的旋转性质与点积、投影破坏相对性、三个模型的精确参数量、6N 规则、词表占比、BPE vs WordPiece、Unigram 的 Viterbi

§B 参考实现(code/ 目录):w1d2_block.py、w1d2_tokenizers.py。

今天的目标:能画出一个完整的 Transformer 块并说出每个零件的动机;能从正弦位置编码推出”相对位移是一个旋转”;能精确算出 GPT-2 small 的参数量。


§0 今天的主线

0.1 跳跃二:扔掉 RNN 之后要补什么

Day 1 做完了跳跃一(自注意力)和跳跃三(多头)。现在执行跳跃二:只用注意力,不要循环。 可是 Day 1 已经暴露了自注意力单独工作时的四个缺口:

缺口Day 1 的证据今天的解法
只会平均,不会逐位置加工§2.2 凸包性质FFN(§1)
堆深了会坍缩、不好训§2.2 秩坍缩残差 + LayerNorm(§2)
不知道顺序§1.5 置换等变位置编码(§3)
翻译这类任务要”读一句、写一句”Week 0 Day 4 §4编码器-解码器 + 交叉注意力(§4)

Transformer 块 = 注意力 + 四个补丁。 每个补丁都对应一个能定量说清的缺口。今天结束时,你手里会有一张完整的图,并且知道图上每个方框为什么在那里。

0.2 今天要用到的旧结论

  • Week 0 Day 2 §4.3–§4.5:LayerNorm 的雅可比是投影;Pre-LN 的残差流按 L\sqrt L 增长,梯度按 1/L1/\sqrt L 缩小
  • Week 0 Day 2 §5.3:残差分支输出按 1/2L1/\sqrt{2L} 缩放
  • Week 0 Day 3 §2.2:PMI;Week 0 Day 3 §7:BPE 就是压缩
  • Week 0 Day 1 §9:显存账;Week 0 Day 5 §3.3:权重绑定
  • Day 1 §2.2 凸包与秩坍缩;Day 1 §1.5 置换等变;Day 1 §4.5 QK / OV 电路;Day 1 §5.1 注意力的 FLOPs

§1 前馈网络(FFN):每个位置自己的加工车间

1.1 动机

Day 1 §2.2 证明了注意力的输出是 value 的凸组合:它能把信息从一个位置搬到另一个位置,但不能对单个位置做非线性加工。 仅有注意力的模型,本质上是在反复”平均”。

我们需要一个组件:对每个位置独立地做一个表达力强的非线性变换。

1.2 形式

FFN(x)=W2 σ(W1x+b1)+b2,W1∈Rdff×d, W2∈Rd×dff, dff=4d\mathrm{FFN}(x)=W_2\,\sigma(W_1x+b_1)+b_2,\qquad W_1\in\mathbb R^{d_{ff}\times d},\ W_2\in\mathbb R^{d\times d_{ff}},\ d_{ff}=4d

一个两层 MLP,对序列里每个位置用同一套参数、各自独立地算(所以叫 position-wise,等价于核宽为 1 的卷积)。原始论文 d=512d=512、dff=2048d_{ff}=2048。

分工一句话:注意力负责位置之间的通信,FFN 负责位置内部的计算。 一个块先通信、再计算,堆 LL 次。

1.3 直觉:FFN 是一张键-值记忆表

把矩阵乘拆开写(ki⊤k_i^\top 是 W1W_1 的第 ii 行,viv_i 是 W2W_2 的第 ii 列):

FFN(x)=∑i=1dffσ(ki⊤x+b1,i) vi+b2\mathrm{FFN}(x)=\sum_{i=1}^{d_{ff}}\sigma\big(k_i^\top x+b_{1,i}\big)\,v_i+b_2

这和注意力的形式几乎一样:

注意力FFN
键kjk_j,来自上下文,每句话不同kik_i,训练学到,与上下文无关
匹配q⊤kjq^\top k_jx⊤kix^\top k_i
权重softmax,归一化,和为 1σ(⋅)\sigma(\cdot),不归一化,多数为 0
值vjv_j,来自上下文viv_i,训练学到
槽数TT(序列长度)dffd_{ff}

Geva et al.(2021)据此提出:FFN 是一张存储在参数里的键-值记忆。kik_i 检测输入里的某种模式(比如”这个词在一个关于首都的句子里”),命中时把 viv_i 加到输出上(比如提升”Paris”的 logit)。这是后来”知识存在 FFN 里”、知识编辑(ROME 等)一系列研究的起点。

w1day2_checks.py 第 1 组验证了两种写法完全相等(差 2.2×10−162.2\times10^{-16});随机输入下每个 token 大约激活一半的槽(0.503,ReLU 对称输入时的期望值)。训练后激活会变得稀疏得多,这是 MoE(Lecture 3)“只激活一部分专家”思路的一个动机。

1.4 为什么是 4 倍,以及激活函数的演化

为什么 dff=4dd_{ff}=4d? 没有理论推导,是经验值。但可以说清它的预算含义:FFN 参数 2⋅d⋅4d=8d22\cdot d\cdot4d=8d^2,注意力 4d24d^2(Day 1 §4.4),FFN 占一层参数的 2/3(第 8 组:GPT-2 small 每层注意力 2.36M、FFN 4.72M)。它也是计算的大头:每 token 前向 16d216d^2 对注意力投影的 8d28d^2。所以谈 Transformer 的计算量,大部分其实是在谈 FFN。

激活函数:ReLU → GELU → SwiGLU。

  • ReLU(原始 Transformer):负半轴梯度为 0,神经元可能”死掉”。
  • GELU(BERT、GPT-2):xΦ(x)x\Phi(x),Φ\Phi 是标准正态 CDF。直觉是”按输入有多大可能是正的来决定保留多少”,一个平滑版的 ReLU。负半轴有小的非零输出(第 3 组:GELU(−1)=−0.159\mathrm{GELU}(-1)=-0.159),梯度不会完全消失。GPT-2 用的是 tanh 近似版,与精确版最大相差 4.7×10−44.7\times10^{-4}(第 3 组),见 §A.2。
  • SwiGLU(PaLM、Llama 等现代 LLM):Shazeer(2020)提出的门控变体

SwiGLU(x)=W2(Swish(W1x)⊙W3x),Swish(z)=z σ(z)\mathrm{SwiGLU}(x)=W_2\big(\mathrm{Swish}(W_1x)\odot W_3x\big),\qquad\mathrm{Swish}(z)=z\,\sigma(z)

一路做非线性、另一路做线性,逐元素相乘当门,和 LSTM 的门(Week 0 Day 4 §3.3)是同一种思想:让网络按输入决定”放多少信息过去”。

2/3 规则。 SwiGLU 有三个矩阵,要保持参数量不变,dffd_{ff} 要从 4d4d 缩到 23⋅4d=8d3\frac23\cdot4d=\frac{8d}{3}:3⋅d⋅8d3=8d23\cdot d\cdot\frac{8d}3=8d^2 ✓。Llama 还会向上取整到 256 的倍数以便硬件对齐:d=4096d=4096 时 8d/3=10922.67→8d/3=10922.67\to 11008(第 2 组)。这就是 Llama 7B 那个奇怪的 11008 的来历。

为什么 SwiGLU 更好? 老实说,Shazeer 论文的原话是”我们无法解释为什么这些架构有效,把它们的成功归功于神的仁慈”。经验上它在同样的参数和计算下困惑度更低,于是被广泛采用。这是一个”先有实验、后找解释”的设计,面试时这么答比编一个理由更诚实。

1.5 收益与代价

收益:逐位置的非线性加工能力;参数里的”记忆”。代价:参数和计算的大头;它和注意力一样不看顺序;推理时它是计算密集的而注意力的 KV 读取是访存密集的,这个差别决定了 Day 4 的推理优化方向。


§2 块的组装:残差与 LayerNorm

2.1 一个现代 decoder 块

            x ──────────────────────┐
            │                       │
          LN₁                       │
            │                       │
     Masked MHA(Day 1)             │
            │                       │
         Dropout                    │
            └──────────(+)──────────┘
                        │
            x' ─────────┼───────────┐
                      LN₂           │
                        │           │
                  FFN(§1)          │
                        │           │
                     Dropout        │
                        └───(+)─────┘
                             │
                            x''

x′=x+MHA(LN1(x)),x′′=x′+FFN(LN2(x′))x'=x+\mathrm{MHA}(\mathrm{LN}_1(x)),\qquad x''=x'+\mathrm{FFN}(\mathrm{LN}_2(x'))

LL 个块堆完,最后再加一个 LN,然后接输出头。这是 Pre-LN。原始 Transformer 用的是 Post-LN:x′=LN(x+MHA(x))x'=\mathrm{LN}(x+\mathrm{MHA}(x))。

2.2 为什么是 Pre-LN:复习与补充

Week 0 Day 2 §4.5 已经推过:Pre-LN 有一条一次 LayerNorm 都不经过的残差直通路径,梯度畅通;Post-LN 每层都重新归一化,深的时候靠近输出的参数梯度大 L\sqrt L 倍,离不开 warmup。原始 Transformer(Post-LN,6 层)用了 4000 步 warmup,这不是巧合。

残差流视角。 Pre-LN 下,xx 像一条贯穿全网的”总线”,每个子层读它(先 LN 再算)、再把结果加回去。结合 Day 1 §4.5 的 OV 电路:每个注意力头往总线上写一个低秩更新,每个 FFN 往总线上写一个”记忆读出”。模型的最终表示就是所有子层写入之和(加上初始嵌入)。这是阅读可解释性论文最有用的心智模型。

2.3 残差流的增长与 1/2L1/\sqrt{2L}

Pre-LN 的代价是残差流不断累加。w1day2_checks.py 第 4 组用完整的”注意力 + FFN”随机块实测每个 token 的 RMS(8 个种子):

层数 LL2832128
不缩放1.853.447.6115.28
输出投影 ×1/2L\times1/\sqrt{2L}1.251.251.251.25

不缩放时 LL 每乘 4,RMS 约翻倍(∝L\propto\sqrt L:2L2L 个近似独立的 O(1)O(1) 分支相加)。把每个残差分支的最后一个线性层(注意力的 WOW_O、FFN 的 W2W_2)初始化时乘 1/2L1/\sqrt{2L},残差流就与深度无关了。这就是 GPT-2 的做法(Week 0 Day 2 §5.3),Day 3 手写 mini-GPT 时要加上。

2.4 Dropout 放在哪

原始论文在三处用 dropout(p=0.1p=0.1):注意力权重上、每个子层输出加回残差之前、嵌入 + 位置编码之后。现代 LLM 预训练通常完全关掉 dropout:数据量远大于参数能记住的量,过拟合不是主要矛盾,dropout 只会拖慢训练。小数据(比如 Day 3 的 Tiny Shakespeare)上它仍然有用。

2.5 LayerNorm → RMSNorm

现代模型把 LN 换成 RMSNorm(Week 0 Day 2 §4.4):去掉减均值和 β\beta,只按均方根缩放。省一点计算,效果相当。Llama 系列、Qwen 系列都是 Pre-RMSNorm + SwiGLU + RoPE,这三件套分别来自今天 §2.5、§1.4 和 Day 5。


§3 位置编码

3.1 动机

Day 1 §1.5 证明了自注意力是置换等变的:“dog bites man”和”man bites dog”对它来说是同一袋词。要让模型知道顺序,必须把位置信息注入进去。

3.2 设计空间:从最笨的方案开始

我们要给每个位置 tt 一个向量 ptp_t,加到词嵌入上。依次考察几个候选,看每个撞上什么墙:

方案问题
pt=tp_t=t(直接用整数)数值无界,位置 1000 的量级压倒词嵌入
pt=t/Tp_t=t/T(归一化到 [0,1])同一个相对距离在长短句里对应不同的数值;推理时 TT 未知
pt=p_t= one-hot维度随最大长度增长,且任意两个位置”一样远”
pt=p_t= 可学习向量能用,BERT、GPT-2 都这么做;但训练时没见过的位置没有向量,无法外推(§3.7)

我们想要的性质:(1) 每个位置唯一;(2) 数值有界;(3) 任意长度都能算(不依赖训练见过);(4) 相对位置容易表达:模型最常需要的是”前一个词""三个词之前”,而不是”第 137 个位置”。

3.3 正弦位置编码:直觉

从二进制计数器出发。 把位置写成二进制:

0: 0 0 0 0      最低位每 1 步翻转一次
1: 0 0 0 1      第二位每 2 步翻转一次
2: 0 0 1 0      第三位每 4 步翻转一次
3: 0 0 1 1      ……

每一位是一个频率不同的方波,高位慢、低位快,组合起来唯一地编码位置。但方波是离散的、不光滑。把方波换成正弦波,就得到了 Vaswani 等人的方案:

 pt,2i=sin⁡(ωit),pt,2i+1=cos⁡(ωit),ωi=1100002i/d \boxed{\ p_{t,2i}=\sin(\omega_it),\qquad p_{t,2i+1}=\cos(\omega_it),\qquad\omega_i=\frac{1}{10000^{2i/d}}\ }

每对维度 (2i,2i+1)(2i,2i+1) 是一个频率为 ωi\omega_i 的”时钟指针”。i=0i=0 转得最快,波长 2π2\pi;i=d/2−1i=d/2-1 转得最慢,波长 2π⋅10000(d−2)/d2\pi\cdot10000^{(d-2)/d}(d=64d=64 时约 47117,第 5 组)。频率按几何级数排列,于是短距离和长距离都有对应的”刻度”。满足性质 (1)(2)(3)。

3.4 关键性质:相对位移是一个旋转

这是正弦编码真正的设计意图,也是 RoPE 的种子。 看第 ii 对维度,用和角公式:

sin⁡(ωi(t+k))=sin⁡(ωit)cos⁡(ωik)+cos⁡(ωit)sin⁡(ωik)cos⁡(ωi(t+k))=cos⁡(ωit)cos⁡(ωik)−sin⁡(ωit)sin⁡(ωik)\begin{aligned}\sin(\omega_i(t+k))&=\sin(\omega_it)\cos(\omega_ik)+\cos(\omega_it)\sin(\omega_ik)\\\cos(\omega_i(t+k))&=\cos(\omega_it)\cos(\omega_ik)-\sin(\omega_it)\sin(\omega_ik)\end{aligned}

写成矩阵:

(pt+k,2ipt+k,2i+1)=(cos⁡ωiksin⁡ωik−sin⁡ωikcos⁡ωik)⏟Rk(i)(pt,2ipt,2i+1)\begin{pmatrix}p_{t+k,2i}\\p_{t+k,2i+1}\end{pmatrix}=\underbrace{\begin{pmatrix}\cos\omega_ik&\sin\omega_ik\\-\sin\omega_ik&\cos\omega_ik\end{pmatrix}}_{R_k^{(i)}}\begin{pmatrix}p_{t,2i}\\p_{t,2i+1}\end{pmatrix}

把所有 d/2d/2 对拼起来,Rk=blockdiag(Rk(0),…,Rk(d/2−1))R_k=\mathrm{blockdiag}(R_k^{(0)},\dots,R_k^{(d/2-1)}):

 pt+k=Rk pt,Rk 只依赖 k,与 t 无关 \boxed{\ p_{t+k}=R_k\,p_t,\qquad R_k\text{ 只依赖 }k\text{,与 }t\text{ 无关}\ }

向后挪 kk 个位置 = 乘一个固定的旋转矩阵。 w1day2_checks.py 第 5 组:k=1,7,100k=1,7,100 时,同一个 RkR_k 对不同的 tt 都成立,误差 10−1410^{-14} 量级。

意义。 “三个词之前”这个相对关系对应一个线性变换,模型原则上可以用线性层学会它。Vaswani 等人的原文是:“We chose this function because we hypothesized it would allow the model to easily learn to attend by relative positions.” 注意是 “hypothesized”:他们并没有证明模型真的这么用。

3.5 点积只依赖相对距离

由 §3.4 马上得到:

pt⊤ps=∑i[sin⁡ωitsin⁡ωis+cos⁡ωitcos⁡ωis]=∑icos⁡(ωi(t−s))p_t^\top p_s=\sum_{i}\big[\sin\omega_it\sin\omega_is+\cos\omega_it\cos\omega_is\big]=\sum_i\cos\big(\omega_i(t-s)\big)

只依赖 t−st-s。 第 6 组(d=128d=128):

距离01416642561024
pt⊤pt+kp_t^\top p_{t+k}64.062.148.639.730.520.513.1

整体随距离减小,但不单调:第一次回升出现在距离 12,0–1024 之间有 492 步是上升的,远处还会变成负数(4096 以内最小 −8.36)。所以它是”相对位置的一个可用信号”,不是一把干净的距离尺子。

3.6 可惜:投影之后,相对性就没了

§3.4–3.5 的漂亮性质是关于 ptp_t 本身的。但进入注意力的是 qt=WQ⊤(xt+pt)q_t=W_Q^\top(x_t+p_t) 和 ks=WK⊤(xs+ps)k_s=W_K^\top(x_s+p_s),打分展开成四项:

qt⊤ks=xt⊤Mxs+xt⊤Mps+pt⊤Mxs+pt⊤Mps,M=WQWK⊤q_t^\top k_s=x_t^\top Mx_s+x_t^\top Mp_s+p_t^\top Mx_s+p_t^\top Mp_s,\qquad M=W_QW_K^\top

即使只看最后一项,pt⊤Mpsp_t^\top Mp_s 也只有在 MM 与所有 RkR_k 可交换时才只依赖 t−st-s,随便一个学到的 MM 不满足。第 7 组:三对都相距 3 的位置,M=IM=I 时打分完全相同(25.587),MM 随机时变成 4.935、0.302、2.819。加法式的位置编码经过投影后,相对性被破坏了;中间两项还把内容和位置搅在一起。

这正是 RoPE(Day 5)的出发点:既然”旋转”是我们想要的性质,为什么不在投影之后、直接对 qq 和 kk 做旋转?那样 qt⊤ksq_t^\top k_s 就严格只依赖 t−st-s。

3.7 可学习的绝对位置编码

BERT、GPT-2 用一张 nn.Embedding(max_len, d) 直接学每个位置的向量。收益:简单,让数据决定位置表示,效果不比正弦差。代价:硬上限,BERT 是 512、GPT-2 是 1024。超过就没有向量可查(IndexError),就算扩大表格,新位置的向量也没训练过。这是”长度外推”问题的最原始形态(Day 5 §3)。

3.8 两个工程细节

为什么是”加”而不是”拼接”? 拼接 [x;p][x;p] 再乘 W=[W1 W2]W=[W_1\ W_2] 得到 W1x+W2pW_1x+W_2p;相加再乘得到 Wx+WpWx+Wp,相当于强制 W1=W2W_1=W_2。加法省了维度,代价是内容和位置共享同一组投影。高维空间里两个随机向量近似正交,模型可以学着把位置信息放在与词义大致正交的子空间里,所以实践中加法够用。

原始论文把词嵌入乘了 d\sqrt{d}(“we multiply those weights by dmodel\sqrt{d_{\text{model}}}”)。原因是它的嵌入与输出层权重绑定,初始化尺度小,而正弦编码的每个分量在 [−1,1][-1,1],不放大的话位置信号会压过词义。


§4 编码器、解码器与三种架构

4.1 原始 Transformer

   源句 ──► Embedding + PE ──► [ 编码器块 ×6 ] ──► 记忆 (T_src × d) ──┐
                                 双向自注意力                        │
                                 + FFN                              │ K, V
                                                                    ▼
 目标句(右移一位)──► Embedding + PE ──► [ 解码器块 ×6 ]  ──► Linear ──► softmax
                                        因果自注意力
                                        + 交叉注意力 ◄── Q 来自解码器
                                        + FFN

base 配置:6 + 6 层,d=512d=512,h=8h=8,dff=2048d_{ff}=2048,dropout 0.1,label smoothing 0.1,共约 65M 参数;Adam(β2=0.98\beta_2=0.98),学习率 d−0.5⋅min⁡(step−0.5, step⋅4000−1.5)d^{-0.5}\cdot\min(\text{step}^{-0.5},\ \text{step}\cdot4000^{-1.5}),即 4000 步线性 warmup 后按 1/step1/\sqrt{\text{step}} 衰减。

编码器块 = 双向自注意力(只有 padding 掩码)+ FFN。解码器块多一层交叉注意力:QQ 来自解码器的当前表示,K,VK,V 来自编码器的最终输出。

4.2 交叉注意力就是 Bahdanau 注意力

回看 Week 0 Day 4 §5.2:解码器状态当查询,编码器的所有隐状态当键和值。交叉注意力就是它,只是换成了缩放点积、多头、K/V 各自投影。 所以 Transformer 里其实有两种注意力:自注意力(跳跃一)和交叉注意力(2014 年的原版)。

掩码上:交叉注意力只需要源句的 padding 掩码,不需要因果掩码(源句完整已知)。

推理流程:编码器跑一次得到记忆;解码器自回归地一个 token 一个 token 生成,每一步都去查同一份记忆。这份记忆的 K、V 可以一次算好缓存起来,Day 4 的 KV Cache 对交叉注意力来说是免费的。

4.3 三种架构

架构注意力训练目标代表擅长
Encoder-only双向掩码语言模型(完形填空)BERT、RoBERTa理解:分类、抽取、句向量(第 4 周 RAG 的检索器)
Decoder-only因果下一个 tokenGPT 系列、Llama、Qwen生成;通过提示做一切任务
Encoder-decoder编码器双向 + 解码器因果 + 交叉序列到序列原始 Transformer、T5、BART输入输出结构清晰的任务:翻译、摘要

BERT 为什么不能直接生成? 它的每个位置都能看到两边,训练时从没学过”只根据左边预测右边”。Day 6 §4 展开。

为什么今天的 LLM 几乎全是 decoder-only? 这是 Lecture 3 的第一个问题,先记住几条经常被引用的理由:(1) 目标统一,每个 token 都是一个监督信号(Day 1 §3.1),BERT 只在被遮住的 15% 上有损失;(2) 结构简单,一种块、一种掩码,工程上好扩展;(3) 生成即接口,任何任务都能写成”给定前文续写”,零样本和少样本能力随规模涌现;(4) 推理友好,KV Cache 只有一份。这些理由没有一条是决定性的证明,encoder-decoder 在一些任务上仍有优势,但行业已经收敛。


§5 参数与 FLOPs:整块的账

5.1 参数量:数到每一个

一层(带 bias 和 LN 的 GPT-2 式块,dff=4dd_{ff}=4d):

组件参数
Q/K/V 投影3d2+3d3d^2+3d
输出投影 WOW_Od2+dd^2+d
FFN 第一层4d2+4d4d^2+4d
FFN 第二层4d2+d4d^2+d
两个 LayerNorm4d4d
一层合计12d2+13d12d^2+13d

整个模型 =Vd=Vd(词嵌入,与输出头绑定)+ Pd+\,Pd(位置嵌入)+ L(12d2+13d)+2d+\,L(12d^2+13d)+2d(末端 LN)。

w1day2_checks.py 第 8 组,三个模型都精确到个位:

模型配置算出来官方
GPT-2 smallV=50257,P=1024,d=768,L=12V=50257,P=1024,d=768,L=12124,439,808124,439,808 ✓
BERT-baseV=30522,P=512,d=768,L=12V=30522,P=512,d=768,L=12,+ 段嵌入、pooler109,482,240109,482,240 ✓
Llama 2 7BV=32000,d=4096,L=32,dff=11008V=32000,d=4096,L=32,d_{ff}=11008,SwiGLU,无 bias,不绑定6,738,415,6166.74B ✓

要记住的近似:非嵌入参数 ≈12Ld2\approx12Ld^2。GPT-2 small 是 84.9M,嵌入另有 39.4M,小模型里词表占了近三分之一(§6.4)。

5.2 FLOPs:为什么训练是 6N6N

一个线性层,y=Wxy=Wx,W∈Rn×mW\in\mathbb R^{n\times m},一个 token:前向 2mn2mn(每个参数一次乘一次加)。反向要算两样东西:

xˉ=W⊤yˉ  (2mn),Wˉ=yˉ x⊤  (2mn)\bar x=W^\top\bar y\ \ (2mn),\qquad\bar W=\bar y\,x^\top\ \ (2mn)

所以反向 = 2 × 前向(第 9 组:4,718,592 对 9,437,184)。每个参数:前向 2 次运算,反向 4 次,训练 6 次。一个 NN 参数的模型在 DD 个 token 上训练:

 C≈6ND \boxed{\ C\approx6ND\ }

这就是 Kaplan / Chinchilla 缩放律的计算量口径(Lecture 4)。推理只有前向:2N2N 每 token。

注意力的 TT 相关项不在 NN 里。每 token 每层:QK⊤QK^\top 和 AVAV 各 2Td2Td,共 4Td4Td(不计因果掩码省下的一半)。它追上每层线性部分(24d224d^2,其中注意力投影 8d28d^2、FFN 16d216d^2)要到 T=6dT=6d:d=768d=768 时 4608,d=4096d=4096 时 24576。第 9 组给出 GPT-2 small 的实例:T=1024T=1024 时注意力项占每 token 前向的 10%,T=4096T=4096 时 31%(这里用 Kaplan 的口径 2LTd2LTd)。

结论:对大模型、常见上下文长度,6ND6ND 是很好的近似;Day 1 §5.1 说的”短序列上瓶颈不在 T2T^2“,放到整块(加上 FFN)更是如此。


§6 分词:Lecture 1 的另一半

Week 0 Day 3 §7 讲了 BPE 的原理(分词 = 压缩)。Lecture 1 开头还会提到 WordPiece、Unigram 和 SentencePiece,这里补齐。

6.1 BPE(复习)

从字符开始,反复合并出现频次最高的相邻对。GPT 系列用它的字节级版本:基础词表是 256 个字节,任何输入都能编码,永远没有 [UNK]。GPT-2 词表 50257 = 256 字节 + 50000 次合并 + 1 个 <|endoftext|>。

6.2 WordPiece:合并”意外地常在一起”的对

BERT 用 WordPiece。合并规则换了:

score(a,b)=count(ab)count(a)⋅count(b)\text{score}(a,b)=\frac{\text{count}(ab)}{\text{count}(a)\cdot\text{count}(b)}

直觉:BPE 问”哪对最常见”;WordPiece 问”哪对在一起的次数,远超它们各自的频率所预期的”。这就是 PMI(Week 0 Day 3 §2.2)去掉 log 和常数的版本,本质上是在最大化训练语料在 unigram 模型下的似然增益。

w1day2_checks.py 第 11 组(语料 hug×10、pug×5、pun×12、bun×4、hugs×5):BPE 先合并 u+g(频次 20);WordPiece 先合并 g+s(得分 0.050,而 u+g 只有 0.028)。u 出现 36 次,含 u 的对不稀奇;s 只出现 5 次,但每次都跟在 g 后面。

WordPiece 用 ## 标记词中片段(playing → play, ##ing),编码时从左到右贪心取最长匹配,与 BPE 按合并顺序重放不同。

6.3 Unigram:从大到小剪枝

Unigram(Kudo 2018)方向相反:从一个很大的候选词表出发,给每个子词一个概率,一个词的切分概率是各片段概率之积。训练时反复 (1) 用 EM 估计子词概率,(2) 删掉”删了之后语料似然损失最小”的那批子词,直到词表缩到目标大小。

同一个词有多种切法,推理时用 Viterbi 找概率最大的切分。第 12 组:hugs 的 7 种切法里,hugs 整体(log⁡P=−3.51\log P=-3.51)优于 hug + s(−4.71-4.71),Viterbi 与穷举一致。

独有的好处:subword regularization。 训练时按概率采样不同的切分,让模型见到同一个词的多种拆法,相当于数据增强,对低资源翻译有帮助。

6.4 SentencePiece 与词表大小

SentencePiece(Kudo & Richardson 2018)不是一种新算法,而是一个框架:它把输入当作原始字符流,连空格也当普通字符(记作 ▁),不需要按空格预分词。所以对中文、日文这类没有空格的语言天然友好,而且空白信息不会丢,decode 能还原空格和换行(对照 Week 0 Day 3 §A.9:按空白预分词的实现做不到这一点)。严格说它默认还会做 NFKC 规范化,想要逐字节还原要设 normalization_rule_name="identity"。它实现了 BPE 和 Unigram 两种算法。T5、ALBERT 用 SentencePiece-Unigram;Llama 1/2 用 SentencePiece-BPE 并开启字节回退(未知字符拆成字节)。

词表大小的取舍:词表越大,同样文本的 token 越少(序列短、注意力便宜、上下文装得多),但嵌入和输出层越大。第 10 组:

模型VV嵌入相关参数占总参数
GPT-2 small50,25738.6M(绑定)31.0%
Llama 2 7B32,000262.1M(不绑定)3.9%
Llama 3 8B128,2561050.7M(不绑定)13.1%

小模型里词表是参数大户;大模型里占比下降,于是有余地用 128k 甚至更大的词表,换更短的序列和更好的多语言覆盖(Week 0 Day 3 §7.4 的”多语言不公平”)。

数字怎么切也是个设计选择:Llama 1/2 把所有数字拆成单个数字;OpenAI 的 cl100k 词表在预分词时把数字按最多 3 位一组切开。这会影响模型做算术的难度。


§A 架构与分词:API 用法与边界

A.1 nn.TransformerEncoderLayer / nn.TransformerDecoderLayer

layer = nn.TransformerEncoderLayer(
    d_model=512, nhead=8,
    dim_feedforward=2048,      # 默认 2048,不是 4·d_model
    dropout=0.1,               # 默认 0.1
    activation="relu",         # 默认 relu;可传 "gelu" 或可调用对象
    batch_first=False,         # ★ 默认 False
    norm_first=False,          # ★ 默认 False = Post-LN(2017 原版)
)
enc = nn.TransformerEncoder(layer, num_layers=6, norm=nn.LayerNorm(512))   # Pre-LN 时要自己传末端 norm
  1. norm_first=False 是 Post-LN。 想要现代的 Pre-LN 必须显式写 norm_first=True,并且给 TransformerEncoder 传 norm= 作为末端归一化,否则最后一层输出没有归一化。
  2. batch_first=False,和 nn.MultiheadAttention 一样的坑(Day 1 §A.2)。
  3. dim_feedforward 默认 2048 与 d_model 无关。d_model=768 时不改它,FFN 就只有 2.67 倍而不是 4 倍。
  4. nn.TransformerEncoder 在 norm_first=True 时会警告 nested tensor 快速路径被禁用,可以忽略或传 enable_nested_tensor=False。
  5. 它不含嵌入、位置编码和输出头,只是块的堆叠。

本周的建议:手写(Day 3)。这些类适合快速搭 baseline,但默认值是 2017 年的配方。

A.2 GELU 的两个版本

F.gelu(x)                          # 精确版:x·Φ(x)
F.gelu(x, approximate="tanh")      # tanh 近似:GPT-2、BERT 原实现用的
nn.GELU(approximate="tanh")

最大差 4.7×10−44.7\times10^{-4}(第 3 组)。从头训练时无所谓;加载别人训好的权重做推理时要用对版本,否则 logits 有小偏差,生成结果可能不同,对拍时会困惑很久。HuggingFace 的 GPT-2 配置里 activation_function="gelu_new" 就是 tanh 版。

A.3 位置编码的实现

class SinusoidalPE(nn.Module):
    def __init__(self, d, max_len=4096, base=10000.0):
        super().__init__()
        pos = torch.arange(max_len).unsqueeze(1)
        w = torch.exp(-math.log(base) * torch.arange(0, d, 2) / d)   # 1/base^(2i/d)
        pe = torch.zeros(max_len, d)
        pe[:, 0::2], pe[:, 1::2] = torch.sin(pos * w), torch.cos(pos * w)
        self.register_buffer("pe", pe, persistent=False)             # 可重建,不进 state_dict
    def forward(self, x):                                            # x: (B, T, d)
        return x + self.pe[: x.size(1)]
  • 用 exp(-log(base)·2i/d) 而不是 base ** (2i/d) 再取倒数,数值上更稳,写法也更常见。
  • 可学习位置编码:nn.Embedding(max_len, d),前向传 torch.arange(T, device=x.device)。忘了指定 device 是高频 bug:索引在 CPU、嵌入表在 GPU,直接报错。T>T> max_len 时报 IndexError(CUDA 上是难读的 device-side assert,Week 0 Day 4 §A.5)。
  • dd 必须是偶数。

A.4 用 HuggingFace tokenizers 训练三种分词器

from tokenizers import Tokenizer, models, pre_tokenizers, trainers
tok = Tokenizer(models.BPE(unk_token="[UNK]"))             # 或 models.WordPiece(unk_token=...)、models.Unigram()
tok.pre_tokenizer = pre_tokenizers.Whitespace()
trainer = trainers.BpeTrainer(vocab_size=8000, special_tokens=["[UNK]", "[PAD]"])
# WordPieceTrainer / UnigramTrainer 参数类似;UnigramTrainer 要额外给 unk_token="[UNK]"
tok.train(["input.txt"], trainer)
print(tok.encode("unbelievable").tokens)
  • special_tokens 要在训练时给,它们会占用最前面的 id。训练完再加会导致 id 布局与预期不同。
  • Unigram 模型的 unk_token 要在 trainer 里指定,否则遇到未知字符会报错。
  • 字节级 BPE 用 pre_tokenizers.ByteLevel() 配 decoders.ByteLevel(),不需要 [UNK]。

A.5 sentencepiece

import sentencepiece as spm
spm.SentencePieceTrainer.train(input="input.txt", model_prefix="sp", vocab_size=8000,
                               model_type="unigram",          # 默认 unigram;也可 "bpe"
                               character_coverage=0.9995,     # 中日文等大字符集用 0.9995,纯英文可用 1.0
                               byte_fallback=True)            # 未覆盖的字符拆成字节,不产生 <unk>
sp = spm.SentencePieceProcessor(model_file="sp.model")
sp.encode("Hello world", out_type=str)                        # ['▁Hello', '▁world']
  • character_coverage 小于 1 时,最罕见的字符不进词表。不开 byte_fallback 的话它们会变成 <unk>,而且不报错。
  • vocab_size 太大而语料太小时训练会失败并提示最大可行值。

§B 实操

  1. 跑通 w1day2_checks.py,12 组结论都看懂。第 5、7 组放在一起看:正弦编码本身有完美的相对性,投影之后就没了,这是 Day 5 学 RoPE 的全部动机。
  2. 推导(1h,纸笔):(a) 用和角公式推出 pt+k=Rkptp_{t+k}=R_kp_t;(b) 推出 pt⊤ps=∑icos⁡(ωi(t−s))p_t^\top p_s=\sum_i\cos(\omega_i(t-s));(c) 按 §5.1 的表逐项数出 GPT-2 small 的 124,439,808。
  3. 手写一个 Pre-LN decoder 块(1.5h):MHA 用 Day 1 你自己写的;FFN 支持 relu/gelu/swiglu;带残差和 dropout;输出投影按 1/2L1/\sqrt{2L} 初始化。与 nn.TransformerEncoderLayer(norm_first=True, batch_first=True, activation="gelu") 对拍(复制权重后输出应一致,传因果掩码)。
  4. 位置编码实验(1h):实现正弦编码,画出 pe 的热力图(位置 × 维度),再画 p0⊤pkp_0^\top p_k 随 kk 的曲线,对照第 6 组。
  5. 训练三种分词器(1h):在 Tiny Shakespeare 上用 tokenizers 分别训 BPE、WordPiece、Unigram(同样的 vocab_size),比较同一段文本的 token 数、以及几个词的切法。

参考实现:code/w1d2_block.py(第 3、4 题,含参数量核对)、code/w1d2_tokenizers.py(第 5 题)。


§C 自测

动机与直觉层

  1. Transformer 块里的四个”补丁”各自对应自注意力的哪个缺口?
  2. 为什么说”注意力负责通信、FFN 负责计算”?FFN 为什么能被看成键-值记忆?和注意力对比,键、值、权重各有什么不同?
  3. 为什么需要位置编码?列出四个朴素方案各自的问题,以及理想方案应满足的四条性质。
  4. 正弦位置编码的”二进制计数器”直觉是什么?频率为什么按几何级数排列?
  5. 交叉注意力和 Bahdanau 注意力是什么关系?它需要什么掩码?
  6. 三种架构各适合什么任务?BERT 为什么不能直接生成?

推导层

  1. 推出 pt+k=Rkptp_{t+k}=R_kp_t,写出 RkR_k。为什么说这是 RoPE 的种子?
  2. 推出 pt⊤psp_t^\top p_s 只依赖 t−st-s。它随距离单调衰减吗?
  3. 为什么加法式位置编码经过 WQ,WKW_Q,W_K 之后不再只依赖相对位置?写出打分展开的四项。
  4. SwiGLU 为什么 dffd_{ff} 取 8d/38d/3?Llama 7B 的 11008 怎么来的?
  5. 逐项数出 GPT-2 small 的参数量。非嵌入参数的近似公式是什么?
  6. 为什么反向传播的计算量是前向的 2 倍?由此推出 C≈6NDC\approx6ND。
  7. 每 token 的 TT 相关注意力计算量追上线性部分要多长的序列?
  8. WordPiece 的合并得分是什么?它和 PMI 有什么关系?举一个 BPE 和 WordPiece 选择不同的例子。

工程与 API 层

  1. nn.TransformerEncoderLayer 默认是 Pre-LN 还是 Post-LN?dim_feedforward 默认是多少?
  2. 加载 GPT-2 权重时 GELU 要用哪个版本?用错了会怎样?
  3. 可学习位置编码最常见的两个 bug 是什么?
  4. SentencePiece 和 BPE 是什么关系?byte_fallback 解决什么问题?
  5. 为什么大模型敢用 128k 的词表而小模型不太敢?

出关标准

  • 能画出 Pre-LN decoder 块和原始 encoder-decoder 架构,说出每个零件的动机
  • 能讲清 FFN 的键-值记忆视角,知道 FFN 占一层参数的 2/3
  • 能说出 ReLU → GELU → SwiGLU 的演化和 2/3 规则
  • 能用实测数字说明 1/2L1/\sqrt{2L} 缩放的作用
  • 推出 pt+k=Rkptp_{t+k}=R_kp_t,并说明它为什么经过投影后失效
  • 能讲清三种架构的区别和 decoder-only 胜出的常见理由
  • 精确算出 GPT-2 small 的参数量,推出 6ND6ND
  • 能讲清 BPE / WordPiece / Unigram / SentencePiece 的区别
  • 手写 Pre-LN 块,与 nn.TransformerEncoderLayer 对拍通过

疑问清单

← 返回课程目录