thou 后面那个 r 是怎么消失的:一个注意力头长成 GPT
第二十四篇结尾说:把这个头装进模型、加上缩放,loss 终于要从 2.49 往下走了。
这一篇就走这一段路,一共四步:装进一个头 → 多头 + FFN → 堆深(残差 + LayerNorm)→ 放大 + Dropout。 每一步都拿同一个问题去问模型,看它的回答怎么变。
同一个问题:thou 后面接什么
第二十四篇开头那个实验:bigram 对 hou、you、thou 给出完全相同的预测,因为它只看最后一个 u。
先把标准答案数出来 —— 在莎士比亚全集里数每个上下文后面实际跟的字符:
text[i + len(ctx)] for 所有 text.startswith(ctx, i) 的位置 i| 上下文 | 出现次数 | 原文里实际跟的(前几名) |
|---|---|---|
| thou | 1616 | 空格 66.8%、g 16.7%(though / thought)、s 5.1%(thousand)、逗号 4.3%、t 4.1%(without) |
| hou | 2594 | 空格 49.5%、l 15.3%(should / would)、g 12.6%、s 9.5%、r 4.5% |
| you | 4649 | 空格 44.0%、r 35.1%(your)、逗号 7.6%、句号 3.2%、n 2.5% |
🔑 thou 后面接 r,在全集里一次都没有出现过(
text.count('thour') == 0)。 这一条下面会反复用到:模型把 r 排得越高,说明它越是只看了最后两个字母。
第一步:装进一个头
第二十四篇在 notebook 里演示的那个头,包成一个类:
class Head(nn.Module): def __init__(self, head_size): super().__init__() self.key = nn.Linear(n_embd, head_size, bias=False) self.query = nn.Linear(n_embd, head_size, bias=False) self.value = nn.Linear(n_embd, head_size, bias=False) self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
def forward(self, x): B, T, C = x.shape k, q = self.key(x), self.query(x) # (B,T,hs) wei = q @ k.transpose(-2, -1) * k.shape[-1] ** -0.5 # (B,T,T) wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf')) wei = F.softmax(wei, dim=-1) return wei @ self.value(x) # (B,T,hs)跟 notebook 几乎一样,只有三处新东西。
register_buffer:不训练,但跟着模型走
tril 是一张 8×8 的下三角「遮挡模板」,在 __init__ 里建一次存起来。为什么不直接写 self.tril = ...:
model.to(device) 只搬它「认识」的东西。在 Mac 上验证一下:
d = Demo().to('mps')d.lin.weight.device # mps:0 ← 参数搬过去了d.tril_buf.device # mps:0 ← buffer 也搬过去了d.tril_plain.device # cpu ← 普通属性还留在 CPUd.state_dict().keys() # tril_buf、lin.weight、lin.bias —— tril_plain 哪里都没有普通属性留在 CPU,而 wei 在 GPU 上,masked_fill 一执行就报设备不一致。
| 参数 / 子模块 | register_buffer | 普通属性 | |
|---|---|---|---|
| 会被训练吗 | ✅ | ❌ | ❌ |
| 算进参数量吗 | ✅ | ❌ | ❌ |
跟着 .to(device) 搬吗 | ✅ | ✅ | ❌ |
| 存进模型文件吗 | ✅ | ✅ | ❌ |
| 例子 | W_q W_k W_v、嵌入表 | tril、BN 的 running_mean | —— |
tril 恰好落在中间那一列:规则是固定的、不该学,但它属于模型。
第十六篇手写的 running_mean / running_var,在 PyTorch 官方的 BatchNorm 里也是 buffer。
tril[:T, :T]:输入不一定满 8 个字符
模板按最大长度建,但生成的第 1 步 T = 1、第 3 步 T = 3。切出左上角 T×T,才能跟 wei 的形状对上。
缩放用 k.shape[-1],不用 C
注解 ⑥ 推过:该除以的是点积的维度,也就是 head_size。Karpathy 视频里写的是 C ** -0.5(n_embd),
是个小笔误,仓库里后来改成了 k.shape[-1]。单头时两者碰巧相等(都是 32),多头时就不等了。
超参要改两个
max_iters 3000 → 5000(模型更复杂);learning_rate 1e-2 → 1e-3。注意力里有 softmax,
第二十四篇注解 ⑥ 说过分数一大权重就剧烈偏向一边 —— bigram 只是一张表,才经得起 1e-2。
参数量:token 表 65×32 = 2080 + 位置表 8×32 = 256 + 注意力 3×32×32 = 3072 + lm_head 32×65 + 65 = 2145
= 7553。Karpathy 的 val 从 2.49 降到约 2.40 —— 注意力第一次带来实际收益。
第二步:多头,参数一个没多
现状:一行权重只能表达一种看法
一个头,每个位置只有一行注意力权重,加起来等于 1。预测 hou 的下一个字符,u 同时需要两种信息:
「开头是 h」和「自己是 u」。用第二十四篇那套 v(v_h = [10, 0]、v_o = [0, 10]、v_u = [5, 5])看:
全看 h: [0.80, 0.02, 0.18] → out_u = [8.87, 1.13] 拿到了开头,自己的信息很少全看自己: [0.05, 0.10, 0.85] → out_u = [4.75, 5.25] 拿到了自己,开头的信息很少各看一半: [0.50, 0.00, 0.50] → out_u = [7.50, 2.50] ← 两种信息加在同一组数字上,混成一团方案:再造几套 W_q W_k W_v,结果并排拼起来
头 A 就是第二十四篇那套(u 行看开头)。设头 B 学成了「看自己」:u 行权重 [0.05, 0.10, 0.85]
(h、o 两行跟头 A 一样),它自己的 v 是 h = [2, 0]、o = [0, 2]、u = [0, 6]。两个头各算各的,再沿最后一维拼接:
头 A(看开头) 头 B(看自己)h [ 10.00, 0.00 | 2.00, 0.00 ]o [ 2.69, 7.31 | 0.54, 1.46 ]u [ 8.87, 1.13 | 0.10, 5.30 ]u 那一行同时装着两种信息,而且分在不同的位置上,互不干扰。
★ 推导:为什么是拼接,不是相加
头的输出后面总要接一个 Linear(下面的 FFN,或者第 8 段加的 proj)。把 Linear 的权重按列切成两块
W = [W_A W_B],作用在拼接结果上:
W · [a ; b] = W_A · a + W_B · b拼接之后接 Linear,等于每个头各配一块自己的权重,再加起来。 如果改成先相加:
W · (a + b) = W · a + W · b两个头被迫共用同一块 W —— 下一层分不出哪部分来自「看开头」、哪部分来自「看自己」,只能一视同仁。
拼接把「怎么组合各个头」这件事留给了后面的层去学。
代码:ModuleList 和 cat(dim=-1)
class MultiHeadAttention(nn.Module): def __init__(self, num_heads, head_size): super().__init__() self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])
def forward(self, x): return torch.cat([h(x) for h in self.heads], dim=-1)
self.sa_heads = MultiHeadAttention(4, n_embd // 4) # 4 个头 × 8 维,拼回 32 维- 列表推导式每次都重新执行
Head.__init__→ 四个头各有独立参数、各自随机初始化、各自训练 - 必须用
nn.ModuleList:普通 Python 列表里的模块,nn.Module看不见 → 参数不进model.parameters()→ 优化器不更新、也不跟着.to(device)走。跟register_buffer同一个道理:PyTorch 只管它登记过的东西 cat(dim=-1)只沿最后一维拼:对每一个「序列b、位置t」,把几个头在这个位置的向量首尾相接,out[b, t, 0:8]是头 0、out[b, t, 8:16]是头 1…… 沿dim=1拼会得到(B, 2T, …),头 1 被当成了多出来的位置; 沿dim=0拼会被当成另外几条序列
参数一个没多
| 每个 W | W 的个数 | 参数 | |
|---|---|---|---|
| 单头 1 × 32 | 32×32 | 3 | 3072 |
| 多头 4 × 8 | 32×8 | 12 | 3072 |
7553 → 7553,Karpathy 的 val 从约 2.40 降到约 2.28。提升纯粹来自分工。
头数和每头大小是怎么定的
约定 n_head × head_size = n_embd,通常只选头数,n_embd 必须能整除(nanoGPT 里有
assert n_embd % n_head == 0)。头太少,关注方式单一;头太多,每个头的 q·k 维度太小。
| 模型 | n_embd | 头数 | 每头维度 |
|---|---|---|---|
| 本篇第 7 段 | 32 | 4 | 8 |
| 本篇第 8 段 | 384 | 6 | 64 |
| GPT-2 small | 768 | 12 | 64 |
| GPT-3 175B | 12288 | 96 | 128 |
| Llama 3 8B | 4096 | 32 | 128 |
| Qwen2.5-7B | 3584 | 28 | 128 |
🔑 业内实际是反过来定的:先定每头 64 或 128,再按
n_embd决定开几个头。模型越大头越多,每个头的大小基本不变。
这个等式也不是铁律:第 8 段会在拼接后加一个输出投影 proj,有了它拼接后是多少维都能投回 n_embd
(Gemma 7B:n_embd 3072,16 头 × 256 = 4096,再投影回 3072)。
多头出自 2017 年 Google 的《Attention Is All You Need》(Vaswani 等 8 位作者),论文脚注写明多头注意力、 缩放点积(那个 √dₖ)和不带参数的位置编码是 Noam Shazeer 提出的。论文的理由:单头会把不同子空间的信息平均掉; 消融实验里单头比最好的配置差 0.9 BLEU,头太多也会变差 —— 是试出来的取舍。
第三步:FFN,唯一的非线性
现状:只有混合,没有非线性
第十九篇把网络里的层分成三类:混合、非线性、归一化,前两类绝不能省。第二步做完的模型里:
注意力负责位置之间的混合,lm_head 负责维度之间的混合 —— 非线性一个都没有。
注意力的权重一旦算定,输出就是 v 的加权和,特征维度上没有任何逐元素的弯折。
而纯线性的层叠多少都等于一层:
W₂(W₁x + b₁) + b₂ = (W₂W₁)·x + (W₂b₁ + b₂) = W′x + b′要推出「开头是 h 并且 中间是 ou → 很可能是 house」这种组合判断,线性组合做不到。
方案:FeedForward,就是第五篇那种 MLP
class FeedForward(nn.Module): def __init__(self, n_embd): super().__init__() self.net = nn.Sequential(nn.Linear(n_embd, n_embd), nn.ReLU())
def forward(self, x): return self.net(x)FFN = Feed-Forward Network,「前馈」= 信息只往前流、不回头(相对循环网络 RNN 而言)。
跟第十篇的隐藏层 h = torch.tanh(emb.view(-1, 6) @ W1 + b1) 是同一种结构,激活从 tanh 换成 ReLU = max(0, x)。
(严格说现在只有一层,第 8 段会补成标准的两层形式。)
Linear 负责组合,ReLU 负责设门槛
FFN 的输入就是四个头拼起来的结果。拿上面两个头的拼接结果试一个探测器:
权重 [0.1, 0, 0, 0.1]、偏置 −1,想检测「头 A 第 0 维强 并且 头 B 第 1 维强」:
Linear 后 ReLU 后h 10.00×0.1 + 0.00×0.1 − 1 = 0.00 0.00 ← 头 A 强、头 B 是 0,只满足一半o 2.69×0.1 + 1.46×0.1 − 1 = −0.59 0.00 ← 两个都不够强u 8.87×0.1 + 5.30×0.1 − 1 = 0.42 0.42 ← 两个都强,响了只在 u 这一行响 = 「开头是 h」并且「当前是 u」。 只有 Linear 没有 ReLU:h 行会得到一个中间值,
表达不了「两个都满足才算」;只有 ReLU 没有 Linear:各头还是各管各的。先组合,再判断是否达标。
只有一个 MLP,在每个位置上各用一次
「每个位置过一个小 MLP」的准确说法是:同一个 MLP(一套权重)在每个位置上各用一次,不是每个位置有自己的一份。
nn.Linear 只作用在最后一维,所以位置 3 的输出只取决于位置 3 自己:ffwd(x)[:, 3] == ffwd(x[:, 3])。
| 多头注意力 | FFN | |
|---|---|---|
| 创建了几个 | 4 个 Head(4 套权重) | 1 个 FeedForward(1 套) |
| 为什么 | 要学 4 种不同的关注方式 | 每个位置的加工规则都一样 |
| 位置之间交流吗 | ✅ 这是它唯一的工作 | ❌ 各算各的 |
| 做什么 | 通信:把别人的信息拿过来 | 计算:把拿到的信息想清楚 |
🔑 Karpathy:注意力是「通信」,FFN 是「计算」。 Transformer block 就是这两件事交替进行。 标准 FFN 先放大 4 倍再缩回,两个 Linear 共
2 × d × 4d = 8d²个参数;注意力的 q、k、v、proj 共4d²—— 真实模型里 FFN 占了约三分之二的参数,后面实验 D 的参数分布会印证这一点。
参数量 7553 + 32×32 + 32 = 8609。
实测:2.2495,但 thou 后面排第一的是 r
step 3000: train loss 2.2826, val loss 2.2922step 4000: train loss 2.2432, val loss 2.2456step 4999: train loss 2.2145, val loss 2.2495val 2.2495,比 bigram 的 2.49 降了 0.24,跟 Karpathy 的约 2.24 对得上。 train 和 val 第一次拉开了一点(差 0.035)—— 模型开始有一点「背答案」的迹象。
再问那个问题:
| 上下文 | bigram | 第 7 段(多头 + FFN) | 原文实际(前几名) |
|---|---|---|---|
| thou | 三行完全相同 | r、空格、t | 空格 66.8%、g 16.7%、s 5.1% |
| hou | 同上 | r、s、空格 | 空格 49.5%、l 15.3%、g 12.6% |
| you | 同上 | 空格、r、n ✅ | 空格 44.0%、r 35.1%、逗号 7.6% |
三行终于不一样了 —— 最后一个位置确实看到了前面的字符。但 thou 的第一名是原文里一次都没出现过的 r。
原因在「ou」上:全集里所有 ou 后面,r 占 27.1%(our、your、four、hour、honour)。
模型还在靠「最后两个字母是 ou」这条局部规律,没能充分用上前面的 th。 看到了,不等于用好了。
生成的文本里开始出现真词 —— thy、to、your、for、of、though、four、now、our: bigram 拼到第三个字母就忘了前两个,现在每个位置能往回看 8 个字符,短词拼得出来了。
第四步:堆 3 层,不升反降
「通信 + 计算」只做了一轮。想让模型多想几轮,就把它打包成 Block、堆起来:
class Block(nn.Module): def __init__(self, n_embd, n_head): super().__init__() self.sa = MultiHeadAttention(n_head, n_embd // n_head) self.ffwd = FeedForward(n_embd)
def forward(self, x): x = self.sa(x) # 通信 x = self.ffwd(x) # 计算 return x
self.blocks = nn.Sequential(*[Block(n_embd, n_head=4) for _ in range(3)])* 把列表拆开:nn.Sequential(*[b1, b2, b3]) 等价于 nn.Sequential(b1, b2, b3)。
Karpathy 的结果:val 没有变好,甚至更差。 参数多了三倍,效果却没提升。原因有两个:
- 反向:梯度要依次穿过每一层,逐层相乘。每层局部导数 0.3,三层后
0.3³ = 0.027,第一层只收到 2.7% - 前向:每层的输出完全替换输入,原始信息必须在每一层被「重新表达」一遍 —— 像传话游戏,传到后面早就不是原话
第十二篇说过「深度的性价比更高,但需要一整套技巧才能兑现」,第十七篇的诊断手册里「梯度消失」那一行的对策 也写着「加残差连接」。现在网络真的深了,该兑现了。
残差:分支只「加」一点
改法只有两个字符:
x = x + self.sa(x) # 原来是 x = self.sa(x)x = x + self.ffwd(x) # 原来是 x = self.ffwd(x)分支不再「替换」x,而是在 x 上「加一点修改」。 像一份共享文档:每个人都能读全文、在后面追加批注, 但不能删改原文。
★ 推导:递推展开
每个分支是不同的函数(各有参数),记作 f₁, f₂, …;每个分支读的是它的输入:
x₁ = x₀ + f₁(x₀)x₂ = x₁ + f₂(x₁) = x₀ + f₁(x₀) + f₂(x₁)x₃ = x₂ + f₃(x₂) = x₀ + f₁(x₀) + f₂(x₁) + f₃(x₂)⋮xₙ = x₀ + f₁(x₀) + f₂(x₁) + … + fₙ(xₙ₋₁)两处容易写错:最后一项是 fₙ(xₙ₋₁),不是 f(xₙ) —— xₙ 是结果,不会出现在自己的算式里;
f₁, f₂, … 是不同的函数,不是同一个 f 用 n 次(3 个 Block 就是 6 个分支:每个 Block 的注意力和 FFN 各一个)。
拿数字核一遍:x₀ = [1.00, 2.00],f₁(x₀) = [0.10, −0.05] → x₁ = [1.10, 1.95];
f₂(x₁) = [0.02, 0.30](读的是 x₁ 不是 x₀)→ x₂ = [1.12, 2.25] = x₀ + f₁(x₀) + f₂(x₁) ✅
展开式说明了两件事:
x₀单独站在最前面、系数是 1 → 原始信息(是哪个字符、在第几位)原封不动地保留到最后,lm_head能直接看到- 后面的分支读到的输入,包含了前面所有分支的成果(
f₃读的x₂ = x₀ + f₁(x₀) + f₂(x₁))→ 多轮通信: 第一轮收集来的信息,第二轮在它的基础上继续收集
★ 推导:反向时那个恒为 1 的项
对一层求导(I 是单位矩阵,Jₖ = ∂fₖ/∂xₖ₋₁ 是分支自己的雅可比):
∂xₖ / ∂xₖ₋₁ = I + Jₖ链式法则把 n 层连乘起来,再展开:
∂xₙ / ∂x₀ = (I + Jₙ)(I + Jₙ₋₁) ⋯ (I + J₁) = I + Σₖ Jₖ + Σⱼ<ₖ Jₖ Jⱼ + … + Jₙ ⋯ J₁第一项 I 跟各个 Jₖ 有多小完全无关。 没有残差时只剩最后一项 Jₙ ⋯ J₁,一路连乘下去就消失了。
标量版一眼就能看出差别,每层局部导数 0.3、三层:
没有残差:0.3 × 0.3 × 0.3 = 0.027有残差: (1 + 0.3)³ = 1 + 0.9 + 0.27 + 0.027 ↑ 沿主干回来的那一条路,永远是 1在计算图上看就是第四篇 micrograd 的加法节点:self.grad += out.grad、other.grad += out.grad ——
(+) 把梯度原样分给两边:一份不衰减地回到主干,一份给分支去更新分支自己的参数。
| 名字 | 指什么 | 代码里 |
|---|---|---|
| 残差 | 分支算出来的差值(这一层的输出减输入) | self.sa(self.ln1(x)) 的结果 |
| 残差分支 | 算残差的模块 | self.sa、self.ffwd |
| 残差连接 | 把残差加回主干那一步 | x = x + ... 里的 x + |
| 残差流 | 主干上一路传下去的 x | 贯穿所有 Block 的 x |
出处是 2015 年的 ResNet(何恺明等,图像识别):学「差值」比学完整输出容易。假设某一层最好的做法是什么都不改 —— 没有残差时,要让随机权重恰好学成恒等映射,难;有残差时只要分支输出 0,权重往 0 缩就行。 「不改」在残差结构里是默认状态。
初始化时:深网络 ≈ 浅网络
刚初始化时 Linear 的权重很小,分支输出通常比主干上的 x 小得多:x = [1.00, 2.00],f(x) = [0.01, −0.02],
x + f(x) ≈ x。于是 x₁₂ = x₀ + (f₁ + … + f₁₂) ≈ x₀,整个模型约等于「查表 → lm_head」——
第二十四篇那个只有入口和出口的浅模型。浅模型好训:先学会 bigram 那种最简单的规律,
训练中分支权重慢慢长大、f(x) 开始有实际内容,就是「分支慢慢接入」。
配套两处修改
- 多头后面加
proj:self.proj = nn.Linear(head_size * num_heads, n_embd)。分支结果写回残差流之前, 用一个 Linear 把并排放着的各头结果组合整理 —— 正是上面推导里那个[W_A W_B]。有了它,头数 × 每头维度不必等于n_embd - FFN 先放大 4 倍再缩回:
Linear(n_embd, 4·n_embd) → ReLU → Linear(4·n_embd, n_embd),补成标准的两层 MLP。 第二个 Linear 就是写回残差流的投影(原论文d_model512、中间层 2048)
Karpathy 的 val 约 2.08,而且 train 开始明显低于 val —— 模型大了,开始有能力「背」训练集。
LayerNorm:换个方向归一化
★ 推导:残差流的方差越加越大
残差流是一路加下来的:xₙ = x₀ + f₁ + f₂ + … + fₙ。第十五篇的规则:独立变量相加,方差相加、标准差不相加。
设每一项的方差都是 σ²:
Var(xₙ) = Var(x₀) + Var(f₁) + … + Var(fₙ) = (n + 1)·σ²std(xₙ) = √(n + 1) · σ两个标准差为 1 的信号相加,标准差是 √2 ≈ 1.414(V4 实测 1.413)。3 个 Block 加 6 次,约 √7 ≈ 2.65 倍; 6 个 Block 加 12 次,约 √13 ≈ 3.61 倍。层越深,数值尺度越大,后面的分支拿到的输入尺度跟前面完全不同。
为什么不用 BatchNorm
第十六篇和第二十四篇已经讲过,这里只列结论:
| BatchNorm 的问题 | 在 Transformer 里 |
|---|---|
| 同一批的样本互相影响(第十六篇实测 −3.8143 vs −4.3548) | 注解 ③ 要求序列之间完全独立 |
| batch = 1 时算不出方差 | 生成时就是 batch = 1 |
| 训练和推理行为不同,要 running 统计量 | 麻烦、容易出错 |
方案:按行归一化
第二十四篇说过,矩阵归一化必须先说沿哪个方向:BatchNorm 按列(每个特征、跨所有样本,x.mean(0)),
LayerNorm 按行(每个位置、跨自己的特征,x.mean(-1))。LayerNorm 只看一个位置自己的数,
上面三个问题全部消失。
一个位置的 4 维向量(真实是 384 维):x = [2, 4, 6, 8]均值 = 5;方差 = (9 + 1 + 1 + 9) / 4 = 5,标准差 = 2.236归一化 = [−1.34, −0.45, 0.45, 1.34] ← 均值 0、方差 1然后 × γ + β(跟 BN 一样,可学习的缩放和平移)Karpathy 在视频里就是拿第十六篇手写的 BatchNorm 改的:mean(0) 改成 mean(1),再删掉 running 统计量那几行。
实际代码用 nn.LayerNorm(n_embd),每个有 γ 384 + β 384 = 768 个参数。
放在哪:Pre-Norm
def forward(self, x): x = x + self.sa(self.ln1(x)) # 先归一化,再进注意力 x = x + self.ffwd(self.ln2(x)) # 先归一化,再进 FFN return xLayerNorm 放在分支入口,不放在残差流上:每个分支拿到的输入都是归一化过的,而残差流本身保持干净 —— 上面推导里那条系数为 1 的直通路径不受影响。
| Post-Norm(原论文 2017) | Pre-Norm(本篇,GPT-2 起) | |
|---|---|---|
| 写法 | x = LN(x + f(x)) | x = x + f(LN(x)) |
| LN 在哪 | 残差流上 | 分支入口 |
| 深网络 | 要小心调学习率 | 更稳 |
最后在 lm_head 之前再加一个 self.ln_f(f = final):残差流加了很多次,出口前统一归一化一次。
阶段二会碰到 RMSNorm:只除以均方根、不减均值,更省算力,Llama、Qwen 都用它。
实测:实验 C(3 层 + 残差 + LayerNorm)
preset=small residual=True layernorm=True device=mps0.0424 M parametersstep 0: train loss 4.3103, val loss 4.3100step 2000: train loss 2.1190, val loss 2.1600step 4999: train loss 1.9747, val loss 2.0613val 2.0613,跟 Karpathy 的约 2.06 对上了。参数 0.0424 M = 42,369,跟下面的逐项计算一个不差。第三次问:
| 上下文 | 第 7 段(1 层) | 实验 C(3 层 + 残差 + LN) | 原文实际 |
|---|---|---|---|
| thou | r、空格、t | 空格 0.34、g 0.22、r 0.12、s 0.07 | 空格 66.8%、g 16.7%、s 5.1% |
| hou | r、s、空格 | l 0.33、g 0.16、空格 0.15、r 0.13 | 空格 49.5%、l 15.3%、g 12.6% |
| you | 空格、r、n | 空格 0.36、r 0.29、逗号 0.12 | 空格 44.0%、r 35.1%、逗号 7.6% |
thou 的第一名从 r 变成了空格,g(though / thought)进了前二。 这就是多轮通信的效果:
第 7 段只通信一轮,最后一个位置主要看到了 ou;现在 3 层、6 个分支,th 的信息终于被用上了。
r 还没完全消失(thou 后面仍给 12%,原文 0%)。
train / val 的差距从第 7 段的 0.035 扩大到 0.087 —— 参数从 8609 涨到 4.2 万,过拟合在扩大。 生成的文本已经是剧本的格式:角色名 + 冒号 + 换行 + 台词,短语级别像英文。
⚠️ 这个小模型在 Mac 的 MPS 上跑了 12.3 分钟。模型太小时,每次把小计算发给 GPU 的开销比计算本身还大; 评估也不便宜(每次 400 个 batch,11 次就是 4400 次前向)。
放大 + Dropout
放大
| 超参 | small | large | 说明 |
|---|---|---|---|
| batch_size | 32 | 64 | |
| block_size | 8 | 256 | 上下文长 32 倍,注意力矩阵 256×256 |
| n_embd | 32 | 384 | |
| n_head(每头) | 4(8) | 6(64) | 上面那张表里业内常见的每头 64 |
| n_layer | 3 | 6 | 通信 + 计算 6 轮 |
| learning_rate | 1e-3 | 3e-4 | 模型大了步子要小 |
| dropout | 0 | 0.2 | 防过拟合 |
| 参数 | 42,369 | 10,788,929 |
参数逐项算(d = 384,每个 Block):注意力 3d² + (d² + d) = 590,208,FFN (d·4d + 4d) + (4d·d + d) = 1,181,568,
两个 LN 2 × 768。6 个 Block 合计:
| 部分 | 参数 | 占比 |
|---|---|---|
| FFN | 7,089,408 | 65.7% |
| 注意力 | 3,541,248 | 32.8% |
| 嵌入表(token + 位置) | 123,264 | 1.1% |
| lm_head | 25,025 | 0.2% |
| LayerNorm(13 个) | 9,984 | 0.1% |
FFN 约占三分之二,跟上面 8d² : 4d² 的估算一致。另外,注意力的参数跟上下文长度无关:T 从 8 到 256,
一个参数都没多。第十二篇结尾说过,MLP 的 block_size 每加 1 输入维度就加 n_embd,这个方案不 scale,
「这正是 Transformer 存在的理由」—— 这里兑现了。
现状:过拟合
1079 万参数 vs 100 万字符,模型完全有能力把训练集背下来:train 一直降,val 停住甚至回升。 第 7 段差 0.035、实验 C 差 0.087,放大后会严重得多。
方案:Dropout
训练时每次前向,随机把一部分数清零,剩下的乘 1/(1−p) 补偿。
输入: [1.0, 2.0, 3.0, 4.0, 5.0]随机掩码: [ 1, 1, 0, 1, 1 ] ← 这次第 3 个被抽中(p = 0.2)× 1.25: [1.25, 2.5, 0, 5.0, 6.25]下一次前向,被清零的是另外一些模型不能依赖某个固定的神经元或路径,必须学到冗余、稳健的规律;也可以理解成每步训练一个随机子网络, 最后相当于很多子网络的平均。
为什么乘 1/(1−p):每个数被保留的概率是 1 − p,掩码 m 取 1 的概率是 1 − p、取 0 的概率是 p:
E[ m · x / (1 − p) ] = (1 − p) · x / (1 − p) + p · 0 = x训练时的期望跟不扔时一样。 所以评估时直接关掉 Dropout,后面的层拿到的数值尺度跟训练时一致。
| 位置 | 扔掉的是什么 | 效果 |
|---|---|---|
| ① Head 里 softmax 之后 | 注意力权重的一部分 | 随机切断一些「通信的边」 |
② 多头 proj 之后 | 注意力分支的修改意见 | 不能押在某几维上 |
| ③ FFN 最后 | FFN 分支的修改意见 | 同 ② |
GPT-2 的配置里 ② ③ 共用一个参数叫 resid_pdrop(残差 dropout),① 叫 attn_pdrop。
Dropout 放在分支上,不放在主干上
out = self.dropout(self.proj(out)) # ② 多头写回残差流之前主干 x = [1.00, 2.00, 0.50, −1.00]修改意见 f = [0.40, −0.20, 0.10, 0.30]Dropout 后 = [0.50, 0.00, 0.125, 0.375] ← 第 2 个被扔,其余 ×1.25x + f_dropped = [1.50, 2.00, 0.625, −0.625] ↑ 这一维这次没被改,但原来的 2.00 还在被扔掉的只是修改意见的一部分,主干上的原始 x 一个数都没动。 如果写成 x = self.dropout(x + ...) 放在主干上,
每层都会随机抹掉原始信息,梯度直通的路径也被切断一部分。
🔑 Dropout 放在分支上,跟 LayerNorm 放在分支入口是同一个原则:残差流本身要保持干净。 所有「改动」都在分支里做,主干只负责传递和累加。
model.eval() 终于有用了
Dropout 只在训练时生效,评估和生成时必须关掉,否则每次预测都带随机性。第二十三篇写 estimate_loss() 时留了一句:
model.eval() 对 bigram 没用,但先写上,后面加了 Dropout 评估时必须关掉 —— 就是这里。LayerNorm 训练推理行为一样,不需要切,
所以 Transformer 里 model.eval() 主要就是为了关 Dropout,跟第十六篇为了切 BN 的 running 统计量不一样。
完整的样子
到这里模型就定型了:嵌入 → 6 个 Pre-Norm Block → ln_f → lm_head,一个完整的 decoder-only Transformer,
就是 GPT 的结构。下面这张图把一次前向、一个 Block、一个头、FFN、LayerNorm 的方向和参数分布放在了一起。
实验 D:1.4809
先测速
实验 D 参数多 250 倍、上下文长 32 倍、batch 大 2 倍。先临时改成 100 步测速:平均每步约 1.06 秒(含评估), 拆开算纯训练一步约 0.88 秒 → 5000 步训练约 73 分钟 + 11 次评估约 21 分钟 ≈ 1 小时 35 分。实测 93.1 分钟。
测速时 100 步的 val 是 2.47,hou / you / thou 三行几乎一样 —— 1079 万参数的深网络刚开始时表现跟 bigram 差不多, 正是上面「初始化时深网络 ≈ 浅网络」的起点。
训练
10.7889 M parametersstep 0: train loss 4.2846, val loss 4.2820step 1000: train loss 1.5327, val loss 1.7199step 2000: train loss 1.3057, val loss 1.5512step 3000: train loss 1.2012, val loss 1.5047step 4000: train loss 1.1221, val loss 1.4877step 4500: train loss 1.0857, val loss 1.4809 ← val 最低step 4999: train loss 1.0503, val loss 1.4938 ← val 开始回升训练完成,共 93.1 分钟| 预期(Karpathy) | 实测 | |
|---|---|---|
| 参数量 | 10.7889 M | 10.7889 M ✅ |
| val loss | 约 1.48 | 1.4809(最低)/ 1.4938(最后)✅ |
| train loss | 约 1.1 | 1.0503 |
| train / val 差距 | 约 0.4 | 0.44(实验 C 只有 0.087) |
D 这一步降了 0.58,比前面所有改进加起来(0.43)还多 —— 模型代码一行没改,只是把数字调大。 结构对了之后,放大就有效。
过拟合的转折点
第 4500 步 val 最低(1.4809),之后回升到 1.4938,而 train 还在降(1.0857 → 1.0503):
模型开始背训练集了。 这正好对上 nanoGPT 自带的莎士比亚配置 config/train_shakespeare_char.py:
eval_interval = 250 # keep frequent because we'll overfitalways_save_checkpoint = False # 只在 val 变好时才保存Karpathy 早就知道这个配置会过拟合,所以评估间隔设成 250(比这里的 500 更密),并且只保存 val 最好的那一版。 这里存下的是最后一步(1.4938),不是最好的一步。差别不大,但「保存最佳检查点」是训练大模型的标准做法。
第四次问:r 消失了
拿训练好的权重(gpt_large.pt)复算,model.eval() 下没有随机性,每次结果都一样:
| 模型 | thou 后面的预测 |
|---|---|
| bigram | 三行完全一样 |
| 第 7 段 | r、空格、t ❌ |
| 实验 C | 空格 0.34、g 0.22、r 0.12 |
| 实验 D | 空格 0.672、g 0.207、逗号 0.058、s 0.023、撇号 0.012(r 只剩 0.001) |
| 原文实际 | 空格 66.8%、g 16.7%、s 5.1%、逗号 4.3% |
空格 67.2% vs 原文 66.8%,g 20.7% vs 16.7% —— 几乎就是原文的分布。 第五名的撇号也合理: 莎士比亚里大量 thou’rt(thou art)、thou’lt(thou wilt)。you 也几乎贴合原文(空格 0.452、r 0.327、逗号 0.104)。
hou 偏差大一点(空格 0.638、r 0.108、g 0.084、s 0.059,原文占 15% 的 l 没进前五)。这不是模型的问题: 原文里的 hou 绝大多数前面还有字母(should、would 里前面是 s、w),而测试让 hou 出现在序列最开头, 模型看不到前面的字母,只能猜「单独一个 hou 开头」最可能接什么,自然跟「原文所有 hou 的统计」对不上。
生成的文本整段像莎剧:
QUEEN MARGARET:What, are you?
GLOUCESTER:You did, makes you upregant me.
KING HENRY VI:The visitate Lords, or soul wo角色名是剧本里真实存在的(实验 C 生成的还是编出来的名字),句式、标点、古英语用词都像,但意思不通。 256 个字符的上下文让它能记住「现在在哪场戏、谁在说话」。
每一步都在回答同一个问题
| 节点 | 模型能做什么 | val | thou 后面的第一名 |
|---|---|---|---|
| bigram | 只看前 1 个字符 | 2.49 | 跟 hou、you 一样 |
| 第 7 段:注意力 + FFN | 看前 8 个、通信一轮 | 2.2495 | r ❌ |
| 实验 C:3 层 + 残差 + LN | 多轮通信 | 2.0613 | 空格,r 还有 0.12 |
| 实验 D:放大 + Dropout | 看前 256 个、6 层、1079 万参数 | 1.4809 | 空格 0.672,r 0.001 |
多头、FFN、残差、LayerNorm、Dropout,每一样都在回答同一个问题:怎么更好地用上前面的内容。 残差和 LayerNorm 本身不是目的 —— 它们是「能把网络堆深」的前提;堆深、放大,才是为了让模型更会用上下文。
到这里写出来的,结构上已经是 GPT-2。下一篇对照原论文看它是哪一半,走读 nanoGPT 看工业级实现差在哪, 再看 ChatGPT 是怎么从这样一个模型长出来的。