2.1752:先有尺子,再谈调参
上一篇把 loss 压到了 2.33,越过了 bigram。但那些数字全是在训练集上算的 —— 它们只回答「模型记住了多少」。
这一篇先造尺子,再用尺子调参。没有炫技的东西,就是把一个数字变成两个 —— 但它是后面所有实验的地基。
一个数字不够用
回想上一篇那个 5 样本过拟合实验:loss = 0.001965,看起来完美无缺,但那个模型对任何新名字都是废的。
同样的机制正在当前模型上小规模发生,而且马上要变严重:
| 参数 | 现在 | 调参之后 |
|---|---|---|
n_embd | 2 | 10 |
| 隐藏层宽度 | 100 | 200—300 |
| 总参数量 | 3481 | 约 1.2 万 |
参数量翻三倍,背诵能力也随之上升。会出现这样的局面:训练集 loss 持续下降、看起来在进步,而真实能力已经在退步 —— 从一个数字上完全看不出来。
顺序必须是:先建评估体系,再加大模型。 反过来做,后面所有实验结论都不可信。
三个集合,谁看得到谁
| 集合 | 占比 | 用途 | 谁「看得到」它 |
|---|---|---|---|
| train | 80% | 算梯度、更新参数 | 模型 |
| dev / val | 10% | 调超参数、判断过拟合 | 你 |
| test | 10% | 最终报成绩 | 几乎没人,用一次 |
为什么 val 和 test 要分开
既然都是「没训练过的数据」,一份不就够了?
看看调参实际是怎么进行的:
n_embd=2, hidden=100 → val 2.31n_embd=10, hidden=100 → val 2.24n_embd=10, hidden=300 → val 2.19n_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 randomwords = open('names.txt').read().splitlines() # 每次从文件重读random.seed(42)random.shuffle(words)
n1 = int(0.8 * len(words)) # 25626n2 = int(0.9 * len(words)) # 28829
Xtr, Ytr = build_dataset(words[:n1]) # 182625 个样本Xdev, Ydev = build_dataset(words[n1:n2]) # 22655Xte, Yte = build_dataset(words[n2:]) # 22866shuffle 把列表元素随机打乱,原地修改,返回 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 的绝对值)回答「学得够不够好」。这两件事互不相关,组合起来四种情况:
| train | val | 诊断 | |
|---|---|---|---|
| ① | 2.33 | 2.33 | 差距小 + 水平差 → 欠拟合 |
| ② | 1.50 | 1.52 | 差距小 + 水平好 → 理想状态 |
| ③ | 1.80 | 2.40 | 差距大 + 水平好 → 过拟合 |
| ④ | 2.80 | 3.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.3313val 比 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 的话输入端就卡死了,隐藏层再宽也救不回来。所以要分开命名、分开调。
对照真实模型:
| 模型 | 层数 | 宽度 |
|---|---|---|
| 本篇 | 2 | 200 |
| GPT-2 small | 12 | 768 |
| GPT-3 | 96 | 12288 |
| Llama 3 70B | 80 | 8192 |
真实模型是宽深一起长的。极端配置都不好:100 层 × 宽 16 太细(信息瓶颈);2 层 × 宽 10 万参数爆炸且低效。
深度的性价比更高,但需要一整套技巧才能兑现。 2012 年之前几乎没人能训超过几层的网络 —— 不是没想到,是训不动。ResNet(2015)用残差连接给梯度开了一条高速公路,152 层才成为可能。
通用逼近定理说两层足够宽就能逼近任意连续函数 —— 但「足够宽」可能是指数级的,而深网络只需线性级。
实验记录
| 配置 | 参数量 | 步数 | train | val | 差距 |
|---|---|---|---|---|---|
| embd=2, hid=100 | 3481 | 4 万 | 2.3320 | 2.3313 | −0.001 |
| embd=10, hid=100 | 6097 | 4 万 | 2.2256 | 2.2340 | +0.008 |
| embd=10, hid=300 | 17697 | 7 万 | 2.2184 | 2.2665 | +0.048 |
| embd=10, hid=200 | 11897 | 13 万 | 2.1352 | 2.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,两个都在降 —— 确认是训不够。
三条经验
- 超参数有甜点,不是越大越好。 100 太小、300 太大、200 刚好。
- 大模型需要更多步数。 改了容量不加步数,比较是不公平的。
- 一次只改一个,否则不知道是谁的功劳;每次都要重置参数从头训。
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")| 观察 | 为什么 |
|---|---|
| 元音 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 上:
反向那一段里有一步值得留意:梯度穿过
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 aquinermara chaiir kaleigh ham porriquint sulin alianniwaythoniearynix 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 bigram | 2.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 只剩一半。修完才有资格加深网络。