快 820 倍:粗糙的梯度和一条只能排除的曲线
上一篇把网络搭完了:3481 个参数、2 层、形状全部对上 —— 但它一次都还没训练过。
这一篇让它跑起来。有意思的是,跑起来靠的是两次故意的不精确:先用 32 个样本算一个「错」的梯度,再用一次粗糙的扫描定学习率。第一次的收益是干净的,第二次带条件 —— 而那个条件才是这一篇真正要说的。
step 数的不是样本数
这是最容易混的一处。for step in range(100) 里的 100 不是 100 个样本。
emb = C[X] # X 是全部 228146 个样本一次 step 用掉了全部 228146 个样本。 100 次循环 = 把数据集看了 100 遍 = 2281 万人次。
step 数的是「参数被更新了几次」,不是样本数。p.data += -0.1 * p.grad 执行一次 = 一个 step。
三个术语分清:
| 术语 | 含义 | 全量时 | mini-batch 后 |
|---|---|---|---|
| 样本数 | 数据集多大 | 228146 | 228146 |
| step / iteration | 参数更新了几次 | 100 | 30000 |
| epoch | 完整过了几遍数据集 | 100(与 step 相等) | 30000×32÷228146 ≈ 4.2 |
全量训练时 step 和 epoch 恰好相等,这是巧合。 换成 mini-batch 后跑完一个 epoch 需要 228146÷32 ≈ 7130 步,两者就分家了。看论文说「10 万 step」和「3 个 epoch」是完全不同的事。
为什么不能一个样本更新一次
因为参数只有一套,样本要一起说了算。
样本 A 说 W1[3][7] 该调大,样本 B 说该调小 —— 谁说了算?正确做法是把所有意见平均再动一次。这正是 F.cross_entropy 里那个 .mean() 干的事,上一篇写手写版时它就在第 5 步。
先跑起来:全量 100 步
训练循环的三段结构第六篇讲过、第八篇对照过、上一篇的 parameters 那节又贴过一次,这里不再重复。直接看数字:
| step | loss | 这 20 步降了 |
|---|---|---|
| 0 | 19.5052 | — |
| 20 | 8.3292 | −11.18 |
| 40 | 5.8031 | −2.53 |
| 60 | 4.5874 | −1.22 |
| 80 | 3.9281 | −0.66 |
| 100 | 3.5892 | −0.34 |
每 20 步的降幅几乎在对半减。 起点在极陡的坡上(randn 初始化让 logits 大得离谱),头几步梯度极大;越接近谷底,坡越缓,走得越慢。
耗时 8.2 秒 / 100 步 = 每步 82 毫秒。 要降到 bigram 那个 2.45,估计需要 3—5 万步 ≈ 68 分钟。而后面两段要反复做实验 —— 这个循环根本没法用来调参。
p.grad is None 这个坑
TypeError: unsupported operand type(s) for *: 'float' and 'NoneType'原因是 requires_grad 没开。 torch.randn 造出来的张量默认不追踪梯度;重跑了建参数那格、没重跑 requires_grad = True,链就断了。
麻烦在于 backward() 此时不报错,只是什么都不做 —— p.grad 保持 None,等到 -0.1 * None 才炸。又一次错在 A 处、报在 B 处,和上一篇 view(-1, 6) 那个是同一类。
诊断一行就够:
for name, p in zip(['C','W1','b1','W2','b2'], parameters): print(name, p.requires_grad, p.grad is None)判断标准:已经调过
backward()而p.grad is None,就是requires_grad的问题。预防:把「建参数」和「开梯度」放进同一个格子,永远一起执行。Jupyter 最容易出的一类 bug 就是这个 —— 格子的执行顺序和它们在页面上的顺序无关。
小数据过拟合:最通用的排查手段
拿前 5 个样本反复训,看 loss 能不能降到接近 0。
Xs, Ys = X[:5], Y[:5]
for step in range(200): emb = C[Xs] h = torch.tanh(emb.view(-1, 6) @ W1 + b1) logits = h @ W2 + b2 loss = F.cross_entropy(logits, Ys)
for p in parameters: p.grad = None loss.backward() for p in parameters: p.data += -0.1 * p.grad实测:4.953627 → 0.007955 → 0.003903 → 0.002600 → 0.001965,只用 50 步就到了 0.008。
这个检查的价值是把两类问题彻底分开:
| 现象 | 结论 | 下一步查什么 |
|---|---|---|
| 能过拟合到 ~0 | 代码是对的 | 只查学习率、batch、数据、架构 |
| 小数据也降不下去 | 代码有 bug | 查形状、requires_grad、梯度清零、标签对齐 |
逻辑很硬:5 个样本、3481 个参数,模型完全有能力死记硬背。记不住只可能是链路断了。
上面那个
p.grad is None的 bug,如果先跑这个检查,两分钟就能暴露,不用等到跑全量时才发现。以后训练任何模型,第一步都该做它。
为什么是 0.001965 而不是 0
loss 要到 0,需要正确答案的概率正好等于 1 —— 那要求对应的 logit 是 +∞。梯度下降只能无限逼近,衰减大致按 1/step。
如果 5 个样本里出现「同一个上下文对应不同答案」,loss 就有一个大于 0 的下限 —— 模型不可能同时预测对两个不同答案。
另外,做完这个检查要重置参数。模型花了 200 步去死记那 5 条,已经过拟合到极致。
mini-batch:三处改动
for step in range(30000): ix = torch.randint(0, X.shape[0], (32,)) # ① 随机抽 32 个下标
emb = C[X[ix]] # ② 只取这 32 行 h = torch.tanh(emb.view(-1, 6) @ W1 + b1) logits = h @ W2 + b2 loss = F.cross_entropy(logits, Y[ix]) # ③ 标签必须同步取③ 最容易漏。
X[ix]取了但Y忘了取,形状不匹配会报错 —— 这算幸运的;若恰好撞上某个维度,可能不报错但答案全错。顺带,上一篇埋的那个雷在这里第一次炸:N 从 228146 变成 32,
emb.view(228146, 6)当场越界。写成emb.view(emb.shape[0], -1)就没事。
实测对比:
| 方法 | 每步耗时 | 步数 | 总耗时 | loss |
|---|---|---|---|---|
| 全量(228146) | 82 ms | 100 | 8.2 秒 | 3.5892 |
| mini-batch(32) | 0.1 ms | 30000 | 3.9 秒 | 2.4046 |
快 820 倍,而且效果好得多。
顺带一个值得留意的细节:样本数少了 7130 倍,但只快了 820 倍。差的那部分是 Python 循环、randint、autograd 记账这些固定开销 —— 样本再少也省不掉。
为什么「粗糙的梯度」反而更好
32 个样本算出的梯度确实不准,它是全量梯度的一个带噪声估计。但关键在于:
梯度只需要「大致对」就够了。 它的作用是指个方向,你走一小步就重新算一次。走歪一点,下一步会纠回来。
方向精确,走 1 步 vs 方向有噪声,走 7130 步 (全量) (mini-batch)7130 次带噪声的修正,走得比 1 次完美的修正远得多。 噪声在多步之间会互相抵消。
更进一步:噪声本身有时是好事,能把参数从不好的位置里抖出来。
而且现代深度学习里根本不存在全量梯度,因为数据集装不进内存。GPT-3 的 batch 是 320 万 token —— 相对于几千亿 token 的语料,仍是极小的一撮。
训练时打印的 loss 不能用
它只来自 32 个样本,噪声很大:
step 400 loss 2.2671 ← 这 32 个碰巧好预测step 600 loss 2.5293 ← 这 32 个碰巧难像用 32 个人估计全国平均身高 —— 数字会跳,跳的是抽样,不是人变高。
@torch.no_grad()def evaluate(Xe, Ye): emb = C[Xe] h = torch.tanh(emb.view(-1, 6) @ W1 + b1) logits = h @ W2 + b2 return F.cross_entropy(logits, Ye).item()评估 = 只做前向,算一遍 loss,不更新参数。 和训练循环差三处:不抽样、不反向、不更新。
torch.no_grad() 的作用是别让 PyTorch 顺手记计算图(就是第四篇里 Value 类的 _prev / _backward)。不加也能跑出正确数字,只是白占几百 MB。
⚠️ 但这仍然是在训练集上评估。
X/Y就是训练用的那份数据,所以它回答的是「模型在见过的数据上表现如何」,不能回答「在没见过的名字上表现如何」。差别有多大?回想那个 5 样本实验 —— loss 0.001965 看起来完美,但那个模型对任何新样本都是废的。现在这个数只能理解成「模型记住了多少」,还不是「学会了多少」。
怎么看 loss 曲线
直接 plt.plot(lossi) 画出来是一个矩形 —— 从头到尾一样宽、一样高,完全看不出趋势。单步 loss 在 1.75—3.3 之间乱跳(波动 1.5),而 3 万步的真实改善可能只有 0.1 —— 噪声完全淹没了信号。
log10 帮不上忙:它只是压缩纵轴刻度,噪声和信号被同等压缩,信噪比一点没变。
log 图在跨数量级时才有用(比如 loss 从 17 掉到 2.4)。这里 loss 一直在一个数量级内。
有用的是移动平均:
li = torch.tensor(lossi)n = len(li) // 200 * 200 # 截掉尾巴,避免 view 报错smooth = li[:n].view(-1, 200).mean(1)噪声降低约 14 倍(√200 ≈ 14 —— 平均 n 个独立样本,噪声按 √n 缩小),趋势就露出来了。
view(-1, 200)又一次用到了上一篇那个 view,这次用来分组:把 30000 个数排成 150 行 × 200 列,再对每行求平均。
第二次「不精确」:学习率扫描
p.data += -0.1 * p.grad 里那个 0.1 是随手定的,而且有证据表明它偏大 —— 连跑三轮 3 万步:
| 轮次 | 全量 loss | 这轮降了 |
|---|---|---|
| 第 1 轮 | 2.4046 | −1.19 |
| 第 2 轮 | 2.3696 | −0.035 |
| 第 3 轮 | 2.3261 | −0.03 |
边际效益掉了四十倍,平滑后的曲线仍在 ±0.02 拖。这不是学到头了,是步子太大进不去 —— 就像下山时步子迈太大,到了谷底附近反而在两边山坡上来回蹦。
但也不能简单说「调小就行」,太小训练会慢到没法用。大小之间有个甜点,而这个甜点没法靠猜。
Karpathy 的办法:只跑一次,但让学习率在这一次里从极小一路涨到极大,同时记录每步 loss。
lre = torch.linspace(-3, 0, 1000) # 指数:−3 → 0lrs = 10 ** lre # 学习率:0.001 → 1.0为什么在指数上取值
lre 存的是指数,lrs 才是学习率。真正拿去更新参数的是 lrs,lre 只是中间产物和画图的横轴。
lre= learning rate exponent。这个e是 exponent 的缩写,不是自然常数 e —— 底数由代码里写的10 **决定。选 10 是因为人能心算:看到 −2 就知道是 0.01;若用 e,−2 是 0.135,得按计算器。换成 e 结果完全一样,只是刻度变成 −6.9~0,读起来费劲。粗略分工:e 用于计算,10 用于展示。
学习率是乘性的
先分清两个「步长」:linspace 相邻两点的取样间隔(这里是 0.003,在指数上),和梯度下降真正迈出的参数更新步长(lr × |grad|)。
学习率本身不是步长,它只是个缩放系数 —— 真正走多远还取决于梯度多大。这也解释了为什么训练初期(梯度大)和后期(梯度小)同样的 lr 效果完全不同。
关键在于 lr 是乘性的:
| 从 → 到 | 差值 | 实际影响 |
|---|---|---|
| 0.001 → 0.002 | +0.001 | 步长翻倍,天翻地覆 |
| 0.500 → 0.501 | +0.001 | 变化 0.2%,几乎没区别 |
同样的差值,意义完全不同。 所以衡量两个学习率「差多远」要看倍数,不看差值。
两种取样各有一个量是恒定的:
| 差值 | 倍数 | |
|---|---|---|
10 ** linspace(-3,0) | 从 0.000007 到 0.007(差 1000 倍) | 1.0069 恒定 ✓ |
linspace(0.001, 1) | 0.001 恒定 | 从 2.0 到 1.001(差 1000 倍) ✗ |
选哪个,取决于哪个量决定实际效果。 lr 是乘性的 → 倍数决定效果 → 选第一个。
指数上的等长取样,就是学习率上的等倍取样。我们要的正是后者。
点实际落在哪里:
| 区间 | 对数取样 | 线性取样 |
|---|---|---|
| 0.001 — 0.01 | 333 | 9 |
| 0.01 — 0.1 | 333 | 90 |
| 0.1 — 1.0 | 334 | 901 |
线性取样把九成的点浪费在 0.1—1.0(那里相邻两点的差别小到没意义),而最优值可能在的 0.001—0.01 只有 9 个点。
通用原则:跨不跨数量级
| 超参数 | 典型范围 | 怎么搜 |
|---|---|---|
| 学习率 | 1e-5 — 1 | 对数 |
| 正则化系数 | 1e-6 — 1e-1 | 对数 |
| batch size / 隐藏层宽度 | 8 — 4096 | 对数 |
| dropout 比例 | 0.1 — 0.5 | 线性(不跨数量级) |
scaling law 那些论文里的图全是双对数坐标,就是这个原因。
曲线怎么读
扫描代码只是把 lr 换成 lrs[i] 每步取一个,跑 1000 步,0.1 秒。
| 横轴 | 学习率 | 在发生什么 |
|---|---|---|
| −3.0 → −2.0 | 0.001—0.01 | 太小,推不动模型 |
| −2.0 → −1.2 | 0.01—0.06 | 开始有效下降 |
| −1.2 → −0.5 | 0.06—0.32 | 最平坦的一段 |
| −0.5 → 0.0 | 0.32—1.0 | 太大,发散 |
区间 [−3, 0] 是经过设计的:下界再小基本不动,上界再大直接发散。判断区间选对了的标志是左端已经平了、右端已经上翘。
一个点的确切含义
代码里 loss 是在用这个 lr 更新之前算的。所以横轴 −0.5、纵轴 3.0 那个点读作:
走到第 800 步时(前面已经用 10⁻³ 到 10⁻⁰·⁵ 这一路的学习率走过了),模型当前的 loss 是 3.0。
它不是「用 0.32 训练的结果」,而是「用 0.32 这一步之前的累积成果」。 既然 lr 单调递增,横轴同时也是时间轴。
这个方法证明了什么
这是本篇的落点。这条曲线只严格证明了两件事:
一、右端 loss 掉头上涨。 这是唯一干净的因果信号 —— 前面已经降到 3,如果学习率无害,loss 不该涨回去。涨了,就说明这些步在破坏成果。
二、左端那一段几乎没降。 同样步数后面从 12 掉到 3,这一段只从 20 挪到 12,说明那一带推不动模型。
中间那一大段,什么都没证明。 loss 一路下降到谷底 —— 但这完全可以用「训练在推进」来解释。哪怕全程固定 lr = 0.05 跑 1000 步,loss 也会从 20 降到 3 左右,曲线会长得差不多。
「最低点在哪,最优 lr 就是多少」是把相关当成了因果。 三个缺陷:历史依赖(后面的点建立在前面训练的基础上)、每个 lr 只被试了一步、噪声大(每个点只是一个 32 样本 batch)。
所以它的定位是粗筛:
| 结论 | 可靠吗 |
|---|---|
| 量级在 0.1 附近(不是 0.001 也不是 1) | ✓ 很可靠 |
| 上界在 0.3—0.5,越过就发散 | ✓ 上翘是硬信号 |
| 最优值是 0.18 而不是 0.12 | ✗ 说不准 |
扫描前:lr ∈ [0.001, 1](×1000) → 扫描后:[0.01, 0.4](×40)它的价值是排除,不是选优。 排除了太慢的和会炸的,剩下的范围里选哪个都不会错得离谱。而它的优势是便宜:面对一个全新模型,你可能连量级都不知道(0.001?1?100?),0.1 秒就给你答案。
为什么取谷底偏左
不是因为「保守」,而是代价不对称:
| 方向 | 走错了会怎样 |
|---|---|
| 偏左(lr 小了) | 慢一点,loss 照样降,只是费时间 |
| 偏右(lr 大了) | 越过就急剧上翘,直接发散、前功尽弃 |
还有一个理由:扫描是从随机初始化开始测的,那时梯度大、能承受较大的 lr;训练后期参数进入平坦区域,同样的 lr 会显得偏大。
学习率衰减
既然后期嫌大,那就分两段:
| 阶段 | 步数 | 全量 loss | 降幅 |
|---|---|---|---|
| lr = 0.1 | 30000 | 2.4029 | — |
| lr = 0.01 | 10000 | 2.3337 | −0.069 |
对照前面那张表:一直用 0.1 跑第三轮 3 万步,只降 0.03。三分之一的步数,两倍多的降幅。
参数已到谷底,0.1 的步子跨过谷底落到对面山坡、来回横跳;缩小 10 倍才能沉进去。
⚠️ ÷10 是惯例,不是从曲线上读出来的。 曲线上 0.01 那一处 loss 还高达 10,说明从头用 0.01 训练不行;但衰减阶段处境不同(参数已在谷底),曲线回答不了「该缩多少」。
主学习率有方法(扫描),衰减系数靠惯例。
一句话概括这一篇:两次「故意不精确」,收益完全不同。mini-batch 用一个带噪的梯度换来 7130 倍的步数,这笔买卖是干净的;学习率扫描用一次粗糙的采样换来速度,但它买到的只有「排除」,没有「选优」—— 而把它当成选优来用,就是把相关当成了因果。
现在 loss 到了 2.33,已经越过 bigram 的 2.4544。
但这一篇里的每一个 loss 都是在训练集上算的 —— 它只回答「模型记住了多少」。下一篇先做一件更基础的事:把一个数字变成两个。没有那把尺子,后面所有调参都是瞎调。