bobofamilyNIU CHUN
11/36

快 820 倍:粗糙的梯度和一条只能排除的曲线

2026-09-0614 分钟—阅读

上一篇把网络搭完了: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 后
样本数数据集多大228146228146
step / iteration参数更新了几次10030000
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 那节又贴过一次,这里不再重复。直接看数字:

steploss这 20 步降了
019.5052—
208.3292−11.18
405.8031−2.53
604.5874−1.22
803.9281−0.66
1003.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 ms1008.2 秒3.5892
mini-batch(32)0.1 ms300003.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 → 0
lrs = 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.013339
0.01 — 0.133390
0.1 — 1.0334901

线性取样把九成的点浪费在 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 秒。

示意图 —— 形状取自一次真实扫描,纵轴数值只用来读形状。教科书级的 U 形:左高、中低、右上翘。
横轴学习率在发生什么
−3.0 → −2.00.001—0.01太小,推不动模型
−2.0 → −1.20.01—0.06开始有效下降
−1.2 → −0.50.06—0.32最平坦的一段
−0.5 → 0.00.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.1300002.4029—
lr = 0.01100002.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 都是在训练集上算的 —— 它只回答「模型记住了多少」。下一篇先做一件更基础的事:把一个数字变成两个。没有那把尺子,后面所有调参都是瞎调。