bobofamilyNIU CHUN
25/36

thou 后面那个 r 是怎么消失的:一个注意力头长成 GPT

2026-09-2427 分钟—阅读

第二十四篇结尾说:把这个头装进模型、加上缩放,loss 终于要从 2.49 往下走了。

这一篇就走这一段路,一共四步:装进一个头 → 多头 + FFN → 堆深(残差 + LayerNorm)→ 放大 + Dropout。 每一步都拿同一个问题去问模型,看它的回答怎么变。


同一个问题:thou 后面接什么

第二十四篇开头那个实验:bigram 对 hou、you、thou 给出完全相同的预测,因为它只看最后一个 u。 先把标准答案数出来 —— 在莎士比亚全集里数每个上下文后面实际跟的字符:

text[i + len(ctx)] for 所有 text.startswith(ctx, i) 的位置 i
上下文出现次数原文里实际跟的(前几名)
thou1616空格 66.8%、g 16.7%(though / thought)、s 5.1%(thousand)、逗号 4.3%、t 4.1%(without)
hou2594空格 49.5%、l 15.3%(should / would)、g 12.6%、s 9.5%、r 4.5%
you4649空格 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 ← 普通属性还留在 CPU
d.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 拼会被当成另外几条序列

参数一个没多

每个 WW 的个数参数
单头 1 × 3232×3233072
多头 4 × 832×8123072

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 段3248
本篇第 8 段384664
GPT-2 small7681264
GPT-3 175B1228896128
Llama 3 8B409632128
Qwen2.5-7B358428128

🔑 业内实际是反过来定的:先定每头 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.2922
step 4000: train loss 2.2432, val loss 2.2456
step 4999: train loss 2.2145, val loss 2.2495

val 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) 开始有实际内容,就是「分支慢慢接入」。

残差通路:分支读一份、算、加回去,主干只负责传递和累加。反向时 (+) 把梯度原样分给两边,展开后那个 1 就是从 loss 直通第一层的路。

配套两处修改

  • 多头后面加 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_model 512、中间层 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 x

LayerNorm 放在分支入口,不放在残差流上:每个分支拿到的输入都是归一化过的,而残差流本身保持干净 —— 上面推导里那条系数为 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=mps
0.0424 M parameters
step 0: train loss 4.3103, val loss 4.3100
step 2000: train loss 2.1190, val loss 2.1600
step 4999: train loss 1.9747, val loss 2.0613

val 2.0613,跟 Karpathy 的约 2.06 对上了。参数 0.0424 M = 42,369,跟下面的逐项计算一个不差。第三次问:

上下文第 7 段(1 层)实验 C(3 层 + 残差 + LN)原文实际
thour、空格、t空格 0.34、g 0.22、r 0.12、s 0.07空格 66.8%、g 16.7%、s 5.1%
hour、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

放大

超参smalllarge说明
batch_size3264
block_size8256上下文长 32 倍,注意力矩阵 256×256
n_embd32384
n_head(每头)4(8)6(64)上面那张表里业内常见的每头 64
n_layer36通信 + 计算 6 轮
learning_rate1e-33e-4模型大了步子要小
dropout00.2防过拟合
参数42,36910,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 合计:

部分参数占比
FFN7,089,40865.7%
注意力3,541,24832.8%
嵌入表(token + 位置)123,2641.1%
lm_head25,0250.2%
LayerNorm(13 个)9,9840.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.25
x + 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 的方向和参数分布放在了一起。

gpt.py 的完整流程(large 配置)。蓝色粗线是残差流,每个分支都是「读一份 → 算 → 加回去」;三处 Dropout 全在分支上,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 parameters
step 0: train loss 4.2846, val loss 4.2820
step 1000: train loss 1.5327, val loss 1.7199
step 2000: train loss 1.3057, val loss 1.5512
step 3000: train loss 1.2012, val loss 1.5047
step 4000: train loss 1.1221, val loss 1.4877
step 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 M10.7889 M ✅
val loss约 1.481.4809(最低)/ 1.4938(最后)✅
train loss约 1.11.0503
train / val 差距约 0.40.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 overfit
always_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 个字符的上下文让它能记住「现在在哪场戏、谁在说话」。


每一步都在回答同一个问题

节点模型能做什么valthou 后面的第一名
bigram只看前 1 个字符2.49跟 hou、you 一样
第 7 段:注意力 + FFN看前 8 个、通信一轮2.2495r ❌
实验 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 是怎么从这样一个模型长出来的。