bobofamilyNIU CHUN
12/36

2.1752:先有尺子,再谈调参

2026-09-0613 分钟—阅读

上一篇把 loss 压到了 2.33,越过了 bigram。但那些数字全是在训练集上算的 —— 它们只回答「模型记住了多少」。

这一篇先造尺子,再用尺子调参。没有炫技的东西,就是把一个数字变成两个 —— 但它是后面所有实验的地基。

一个数字不够用

回想上一篇那个 5 样本过拟合实验:loss = 0.001965,看起来完美无缺,但那个模型对任何新名字都是废的。

同样的机制正在当前模型上小规模发生,而且马上要变严重:

参数现在调参之后
n_embd210
隐藏层宽度100200—300
总参数量3481约 1.2 万

参数量翻三倍,背诵能力也随之上升。会出现这样的局面:训练集 loss 持续下降、看起来在进步,而真实能力已经在退步 —— 从一个数字上完全看不出来。

顺序必须是:先建评估体系,再加大模型。 反过来做,后面所有实验结论都不可信。

三个集合,谁看得到谁

集合占比用途谁「看得到」它
train80%算梯度、更新参数模型
dev / val10%调超参数、判断过拟合你
test10%最终报成绩几乎没人,用一次

为什么 val 和 test 要分开

既然都是「没训练过的数据」,一份不就够了?

看看调参实际是怎么进行的:

n_embd=2, hidden=100 → val 2.31
n_embd=10, hidden=100 → val 2.24
n_embd=10, hidden=300 → val 2.19
n_embd=10, hidden=500 → val 2.20 ← 变差,退回去
... 试了 50 次,选 val 最低的那组

你每看一次 val,就往模型里注入了一点 val 的信息。 50 次之后选出来的超参数,一部分是真的好,一部分只是碰巧在这 10% 数据上运气好。val 上那个数因此偏乐观。

train → val → test
模型拟合参数 你拟合超参数 谁都不拟合

模型的参数是从 train 学的;你的超参数是从 val 学的。你自己也是这个系统的一部分。

纪律是:test 只用一次。看了 test 发现不满意、回去改模型再看一次 —— test 当场变成第二个 val。

Kaggle 比赛就是这个机制:比赛期间只能看 public LB(val),private LB(test)到最后一刻才揭晓。很多人 public 刷得很高,private 一开就掉队 —— 那就是过拟合了 val。

为什么是 80/10/10

没有神圣性,是惯例。判断依据:val 和 test 要够大,大到 loss 数字稳定。

10% = 3203 个名字 ≈ 2 万个样本,够了。数据只有 1000 条时,10% 就是 100 条,val 会抖得没法用 —— 那时要改 60/20/20 或用交叉验证。数据越多,验证集占比可以越小:ImageNet 128 万张图,验证集只占 4%。

按名字切,不是按样本切

emma 会产生 5 个样本:

[. . .] → e
[. . e] → m
[. e m] → m
[e m m] → a
[m m a] → .

若按样本随机切,可能变成这样:

训练集:[. . .] → e [. e m] → m [m m a] → .
验证集:[. . e] → m [e m m] → a

模型训练时见过 emma 的开头和结尾,验证时又拿它中间的片段来考 —— 这就不是「没见过的数据」了。

验证集被污染 ⟹ val loss 偏低 ⟹ 你以为模型很好

所以要先把 32033 个名字分成三堆,再分别调 build_dataset。

通用原则:切分要在「最小独立单位」上进行。 这里是名字;医疗影像是病人(同一病人的多张片子不能跨集合);时间序列要按时间切,不能随机打乱。

代码,以及 shuffle 为什么必要

import random
words = open('names.txt').read().splitlines() # 每次从文件重读
random.seed(42)
random.shuffle(words)
n1 = int(0.8 * len(words)) # 25626
n2 = int(0.9 * len(words)) # 28829
Xtr, Ytr = build_dataset(words[:n1]) # 182625 个样本
Xdev, Ydev = build_dataset(words[n1:n2]) # 22655
Xte, Yte = build_dataset(words[n2:]) # 22866

shuffle 把列表元素随机打乱,原地修改,返回 None —— 写成 b = random.shuffle(a) 得到的是 None。

为什么需要它:names.txt 是按出现频率排序的 —— emma、olivia 在最前面,最后全是冷门怪名字。不打乱直接切:

集合拿到的是后果
train常见名字,规律性强学到的是主流命名规律
test冷门怪名字,规律性差loss 异常地高

三个集合必须来自同一个分布 —— 这是划分数据集的前提。

random.seed(42) 把「随机」变成可复现的随机。不固定的后果:调参时每次重跑划分都不同,val 从 2.19 变成 2.23,你分不清是超参数的功劳还是换了一批数据。

⚠️ 训练循环里的 X/Y 要全部换成 Xtr/Ytr。 漏改一处,模型就会在全量数据上训练,验证集当场失效,而且不报错。

怎么读两个数字

差距和水平是两个独立维度。左列那个括注是重点:①欠拟合和②理想状态的差距一模一样,光看差距分不出来。

差距(train 和 val 差多少)回答「有没有在背数据」;水平(loss 的绝对值)回答「学得够不够好」。这两件事互不相关,组合起来四种情况:

trainval诊断
①2.332.33差距小 + 水平差 → 欠拟合
②1.501.52差距小 + 水平好 → 理想状态
③1.802.40差距大 + 水平好 → 过拟合
④2.803.30差距大 + 水平差 → 数据划分有问题

①和②的 train/val 差距一模一样,但一个是欠拟合、一个是最佳状态。光看差距分不出来。

极端的反例:一个只有 3 个参数的模型,train 3.29 / val 3.29,差距为 0 —— 但这显然是最糟的模型。

判断「水平」需要参照系

参照loss
瞎猜(ln 27)3.2958
V2 bigram(只看 1 个字符)2.4544
当前2.3313
同架构、模型更大时的已知水平约 2.17

最后一行是关键:同样的 MLP 结构能到 2.17,说明当前架构下还有 0.16 的空间没榨出来。

欠拟合的完整判断式要两个条件:train ≈ val(没在背数据)加上「已知更大的模型能更好」(水平还有空间)。缺一不可。

差距回答「能不能加大」,绝对值回答「该不该加大」。

严格地说,train ≈ val 只证明模型容量还没被数据「撑满」,加大模型是安全的。至于加大之后有没有用 —— 要试了才知道。

实测

train 2.3320 val 2.3313

val 比 train 还低 0.0007 —— 两个集合各有随机性,这个差完全在噪声范围内,它们本质上是同一个数。

结论:3481 个参数面对 18 万个训练样本,连「背」的能力都没有,它被迫只能去学真正的规律。可以放心加大。

顺带验证了划分是干净的。若按样本切而不是按名字切,验证集会被污染,val 会明显低于 train。现在两个数贴得这么近,说明切分对了。

四个旋钮

旋钮代码改的是什么
字符嵌入维度n_embd每个字符能携带多少信息
隐藏层宽度n_hidden这一层的表达能力
训练步数steps训得够不够久
学习率及衰减lr每步走多远

层数(深度)不在这个列表里。 加第三层要改代码结构,而且没有下一集的初始化技巧,加深大概率更差 —— 你会分不清是「深度不好」还是「初始化不对」。先把变量控制住。

深度、宽度、嵌入维度是三件事

X (N,3) ─C[X]→ (N,3,10) ─展平→ (N,30) ─@W1+b1→ (N,200) ─@W2+b2→ (N,27)
↑ ↑ ↑ ↑
block_size=3 n_embd=10 n_hidden=200 27 锁死

两个瓶颈可能在不同位置:n_embd=1, n_hidden=1000 的话输入端就卡死了,隐藏层再宽也救不回来。所以要分开命名、分开调。

对照真实模型:

模型层数宽度
本篇2200
GPT-2 small12768
GPT-39612288
Llama 3 70B808192

真实模型是宽深一起长的。极端配置都不好:100 层 × 宽 16 太细(信息瓶颈);2 层 × 宽 10 万参数爆炸且低效。

深度的性价比更高,但需要一整套技巧才能兑现。 2012 年之前几乎没人能训超过几层的网络 —— 不是没想到,是训不动。ResNet(2015)用残差连接给梯度开了一条高速公路,152 层才成为可能。

通用逼近定理说两层足够宽就能逼近任意连续函数 —— 但「足够宽」可能是指数级的,而深网络只需线性级。

实验记录

配置参数量步数trainval差距
embd=2, hid=10034814 万2.33202.3313−0.001
embd=10, hid=10060974 万2.22562.2340+0.008
embd=10, hid=300176977 万2.21842.2665+0.048
embd=10, hid=2001189713 万2.13522.1752+0.040

⚠️ 这组对比并不干净 —— 步数不同。严格说只有最后一行是可靠的。要做公平对比,得把 100 和 300 也用 13 万步重跑。

诊断口诀

hid=300 那次 val 从 2.2340 涨到 2.2823,第一直觉会说「参数多了、过拟合了」—— 错的。看 train:

train 2.2256 → 2.2338 ← train 也变差了
现象诊断处方
train 和 val 一起变差训不够加步数
train 降、val 升过拟合减小模型 / 加数据 / 正则化

两种情况的处方完全相反,判错方向就白费功夫。 验证:追加 3 万步后 train 2.2184 / val 2.2665,两个都在降 —— 确认是训不够。

三条经验

  1. 超参数有甜点,不是越大越好。 100 太小、300 太大、200 刚好。
  2. 大模型需要更多步数。 改了容量不加步数,比较是不公平的。
  3. 一次只改一个,否则不知道是谁的功劳;每次都要重置参数从头训。

step 和 epoch 的换算:130000 × 32 ÷ 182625 ≈ 22.8 个 epoch。

现代大模型几乎只报 step 和 token 数,很少提 epoch —— 因为数据集大小不同时,同样 1 个 epoch 含义天差地别。GPT-3 甚至连 1 个 epoch 都没跑完。

把嵌入画出来

把 n_embd 临时改回 2 重训一遍,整张表就能直接画在纸上:

plt.scatter(C[:,0].data, C[:,1].data, s=200)
for i in range(27):
plt.text(C[i,0].item(), C[i,1].item(), itos[i], ha="center", va="center")
没有一个位置是人指定的。梯度只会把行为相似的字符往一起推,而 2 维平面装不下 27 个两两等距的点 —— 剩下的就是结构。
观察为什么
元音 a e i o u 聚成一团它们在名字里的行为相似
. 孤立在最下方只出现在末尾,后面不跟任何东西
q 飞到最顶端太罕见,几乎没被更新过
y 在元音区上方英语里本就是半元音(mary、lily)
w/d、g/f、t/s 两两贴在一起模型认为它们可互换

距离 = 可替换性。 模型学到的不是「什么是元音」这个概念,而是「a 和 e 出现的位置、后面能接什么,规律很像,所以给它们相近的坐标能让预测更准」。分类是副产品,不是目标。

第九篇结尾那句「结构不是设计出来的,是维度不够逼出来的」,到这里兑现了。那时故意没画这张图 —— 网络一次都还没训练,画出来是假的。

为什么 C 也被调整

常见误解:C 是输入,不该被改。错的。

是什么会被调整吗
X(N,3) 字符编号✗ 这才是输入,来自数据
C(27, n_embd) 查找表✓ 是参数,模型自己的
emb = C[X]查表结果中间结果,每次重算

关键在于:

emb = onehot(X) · C

这就是一次标准的 x @ W。 因为 one-hot 里只有一个 1,矩阵乘退化成了「取第几行」。所以真实结构是三层:

one-hot (27) ─@C→ (n_embd) ─@W1+b1→ (n_hidden) ─@W2+b2→ (27)
↑
C 就是这一层的权重

凭什么 W1 要训而它不要? 不写成 one-hot 相乘,只是因为那样 99% 的计算都在乘 0。

如果把 C 固定住:固定成 one-hot,任意两个字符的距离都相同,退回 V2;固定成随机 2 维,a 可能紧挨着 z,这是错误的先验,比没有还糟。没有第三个选项 —— 你不可能手写出正确的 C。

梯度是怎么传到 C 的

∂L/∂C[v] = 把 batch 里所有用到字符 v 的位置,它们的 emb 梯度全部加起来

两个性质:稀疏 —— 没用到的行梯度为 0,一个 batch 最多碰 96 个位置,27 行里可能只有十几行非零;累加 —— 同一字符出现多次要相加,样本 [5,13,13] 里 m 出现两次,实测 ∂L/∂C[13] = [+1.472,−0.681] + [+2.315,−0.284] = [+3.787,−0.965]。

第二条正是第四篇里 a + a 那个实验的道理 —— 一个变量在计算图里被用了几次,梯度就要累加几次,所以 _backward 里必须写 +=。

而第一条就是 q 的处境:罕见字母很久才被碰一次,所以它在散点图上几乎还停在随机初始位置。

整条链路走一遍是这样的 —— 前向六步、反向六步,最后落到 C 上:

交互动画 · 前向 6 步 + 反向 6 步 · 可单步 / 自动播放新窗口打开 ↗

反向那一段里有一步值得留意:梯度穿过 tanh 时要乘上 (1 − h²),这个 batch 里平均只剩下 50%。 这是上一批留给下一集的伏笔之一,层数越多衰减越狠 —— 也是「现在还不能加深」的直接原因。

采样:生成新名字

for _ in range(20):
out = []
context = [0] * block_size # 从 "..." 开始
while True:
emb = C[torch.tensor([context])]
h = torch.tanh(emb.view(1, -1) @ W1 + b1)
logits = h @ W2 + b2
probs = F.softmax(logits, dim=1)
ix = torch.multinomial(probs, num_samples=1, generator=g).item()
context = context[1:] + [ix] # 把新字符接回上下文
if ix == 0: break
out.append(itos[ix])

采样机制第八篇讲透了,这里只看三个细节:emb.view(1, -1) —— batch size 是 1,上一批那句「别把形状写死」在这里再次兑现;multinomial 是按概率抽签,用 argmax 的话 20 个名字会一模一样;context = context[1:] + [ix] 是自回归,GPT 生成文本用的是同一个循环,只是把 MLP 换成了 Transformer。

实际输出:

carmahela jhevi kimrie taty sacaysa jazhnen amerynci aqui
nermara chaiir kaleigh ham porriquint sulin alianni
waythoniearynix kaellinsley dae oiq gian

进步在哪,失败在哪

对照第八篇 bigram 生成的 cexze、momasurailezitynn、konimittain:

  • kaleigh —— 学到了 -eigh 这个组合。从 e 推 i、从 i 推 g,单看前一个字母毫无理由,bigram 绝对学不到。
  • carmahela、nermara —— 元音辅音交替自然,没有 cexze 那种三辅音连一起。
  • waythoniearynix —— 太长了,但局部都对(way/thon/nie/ary)。上下文只有 3 个字符,模型不知道自己已经写了 15 个字母。
  • oiq —— 失败案例。q 后面应该跟 u,模型没学会 —— 因为 C 的第 17 行几乎没被更新过,正是散点图上那个飞到顶端的点。

新的瓶颈不是模型太小,是看得不够远。 但 block_size 每加 1,输入维度就加 n_embd —— 看 10 个字符就要 100 维输入,这个方案不 scale。

这正是 Transformer 存在的理由:用注意力机制处理任意长度的上下文,参数量不随长度线性增长。


V3 到此完结:

参照loss
瞎猜(ln 27)3.2958
V2 bigram2.4544
V3 MLP(val)2.1752

计划目标是约 2.2,实际做到 2.1752。

这四篇(第 9—12 篇)里真正带得走的东西,按重要性排:张量的内存模型(处理高维张量时是刚需)、train/val/test 划分(没有它,所有调参都是瞎调)、小数据过拟合检查(两分钟分清「代码 bug」和「策略问题」)、对数空间搜索(通用方法,不止用于学习率)、mini-batch、表示学习。

进入下一集之前还夹着一篇:这四篇里 C 一直被当成一张查表用的矩阵,但它是参数,得有梯度 —— 下一篇把「索引怎么求导」算出来。

再往后,下一集要修三件事,全部已经实测过:初始 loss 该是 3.30 却是 17;h 里贴着 ±1 的比例远超应有;梯度穿过 tanh 只剩一半。修完才有资格加深网络。