python神经网络编程入门(三十九)——训练细节:标签平滑、AdamW 与学习率调度
📍 路标:本篇位于《从零构建 Transformer》系列 第 10/16 章 · 架构篇(收官)。
系列主线:注意力思想 → 自注意力 → 多头 → 位置编码 → 编解码架构 → 手撕实现 → 预训练模型 → 实战微调 → 知识收官。
进度:▸ 基石篇(1-5) ▸ 架构篇(6-10·本篇) ▸ 预训练篇(11-13) ▸ 实战篇(14-16)
摘要:第 9 章我们把 Transformer 这台"发动机"装好并通过了全部自检——但就像新电脑没有操作系统,机器只会"均匀地说废话"。这一章装操作系统,而且一装就是三件套:标签平滑(Label Smoothing)、AdamW 优化器、warmup 学习率调度。文章不满足于"记住三个名词",而是把每一件都拆到数学和数字里:标签平滑为什么能把"最优 logits 从 +∞ 拽回到有界的 4.69"?Adam 的权重衰减为什么会被自适应缩放"吃掉"、AdamW 的解耦衰减为什么能与理论值 0.5×0.99ᵗ 逐位重合?warmup 为什么能让同一个起点从"发散"变成"收敛"?最后把三件套组装起来,用第 9 章的特征 + 数值梯度,让一台"只会说废话"的机器 25 步学会点名。
上一篇(三十八):从零实现 Transformer 前向传播
引言:发动机装好了,操作系统呢?
上一章结尾我们打了个比方:第 9 章的 Transformer 就像一台刚装好的新电脑——主板、内存、显卡、电源全部到位,开机自检全绿,但里面没有操作系统。所以它只会做一件事:对着任何输入,吐出一份"接近均匀分布"的废话(最大概率只有 0.1434,和抛骰子没区别)。
要让这台机器真正"学会说话",我们得给它装三样东西,而这三样东西恰恰是几乎所有深度模型训练的标准配置:
- 一个"会批评"的损失函数。机器说的每一句话都要被打分,分数低才知道改。但"批评方式"有讲究:如果标签是铁板一块的 one-hot(对的就是 1、错的全是 0),模型会被逼到"过度自信"的绝路——logits 越来越大、越来越尖,最后泛化变差。怎么批评才能既严格又宽容?这就是标签平滑要解决的问题;
- 一个"会惩罚"的优化器。训练时我们希望权重"够用就好",别把训练集的细节死记硬背下来(过拟合)。惩罚手段叫权重衰减——每步都把权重往零的方向缩一点。但同样叫"衰减",缩的方式不同,效果天差地别,这就是 Adam 与 AdamW 之争;
- 一个"懂节奏"的学习率调度。训练一开始,参数全是随机数,梯度方向噪声极大——这时候用大学习率等于刚学车就上高速,直接翻车。正确做法是先"热身"(warmup):学习率从小往大爬,稳住了再冲刺。这就是学习率调度。
这三个词,你在任何一篇 Transformer 论文的"实验设置"里都能看到,但几乎没人把它们的"为什么"讲透。这一章我们就用最老实的办法:每一件都手写、跑数字、画曲线、算数学——让"为什么必须这样做"从数字里自己浮出来。
另外预告一下本章的"实验风格":第 9 章我们验证的是"机器装得对不对"(形状、恒等式),这一章要验证的是"机器学得好不好"(loss 降不降、概率尖不尖)。前者靠数学恒等,后者靠数值实验——你会发现"跑数字"是判断训练技巧最可靠的方式,任何"听起来很有道理"的说法,都过不了真实输出这一关。
🎯 本章目标
- 说清一次训练迭代的四步循环,以及三件套各嵌在哪一步(图 1);
- 手写标签平滑:one-hot → soft 标签,验证"总和仍为 1"(demo1);
- 手写平滑交叉熵,对比同一份 logits 在平滑前后的 loss 数值(demo2);
- 推导并验证"平滑 CE 的最优 logits 有界":a*=ln(109)≈4.69 处梯度恰为 0,而普通 CE 的梯度永远为负、logits 必然冲向 +∞(demo3);
- 手写 Adam 与 AdamW 更新式,用"零梯度纯衰减"案例实锤:Adam 的衰减被自适应缩放成常数步长(过冲振荡),AdamW 的衰减与理论 0.5×0.99ᵗ 逐位重合(demo4);
- 手写 Noam 学习率调度,打印每个 step 的学习率数值,画出"爬升→峰值→衰减"曲线(demo5);
- 用同一起点对比"固定大学习率(发散)vs warmup(收敛)"(demo6);
- 三件套合体:数值梯度 + 平滑 CE + AdamW + warmup,让输出层 25 步学会"点名",loss 从 2.43 降到 0.65(demo7)。
一、先看全景:一次训练迭代的四步循环
训练的本质,是把"前向传播"放进一个循环里反复执行。先看这一章的"总装图"——训练循环:

图 1 的四步,每一步都有明确任务:
- ① 前向:把一批 (src_ids, tgt_ids) 喂进第 9 章那台机器,得到每个位置的概率分布;
- ② 算 loss:拿概率分布和"正确答案"比较,算出一个数——loss 越小,说明机器说得越对。标签平滑就改这一句:把"正确答案"从 one-hot 换成 soft 标签;
- ③ 反向求梯度:计算 loss 对每一个参数的偏导。告诉机器"想降低 loss,每个参数该往哪个方向挪"。本章我们用数值梯度(有限差分)来做这件事,理由第五节讲;
- ④ 更新参数:按梯度方向挪动每个参数。AdamW 管"怎么挪"(挪多快、要不要顺带衰减),warmup 调度管"每次挪的步长 lr 是多少"。
四步转一圈叫一个"迭代"(iteration),转几百上千圈就叫训练。下面我们一件一件拆。
顺带认识两个马上要用的词:batch 和 epoch。 每次迭代喂进去的那"一批"数据叫一个 batch(批),一批有多少个句子叫 batch size(批大小,我们一直用 2);把整个训练集完整过一遍叫一个 epoch(轮)。举个例子:训练集有 1000 个句子、batch size=2,那么一个 epoch = 1000/2 = 500 次迭代。学习率调度的横坐标"step"指的就是迭代次数——warmup=10 意味着"前 10 次迭代学习率爬升",跟"几轮"没关系,别搞混。
二、第一件套:标签平滑——别把"正确答案"说得太满
2.1 先复习交叉熵:one-hot 是一位"严苛到不讲道理"的老师
损失函数的选择,本质是选择"老师怎么批评学生"。Transformer 用的标准损失是交叉熵(Cross Entropy)。对位置 t,模型输出概率分布 q q q,正确答案是 one-hot 向量 p p p(目标词处为 1,其余为 0),交叉熵是:
CE ( p , q ) = − ∑ j p j log q j = − log q target \text{CE}(p,q) = -\sum_j p_j \log q_j = -\log q_{\text{target}} CE(p,q)=−j∑pjlogqj=−logqtarget
看最后那个等号:因为 one-hot 只有目标词那一位是 1,所以交叉熵化简成了只看目标词位置的 log 概率—— CE = − log q t a r g e t \text{CE}=-\log q_{target} CE=−logqtarget。这带来一个副作用:为了让 loss 趋近 0,模型必须让 q t a r g e t → 1 q_{target}\to 1 qtarget→1,也就是让目标词位置的 softmax 概率无限逼近 1,其他位置无限逼近 0。而 softmax 概率要逼近 1,靠的是 logits 无限拉大——模型被逼着把 logits 推向 ±∞。
这在数学上会产生什么后果?我们在 2.5 节用数字证明给你看:普通交叉熵的最优解是"logits 无界",模型会越来越"自信",而过度自信的模型在测试集上往往泛化更差(它把训练集里的噪声也当成真理背下来了)。
为什么分类任务偏爱交叉熵而不是均方误差(MSE)? 一个值得顺手搞清楚的问题。MSE 对概率做惩罚: 1 2 ∥ p − q ∥ 2 \frac{1}{2}\|p-q\|^2 21∥p−q∥2,它有两个硬伤:①MSE 的梯度是 ( q − p ) ⋅ q ( 1 − q ) (q-p)\cdot q(1-q) (q−p)⋅q(1−q)(对 softmax 输出求导),当模型完全错误(q→0)或完全正确(q→1)时, q ( 1 − q ) → 0 q(1-q)\to0 q(1−q)→0——越需要学习的地方梯度越小,这就是"梯度消失";②MSE 假设误差是高斯分布,而分类本质是多项分布,用交叉熵(负对数似然)才是概率论上的"正确距离"。交叉熵的梯度是 q − p q-p q−p,错误时梯度最大、正确时归零——批评力度和错误程度成正比,这才是老师该有的样子。标签平滑改的只是"批评标准",不换"批评工具"。
2.2 软化标签:一个 one-hot 变成"一个大 + 一堆小"
标签平滑的做法朴素到令人意外:把 one-hot 标签的"1"匀一点出来,分给所有其他词。公式:
soft_target j = { 1 − ε + ε / V j = target ε / V j ≠ target \text{soft\_target}_j = \begin{cases} 1-\varepsilon+\varepsilon/V & j = \text{target} \\ \varepsilon/V & j \neq \text{target} \end{cases} soft_targetj={1−ε+ε/Vε/Vj=targetj=target
其中 V 是词表大小,ε 是平滑系数(Transformer 论文用 0.1)。用 NumPy 写就是三行:
import numpy as np
def soft_onehot(ids, V, eps=0.1):
n = len(ids)
soft = np.full((n, V), eps / V) # 先给每个位置都发一份 eps/V
soft[np.arange(n), ids] += (1 - eps) # 目标词位置再额外加 1-eps
return soft
跑一遍,直接看 soft 标签长什么样(V=12,ε=0.1,目标词 id=2):
V, eps = 12, 0.1
one = np.zeros(V); one[2] = 1.0
soft = soft_onehot(np.array([2]), V, eps)[0]
print('one-hot(目标词 id=2):', np.round(one, 4).tolist())
print('soft 标签 (eps=0.1) :', np.round(soft, 4).tolist())
print('soft 标签的和 :', round(float(soft.sum()), 4))
print('id=2 处: %.4f = %.4f + %.4f(1-eps + eps/V)' % (soft[2], 1 - eps, eps / V))
真实输出:
one-hot(目标词 id=2): [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
soft 标签 (eps=0.1) : [0.0083, 0.0083, 0.9083, 0.0083, 0.0083, 0.0083, 0.0083, 0.0083, 0.0083, 0.0083, 0.0083, 0.0083]
soft 标签的和 : 1.0 (概率分布,和为 1)
id=2 处: 0.9083 = 0.9000 + 0.0083(1-eps + eps/V)
其它处: 0.0083 = eps/V = 0.0083
对比一目了然:one-hot 是"1 根柱子 + 11 个 0";soft 标签是"1 根大柱子 0.9083 + 11 根小柱子 0.0083",但总和依然是 1——它仍然是一个合法的概率分布。画成柱状图:

💡 标签平滑的本质:它给模型留了一条"后路"——正确答案不是唯一的。即使模型把 0.0083 的概率分给别的词,loss 也只是轻微上升,而不是"万劫不复"(log 0 = 负无穷)。模型因此不必把 logits 推到无穷大去"死磕"那一个词。
2.3 平滑交叉熵:损失函数怎么写
有了 soft 标签,交叉熵就变成"两个部分加权":
SmoothCE ( q ) = ( 1 − ε ) ⋅ CE ( p onehot , q ) + ε ⋅ CE ( p uniform , q ) \text{SmoothCE}(q) = (1-\varepsilon)\cdot\text{CE}(p_{\text{onehot}}, q) + \varepsilon\cdot\text{CE}(p_{\text{uniform}}, q) SmoothCE(q)=(1−ε)⋅CE(ponehot,q)+ε⋅CE(puniform,q)
第一部分照旧盯目标词;第二部分多了一个"均匀分布老师"——它要求模型不要把所有概率都押在目标词上,也给别的词留一点面子。等价地,可以直接把 soft 标签代入交叉熵定义:
def logsumexp(x, axis=-1):
m = x.max(axis=axis, keepdims=True)
return m + np.log(np.exp(x - m).sum(axis=axis, keepdims=True))
def smoothed_ce(logits, soft_target):
logp = logits - logsumexp(logits) # log-softmax
return -(soft_target * logp).sum(axis=-1) # 与 soft 标签做交叉熵
一个小问题:ε/V 为什么要给每个位置(包括目标词自己)都发一份? 有人会问:给目标词发 ε/V 不是"稀释了正确答案"吗?注意公式里目标词拿的是 1 − ε + ε / V 1-\varepsilon+\varepsilon/V 1−ε+ε/V,其中 1 − ε 1-\varepsilon 1−ε 才是"从 1 里匀走的"部分,而 ε/V 是"从均匀分布里平均分配"的部分——soft 标签的数学本质是**“one-hot 的 (1−ε) 缩放 + 均匀分布的 ε 混合”**(即 p soft = ( 1 − ε ) p onehot + ε u p_{\text{soft}} = (1-\varepsilon)p_{\text{onehot}} + \varepsilon u psoft=(1−ε)ponehot+εu)。这样写的好处是:总和 = (1−ε)·1 + ε·1 = 1,天然是合法概率分布,代码只需要一个 np.full 加一次 +=,三行搞定。如果非要"只分给非目标词",得写条件分支,结果一样但代码丑——而且那份 ε/V 分给目标词并不会"错",它只是让目标词从 (1−ε) 变成 (1−ε+ε/V),占比依然远大于其他词。
2.4 同一份 logits,平滑前后 loss 差多少
空口无凭。取一份具体的 logits,把平滑前后的 loss 都算出来(V=3 简化,目标词 id=0):
logits_demo = np.array([[2.0, 1.0, 0.0]])
q = softmax_rows(logits_demo)[0]
plain = float(-np.log(q[0]))
sm = float(smoothed_ce(logits_demo, soft_onehot(np.array([0]), 3, 0.1)))
print('logits = [2.0, 1.0, 0.0],目标词 id=0,q =', np.round(q, 4).tolist())
print('普通交叉熵 loss : %.4f' % plain)
print('平滑交叉熵 loss : %.4f' % sm)
print('平滑比普通多了 : %.4f' % (sm - plain))
真实输出:
logits = [2.0, 1.0, 0.0],目标词 id=0,q = [0.6652, 0.2447, 0.09]
普通交叉熵 loss : 0.4076 (只盯着 -log q_0)
平滑交叉熵 loss : 0.5076 (还要罚其余词的 log 概率)
平滑比普通多了 : 0.1000(多出来的部分 = ε·KL(均匀分布‖q))
注意一个漂亮的巧合:平滑比普通多了整整 0.1000。这不是随机数,而是数学必然——平滑 CE 可以改写成 CE ( p , q ) + ε ⋅ KL ( u ∥ q ) \text{CE}(p,q) + \varepsilon\cdot\text{KL}(u\Vert q) CE(p,q)+ε⋅KL(u∥q)(u 是均匀分布),多出来的部分正好是 ε 倍的 KL 散度。普通 CE 只要求"把目标词说对",平滑 CE 额外要求"别把其他词说死",那 0.1 的差价就是"宽容的代价"。
2.5 最硬核的验证:平滑 CE 的最优 logits 是"有界"的
现在回答引言里那个问题:为什么 one-hot 会把模型逼向"过度自信"?我们用数学把话说死。
设目标词位置的 logits 为 a a a,其他位置 logits 全为 0(V=12,ε=0.1)。softmax 后目标词概率 q k = e a e a + V − 1 q_k = \dfrac{e^a}{e^a + V - 1} qk=ea+V−1ea。分别对普通 CE 和平滑 CE 求关于 a a a 的梯度(推导放在下面代码注释里):
- 普通 CE: d L d a = q k − 1 \dfrac{dL}{da} = q_k - 1 dadL=qk−1。因为 q k < 1 q_k<1 qk<1,梯度永远是负数——无论 a a a 多大,梯度都在说"再大一点、再大一点",直到 a = + ∞ a=+\infty a=+∞ 才罢休。这就是"无界";
- 平滑 CE: d L d a = q k − ( 1 − ε ) − ε V \dfrac{dL}{da} = q_k - (1-\varepsilon) - \dfrac{\varepsilon}{V} dadL=qk−(1−ε)−Vε。令它等于 0,解出 KaTeX parse error: Undefined control sequence: \* at position 5: q_k^\̲*̲ = 1-\varepsilo…,换算回 logits:KaTeX parse error: Undefined control sequence: \* at position 3: a^\̲*̲ = \ln\Big(1 + …。代入 V=12、ε=0.1:KaTeX parse error: Undefined control sequence: \* at position 3: a^\̲*̲ = \ln(109) \ap…——logits 被拽回到有限值,不再膨胀。
代码验证(梯度用解析式直接算,表格里同时给 loss 与梯度):
V3, eps3 = 12, 0.1
def qk_of_a(a):
ea = np.exp(a); return ea / (ea + V3 - 1)
def grad_sm(a): return qk_of_a(a) - (1 - eps3) - eps3 / V3
def grad_plain(a): return qk_of_a(a) - 1.0
def loss_sm(a):
qk = qk_of_a(a); qj = (1 - qk) / (V3 - 1)
return (1 - eps3) * (-np.log(qk)) + eps3 * (-(1.0 / V3) * (np.log(qk) + (V3 - 1) * np.log(qj)))
def loss_plain(a): return -np.log(qk_of_a(a))
a_star = np.log(1 + V3 * (1 - eps3) / eps3)
print('理论最优 a* = ln(109) = %.4f' % a_star)
print('在 a* 处:平滑 CE 梯度 = %.6f,普通 CE 梯度 = %.6f' % (grad_sm(a_star), grad_plain(a_star)))
print('a 取不同值时 loss 对比:')
print(' %8s %12s %12s %10s %10s' % ('a', '平滑loss', '普通loss', '平滑梯度', '普通梯度'))
for a in [0.0, 2.0, a_star, 6.0, 8.0, 10.0]:
print(' %8.4f %12.4f %12.4f %10.6f %10.6f' % (a, loss_sm(a), loss_plain(a), grad_sm(a), grad_plain(a)))
真实输出:
理论最优 a* = ln(1+V(1-ε)/ε) = ln(109) = 4.6913
在 a*=4.6913 处:
平滑 CE 梯度 dL/da = -0.000000 (≈0,达到最优)
普通 CE 梯度 dL/da = -0.091667 (仍为负 → 还想让 a 更大 → 无界)
a 取不同值时 loss 对比(V=12, ε=0.1):
a 平滑loss 普通loss 平滑梯度 普通梯度
0.0000 2.4849 2.4849 -0.825000 -0.916667
2.0000 1.0951 0.9118 -0.506515 -0.598182
4.6913 0.5262 0.0961 -0.000000 -0.091667
6.0000 0.5769 0.0269 0.065124 -0.026543
8.0000 0.7370 0.0037 0.087990 -0.003677
10.0000 0.9172 0.0005 0.091168 -0.000499
结论:平滑 CE 在 a≈4.69 处停止"加码"(梯度=0),普通 CE 永远想把 logits 推向 +∞
这张表信息量极大,逐列读:
- 平滑 loss 列:先降后升——a=0 时 2.4849,a=4.6913 时降到最低 0.5262,a=6 开始回升(0.5769、0.7370、0.9172)。说明平滑 CE 有一个最小值,过度自信(a 太大)反而受罚;
- 普通 loss 列:一路走低(2.4849 → 0.0005)——普通 CE 眼中没有"太自信"这回事,a 越大越"好";
- 平滑梯度列:在 a*=4.6913 处精确为 -0.000000(机器精度下的 0),a=6 时已变正(+0.065,说明该回头了)——梯度在"拉" logits 停在有限值;
- 普通梯度列:永远为负(-0.916667 → -0.000499),注意 a=10 时普通梯度仍为 -0.000499,虽然小,但方向永远朝 +∞。
把两列 loss 画成曲线,直观得不能再直观:

🎤 一句话总结标签平滑:把 one-hot 换成"一个大 + 一堆小"的 soft 标签,损失函数就从"只要求说对目标词"变成"在说对目标词的同时别把其他词说死";数学上它把"最优 logits = +∞"改写成"最优 logits = 有限值",模型不再被逼着过度自信。这一套全部由数字验证,不是玄学。
2.6 平滑系数 ε 怎么选?
Transformer 原文用 ε=0.1,但不是所有任务都该用 0.1。ε 越小越接近 one-hot(模型越自信),ε 越大越"和稀泥"(模型越不敢下结论)。经验法则:数据集大、任务难(如机器翻译)常用 0.1;数据集小、任务简单(如小规模分类)可以降到 0.05 甚至 0.01,避免把"正确答案"稀释太多。
还有一个直观的定量观察:KaTeX parse error: Undefined control sequence: \* at position 3: a^\̲*̲ = \ln\big(1 + …,ε 变小时 a* 变大——模型被允许更自信;ε→0 时 a*→+∞,正好退化回普通 CE 的无界情形。用本章 demo3 的代码把 ε 换成 0.05 重跑一遍,你会看到最优 a* 从 4.69 变成 ln ( 1 + 12 × 0.95 0.05 ) = ln ( 229 ) ≈ 5.43 \ln(1 + \frac{12\times0.95}{0.05})=\ln(229)\approx5.43 ln(1+0.0512×0.95)=ln(229)≈5.43——模型被允许把 logits 推得更远。这个"自己动手改一个数、看结论跟着变"的过程,就是把公式真正吃进肚子里的过程。
三、第二件套:AdamW——把权重衰减从"梯度里"解耦出来
3.1 先回顾 Adam:四行公式管住"走多远"
优化器的任务是回答:“每个参数该往哪个方向、走多远?“梯度告诉方向,Adam 决定距离。Adam 维护两个累计量:一阶矩 m m m(动量,记住"一直往哪走”)和二阶矩 v v v(记住"这个参数梯度有多大”),再对前几步做偏差校正(bias correction),最后更新:
m t = β 1 m t − 1 + ( 1 − β 1 ) g t , v t = β 2 v t − 1 + ( 1 − β 2 ) g t 2 m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t,\qquad v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2 mt=β1mt−1+(1−β1)gt,vt=β2vt−1+(1−β2)gt2
m ^ t = m t 1 − β 1 t , v ^ t = v t 1 − β 2 t , w t = w t − 1 − lr ⋅ m ^ t v ^ t + ε \hat m_t = \frac{m_t}{1-\beta_1^t},\qquad \hat v_t = \frac{v_t}{1-\beta_2^t},\qquad w_t = w_{t-1} - \text{lr}\cdot\frac{\hat m_t}{\sqrt{\hat v_t}+\varepsilon} m^t=1−β1tmt,v^t=1−β2tvt,wt=wt−1−lr⋅v^t+εm^t
直觉: m ^ t \hat m_t m^t 是"动量方向", v ^ t \sqrt{\hat v_t} v^t 是"该参数历史梯度的大小"——梯度大就少走点(除以它),梯度小就多走点。这就是"自适应学习率"。
3.2 问题:把权重衰减混进梯度,会被自适应缩放"吃掉"
过拟合的常规解法是 L2 正则:在损失里加 λ 2 ∥ w ∥ 2 \frac{\lambda}{2}\|w\|^2 2λ∥w∥2,等价于梯度里加 λ w \lambda w λw,让权重每步都往 0 缩一点。Adam 时代这个做法出了问题——看更新式:梯度里的 λ w \lambda w λw 要和真正的梯度一起除以 v ^ t \sqrt{\hat v_t} v^t。当某个权重有强梯度时, v ^ t \sqrt{\hat v_t} v^t 很大, λ w \lambda w λw 被除得微乎其微——衰减在"梯度大的地方"几乎失效。更糟的是在梯度趋近 0 的地方, λ w \lambda w λw 被自适应缩放成"每步走固定长度"(因为 m ^ t / v ^ t → ± 1 \hat m_t/\sqrt{\hat v_t} \to \pm 1 m^t/v^t→±1),权重会以固定步长来回乱跳。
AdamW 的修正极简:把衰减从"梯度里"挪到"权重上",更新分成两步:
w t = w t − 1 ⋅ ( 1 − lr ⋅ λ ) (先衰减:与梯度无关,每步缩到 (1-lrλ) 倍) w_t = w_{t-1}\cdot(1 - \text{lr}\cdot\lambda) \qquad\text{(先衰减:与梯度无关,每步缩到 (1-lrλ) 倍)} wt=wt−1⋅(1−lr⋅λ)(先衰减:与梯度无关,每步缩到 (1-lrλ) 倍)
w t = w t − lr ⋅ m ^ t v ^ t + ε (再做自适应更新) w_t = w_t - \text{lr}\cdot\frac{\hat m_t}{\sqrt{\hat v_t}+\varepsilon} \qquad\text{(再做自适应更新)} wt=wt−lr⋅v^t+εm^t(再做自适应更新)
因为衰减直接乘在权重上、不经过 v ^ t \sqrt{\hat v_t} v^t 缩放,所以叫"解耦"(decoupled)。两个版本的公式对比:

3.3 实证:零梯度纯衰减——Adam 过冲,AdamW 与理论重合
“被吃掉"和"解耦"听起来都太抽象,我们构造一个极端干净的实验:目标函数 f ( w ) = 0 f(w)=0 f(w)=0(梯度恒为 0),初始 w 0 = 0.5 w_0=0.5 w0=0.5,lr=0.1,λ=0.1,跑 30 步。此时只有权重衰减在工作——真正的梯度一点都没有,任何观察到的现象都 100% 来自"衰减机制本身”。
理论上:AdamW 每步把权重乘以 ( 1 − lr λ ) = 0.99 (1-\text{lr}\lambda) = 0.99 (1−lrλ)=0.99,所以 w t = 0.5 × 0.99 t w_t = 0.5\times 0.99^t wt=0.5×0.99t,这是可以手算的解析值。Adam 呢?衰减项 λ w \lambda w λw 被当成梯度,先累计进 m、v,再除以 v ^ t \sqrt{\hat v_t} v^t——我们会看到它变成"每步走固定步长"的振荡器。写代码:
def adam_update(w, g, m, v, t, lr=0.1, b1=0.9, b2=0.999, eps=1e-8, wd=0.1, decoupled=False):
if decoupled:
g_eff = g
else:
g_eff = g + wd * w # Adam:衰减混进梯度
m = b1 * m + (1 - b1) * g_eff
v = b2 * v + (1 - b2) * g_eff ** 2
mh = m / (1 - b1 ** t)
vh = v / (1 - b2 ** t)
if decoupled:
w = w * (1 - lr * wd) - lr * mh / (np.sqrt(vh) + eps) # AdamW:先衰减
else:
w = w - lr * mh / (np.sqrt(vh) + eps)
return w, m, v
def run(w0=0.5, steps=30, decoupled=False):
w = w0; m = 0.0; v = 0.0
trace = [w]
for t in range(1, steps + 1):
g = 0.0 # 零梯度:只有衰减在工作
w, m, v = adam_update(w, g, m, v, t, decoupled=decoupled)
if t % 5 == 0:
trace.append(w)
return trace
真实输出:
f(w)=0(梯度恒 0),w0=0.5,lr=0.1,权重衰减 λ=0.1,跑 30 步:
步数 Adam(L2在梯度) AdamW(解耦) AdamW理论值 0.5*0.99^t
0 0.500000 0.500000 0.500000
5 0.027815 0.475495 0.475495
10 -0.203323 0.452191 0.452191
15 -0.114221 0.430029 0.430029
20 0.053188 0.408953 0.408953
25 0.099872 0.388911 0.388911
30 0.020995 0.369850 0.369850
说明:Adam 把 λw 当成梯度 → 被自适应缩放成"每步走固定 ~lr" → 权重过冲一路下滑;
AdamW 的 w*(1-lr*λ) 是干净的几何衰减,与梯度大小无关,符合理论值 0.5*0.99^t
把这三列画成曲线,结论一眼就懂:

🎤 一句话总结 AdamW:权重衰减放进梯度里,会被 Adam 的自适应缩放"吃掉"甚至扭曲成常数步长;AdamW 把衰减直接乘在权重上(w·(1−lr·λ)),每步都干净地缩一点,与梯度大小无关。30 步实验里 AdamW 与手算理论值逐位重合,Adam 则在零梯度下自己把自己颠出了 0.5 的轨道——这就是"解耦"的意义。
3.4 两个 β、一个 ε:Adam 的超参数到底在管什么
Adam 公式里三个"魔数":β₁=0.9、β₂=0.999、ε=1e-8,它们不是拍脑袋定的:
- β₁=0.9 管"方向平滑":m 是梯度的指数滑动平均,0.9 表示"新梯度只占 10% 权重",相当于把最近约 10 步的梯度方向做平均,抵消噪声;
- β₂=0.999 管"尺度记忆":v 是梯度平方的滑动平均,0.999 让模型对"这个参数历史上梯度多大"的记忆更长(约 1000 步),缩放更稳定;
- ε=1e-8 是"防除零":√v̂ 可能非常接近 0(某些参数长期梯度为 0),加上 ε 避免除零爆炸;
- bias correction 为什么必要? 看 t=1 时刻: m 1 = 0.9 × 0 + 0.1 g = 0.1 g m_1 = 0.9\times0 + 0.1g = 0.1g m1=0.9×0+0.1g=0.1g,只有真实值的 1/10,直接拿去用会把第一步更新缩小 10 倍;除以 1 − β 1 1 = 0.1 1-\beta_1^1 = 0.1 1−β11=0.1 才把它还原成 g。t 越大, 1 − β t 1-\beta^t 1−βt 越接近 1,校正的作用越小——它只在训练初期起作用。
一句话:β₁ 管方向平滑,β₂ 管尺度记忆,ε 兜底除零,bias correction 修早期偏差。这四个细节在 demo4 的更新式里全部出现,现在它们不再是"神秘魔法数字"了。
四、第三件套:学习率调度——先"热身",再"冲刺"
4.1 为什么训练初期不能大步走?
训练一开始,参数全是随机数,模型对数据一无所知,此时计算出的梯度噪声极大——不同批次的梯度方向可能互相打架。如果这时用大步长(大学习率),参数会被噪声推着满场乱跑,轻则震荡不收敛,重则直接发散(loss 变成 NaN)。等训练跑了几步,参数渐渐"进入状态",梯度方向稳定了,才敢放开步长。所以业界标准做法是 warmup(热身):前若干步用很小的学习率,让模型"先站稳",再逐步加码。
4.2 Noam 调度:两段式的优雅公式
Transformer 论文(Attention Is All You Need)用的学习率调度叫 Noam schedule,一条公式管两段:
lr ( step ) = d model − 0.5 ⋅ min ( step − 0.5 , step ⋅ warmup − 1.5 ) \text{lr}(\text{step}) = d_{\text{model}}^{-0.5} \cdot \min\big(\text{step}^{-0.5},\; \text{step}\cdot\text{warmup}^{-1.5}\big) lr(step)=dmodel−0.5⋅min(step−0.5,step⋅warmup−1.5)
拆开看:括号里取 min 的两项,第一项 step − 0.5 \text{step}^{-0.5} step−0.5 随 step 增大而减小(衰减段),第二项 step ⋅ warmup − 1.5 \text{step}\cdot\text{warmup}^{-1.5} step⋅warmup−1.5 随 step 线性增大(上升段)。step 小的时候第二项小(取它,线性爬升);step 大的时候第一项小(取它,幂次衰减)。两条线的交点恰好是 step = warmup——也就是学习率的峰值点。前面乘的 d model − 0.5 d_{\text{model}}^{-0.5} dmodel−0.5 只是个与模型维度有关的缩放系数,把曲线整体拉到合适的高度。
手写这个公式只要一行,打印数值表(d_model=8,warmup=10):
def noam_lr(step, d_model, warmup):
return d_model ** (-0.5) * min(step ** (-0.5), step * warmup ** (-1.5))
print(' %5s %12s' % ('step', 'lr'))
for s in [1, 2, 3, 5, 8, 10, 12, 15, 20, 30, 50, 80]:
print(' %5d %12.6f' % (s, noam_lr(s, 8, 10)))
print('峰值出现在 step=warmup=10 处:%.4f' % noam_lr(10, 8, 10))
真实输出:
step lr
1 0.011180
2 0.022361
3 0.033541
5 0.055902
8 0.089443
10 0.111803
12 0.102062
15 0.091287
20 0.079057
30 0.064550
50 0.050000
80 0.039528
峰值出现在 step=warmup=10 处:0.1118
看数字的节奏:前 10 步学习率几乎线性翻倍(0.011 → 0.112,每步约 +0.011),第 10 步到达峰值 0.1118,之后一路衰减(0.102、0.091、0.079、0.050、0.039)——训练后期模型已经收敛到谷底附近,学习率要调小才能"精雕细琢"。画成曲线,两段式结构一目了然:

4.3 实战对比:同一个起点,固定大学习率发散,warmup 收敛
光看曲线还不够,得让数字证明"warmup 到底救了多少"。做一个极端实验:目标 f ( w ) = 0.5 w 2 f(w)=0.5w^2 f(w)=0.5w2(最优解 w*=0),起点 w 0 = 3 w_0=3 w0=3。
- 方案 A:固定 lr=2.1——这是个"偏大"的学习率(对 f=0.5w² 来说,lr≥2 就不收敛);
- 方案 B:warmup——前几步用 Noam 调度的小学习率(0.011、0.022、0.034…)慢慢走。
w_fix = 3.0; w_warm = 3.0
print(' %5s %14s %14s' % ('step', '固定lr=2.1', 'warmup 渐增lr'))
for s in range(1, 9):
w_fix = w_fix - 2.1 * w_fix # 固定大步
w_warm = w_warm - noam_lr(s, 8, 10) * w_warm # 小步渐增
print(' %5d %14.4f %14.4f' % (s, w_fix, w_warm))
真实输出:
step 固定lr=2.1 warmup 渐增lr
1 -3.3000 2.9665
2 3.6300 2.9001
3 -3.9930 2.8029
4 4.3923 2.6775
5 -4.8315 2.5278
6 5.3147 2.3583
7 -5.8462 2.1737
8 6.4308 1.9793
左列(固定 lr=2.1):-3.3 → 3.63 → -3.99 → 4.39 → -4.83 → 5.31 → -5.85 → 6.43——每一步都越过原点、越跳越远,|w| 一路放大,这是发散的典型特征;右列(warmup):2.97 → 2.90 → 2.80 → 2.68 → 2.53 → 2.36 → 2.17 → 1.98——单调、稳定地朝 0 逼近。同一个起点、同一个目标函数,只因为"起步的步子大小"不同,一个翻车一个安全。画出来:

🎤 一句话总结 warmup:训练初期梯度噪声大,大学习率会直接把参数推出发散的轨道;Noam 调度用"前 warmup 步线性爬升 + 之后幂次衰减"让模型先站稳、再放开、最后精修。数字对比给出铁证:同一起点,固定 lr=2.1 发散,warmup 收敛。
4.4 其他调度器速览(以及为什么 Transformer 用 Noam)
学习率调度不止 Noam 一种,常见的还有:Cosine 衰减(lr 按余弦曲线从峰值平滑降到 0,HuggingFace transformers 库的默认选择之一)、Step 衰减(每训练 N 轮把 lr 乘一个小于 1 的系数)、ReduceLROnPlateau(验证集 loss 不降了就自动减半)。Noam 的特别之处在于它自带爬升——很多调度器假设你另外配一个独立的 warmup 阶段,而 Noam 把 warmup 和衰减写进同一条公式,参数只有 warmup 一个,省心。对本系列记住一句话:warmup 是必选项(防起步翻车),衰减曲线的形状是风格问题(Noam/Cosine 皆可)——第 14 章在真实训练里我们还会再见到它们。
4.5 调度只在训练期:推理时学习率根本不存在。 一个常被忽略的细节:学习率调度的作用对象是"参数的更新步长",而推理(inference)阶段不做任何参数更新——模型权重已经固定,直接前向、取概率、选词,整个过程不涉及 lr。所以第 9 章那台机器在推理时根本不知道 warmup 为何物;学习率调度、优化器、梯度这些概念全部属于训练期。这个区分也解释了为什么"训练能并行、推理必须串行"(第 8 章)和"训练用 teacher forcing、推理用自回归"(第 8 章)这类问题总被放在一起考——训练期和推理期是两套完全不同的流程,别混为一谈。
五、三件套合体:让第 9 章的机器真正"学会点名"
5.1 为什么敢用数值梯度?
你可能注意到,本章从头到尾没有写反向传播(backprop)。原因有二:一是系列定位——第 1-10 章是 NumPy 手撕阶段,反向传播的链式法则我们在 RNN 系列已经手撕过,这里不重复;二是本章主角是"训练技巧",不是"梯度怎么算"。所以我们用最简单的数值梯度(有限差分):对每个参数 p i p_i pi,微扰 δ \delta δ,用 L ( p + δ e i ) − L ( p − δ e i ) 2 δ \dfrac{L(p+\delta e_i) - L(p-\delta e_i)}{2\delta} 2δL(p+δei)−L(p−δei) 近似偏导。它慢(每个参数要两次前向),但实现零错误率、和解析梯度数学上等价——对本章这种"参数只有几百个、前向极快"的教学场景,刚刚好。
5.2 教学任务:让输出层"学会点名"
我们把第 9 章那台 mini Transformer 的 decoder 输出当作固定特征 y_feat(形状 (2,4,8)),训练对象只有输出层 W_out(8×12) + b_out(12)——共 120 个参数。任务:4 个位置各认领一个目标词(位置 0→词 5,位置 1→词 1,位置 2→词 2,位置 3→词 3)。初始时机器对所有词的概率都接近均匀(“废话”),训练后每个位置的概率应该集中到自己的目标词(“点名”)。训练配置:平滑 CE(ε=0.1)+ AdamW(λ=0.01)+ Noam warmup(warmup=5)。
rng = np.random.RandomState(0)
y_feat = rng.randn(2, 4, 8) * 0.5 # 固定特征(第9章 decoder 输出风格)
target_ids = np.array([5, 1, 2, 3, 4, 5, 6, 7]) # 8 个位置各自的目标词
soft_tgt = soft_onehot(target_ids, 12, 0.1).reshape(2, 4, 12)
def loss_of(W, b):
logits = y_feat @ W + b
return float(smoothed_ce(logits, soft_tgt).mean())
W = rng.randn(8, 12) * 0.1
b = np.zeros(12)
probs_before = softmax_rows(y_feat @ W + b)
p = np.concatenate([W.ravel(), b]) # 120 个参数拍扁
m = np.zeros_like(p); v = np.zeros_like(p)
delta = 1e-3
for t in range(1, 26):
g = np.zeros_like(p)
for i in range(p.size): # 数值梯度:每参数两次前向
p_hi = p.copy(); p_lo = p.copy()
p_hi[i] += delta; p_lo[i] -= delta
g[i] = (loss_of(*unpack(p_hi)) - loss_of(*unpack(p_lo))) / (2 * delta)
lr = noam_lr(t, 8, 5) # warmup(前5步爬升)
m = 0.9 * m + 0.1 * g; v = 0.999 * v + 0.001 * g ** 2
mh = m / (1 - 0.9 ** t); vh = v / (1 - 0.999 ** t)
p = p * (1 - lr * 0.01) - lr * mh / (np.sqrt(vh) + 1e-8) # AdamW
# 每 5 步打印 loss 与 (0,0) 位置的概率/argmax
真实输出:
step loss p[0,0,5] argmax(0,0)
0 2.4326 0.0962 8
5 1.2235 0.3675 5
10 0.7426 0.7047 5
15 0.6721 0.8553 5
20 0.6575 0.8953 5
25 0.6488 0.9085 5
读这张表,见证"从废话到点名"的全过程:第 0 步 loss 2.4326(接近均匀分布的理论值 ln(12)≈2.48),位置 (0,0) 预测词 8(错的);第 5 步 loss 掉到 1.22,argmax 已经翻到目标词 5;第 10 步 p[0,0,5] 飙到 0.70;到第 25 步,loss 0.6488、p[0,0,5]=0.9085——注意 0.9085 恰好约等于平滑标签给目标词的 0.9083,模型把目标词的概率"校准"到了 soft 标签要求的水平,一分不多一分不少。这就是标签平滑在训练中的实际效果:它不止约束了"最优 logits 有界",还直接决定了最终概率的形状。
loss 曲线:

再看训练前后的概率分布对比——这是全章最有成就感的一张图:

训练后的概率数据(真实输出)也贴在下面,方便逐格核对:
训练前 probs[0](4×12):
位置0: [0.0842, 0.075, 0.054, 0.0827, 0.0574, 0.0962, 0.0842, 0.0746, 0.122, 0.0828, 0.0979, 0.0891]
位置1: [0.0833, 0.075, 0.0708, 0.0885, 0.0701, 0.0916, 0.0781, 0.0751, 0.1, 0.0887, 0.0887, 0.09]
位置2: [0.0953, 0.0723, 0.0848, 0.076, 0.0762, 0.0883, 0.0858, 0.0893, 0.0857, 0.0943, 0.0779, 0.0742]
位置3: [0.076, 0.0938, 0.0669, 0.1065, 0.0895, 0.0763, 0.0855, 0.1038, 0.0837, 0.0558, 0.0733, 0.0886]
训练后 probs[0](4×12):
位置0: [0.0005, 0.072, 0.0042, 0.0102, 0.0006, 0.9085, 0.0007, 0.0022, 0.0001, 0.0004, 0.0002, 0.0003]
位置1: [0.0039, 0.8316, 0.0077, 0.0106, 0.0087, 0.099, 0.0213, 0.0053, 0.0029, 0.0035, 0.0032, 0.0022]
位置2: [0.0005, 0.0009, 0.9342, 0.0051, 0.0007, 0.0005, 0.0165, 0.0395, 0.0008, 0.0003, 0.0005, 0.0006]
位置3: [0.001, 0.0035, 0.0121, 0.9162, 0.0233, 0.0401, 0.0001, 0.0011, 0.0005, 0.001, 0.0007, 0.0004]
最后用一张图收束三件套的分工:

💡 一个观察:训练后的位置 0 概率 0.9085、位置 2 概率 0.9342,都比平滑标签的 0.9083 略大——因为 loss 是 8 个位置的平均,模型在"整体最优"下略有取舍。这说明训练不是把每个位置机械地对齐标签,而是在全局 loss 最小化的意义下协同调整——这正是"训练"和"查表"的本质区别。
5.7 从 loss 数值里还能读出什么? 平滑 CE 有个"理论下限":假设模型输出的概率 q 完全等于 soft 标签 p_soft,此时 loss 是多少?代公式: ( 1 − ε ) H ( p onehot , p soft ) + ε H ( u , p soft ) = 0.9 × ( − log 0.9083 ) + 0.1 × ( − 1 12 ( log 0.9083 + 11 log 0.0083 ) ) ≈ 0.5262 (1-\varepsilon)H(p_{\text{onehot}}, p_{\text{soft}}) + \varepsilon H(u, p_{\text{soft}}) = 0.9\times(-\log 0.9083) + 0.1\times\big(-\frac{1}{12}(\log 0.9083 + 11\log 0.0083)\big) \approx 0.5262 (1−ε)H(ponehot,psoft)+εH(u,psoft)=0.9×(−log0.9083)+0.1×(−121(log0.9083+11log0.0083))≈0.5262——注意这正是 demo3 里 a* 处的平滑 loss!它告诉我们两件事:①平滑 CE 的 loss 永远到不了 0(因为 soft 标签本身不是 one-hot,它带着自己的"熵"),所以训练时看到 loss 停在 0.5 附近不要慌,那不是没收敛,而是"有底";②我们 25 步训到 0.6488,离下限 0.5262 还有距离,说明输出层还能继续精调(再多跑几十步、或加大 batch、或减小 wd,loss 会继续逼近下限)。会用"理论下限"校准"实际 loss",是训练老手和新手的分水岭之一。
5.6 从教学玩具到真实 Transformer:还差什么?
本章训练的只是 120 个参数的输出层,第 9 章的完整模型有 3084 个参数、还包含嵌入、注意力、LN、FFN。要把训练扩展到完整模型,只需要两处升级:① 梯度从数值梯度换成反向传播(用 PyTorch 的自动微分,速度差几千倍);② 参数从"拍扁的 120 个"变成"模型里每一层的每张矩阵"。而三件套(平滑 CE + AdamW + warmup)的用法一行都不用改——这正是本章把技巧单独抽出来讲的价值:训练技巧与模型规模无关,120 个参数上验证过的行为,在 6500 万参数的真实模型上一模一样。第 14 章我们会用真实数据集 + PyTorch 把整套流程再走一遍,到时候你会觉得今天的代码眼熟得不得了。
六、常见坑与自查
- 标签平滑的 ε 放错位置:soft 标签目标词处是 1 − ε + ε / V 1-\varepsilon+\varepsilon/V 1−ε+ε/V,不是 1 − ε 1-\varepsilon 1−ε(别忘了把 ε/V 那份补回去),否则总和小于 1、不是合法概率分布。自查:soft 标签.sum() 必须严格等于 1(demo1 验证过);
- 以为平滑只是"让 loss 变大":平滑确实让 loss 数值变大(demo2 多了 0.1),但那是"宽容的代价",换来的是 logits 有界、泛化更好。不要在"loss 降不下去"时急着去掉平滑;
- 把 Adam 的 weight decay 当 AdamW 用:PyTorch 里
torch.optim.Adam(wd=λ)是 L2 混进梯度版,torch.optim.AdamW(wd=λ)才是解耦版——两者对相同 λ 的行为完全不同(demo4 的轨迹就是活教材)。迁移到 Transformer 训练时务必用 AdamW; - warmup 步数理解反:Noam 公式里 warmup 是"爬升到峰值的步数",峰值点恰好落在 step=warmup(demo5 验证:峰值 0.1118 在 step=10)。调大 warmup 会推迟峰值、整体更保守;
- 数值梯度当生产工具:数值梯度只是本章教学替代,真实训练必须用反向传播(速度差成千上万倍)。从第 11 章起我们切 PyTorch 自动微分;
- 三件套缺一不可的错觉:不要以为"三个都得用才叫训练"。小模型/小数据可能只需要 AdamW;但 Transformer 这类大模型训练中,平滑 + AdamW + warmup 是业界标配,缺一样都会在收敛速度或最终精度上吃亏;
- 学习率峰值设太高:warmup 只是"缓起步",峰值(step=warmup 处)本身不能太大,否则爬上去照样炸。峰值由 d model − 0.5 d_{\text{model}}^{-0.5} dmodel−0.5 缩放,改模型维度时别忘了重新校准;
- 把标签平滑无脑用到底:对自回归生成任务,ε 太大(如 0.2 以上)会把目标词概率压得过低,生成质量下降,一般 0.1 以内;做分类任务可以适当调大;
- 改 lr 忘了连带 λ:AdamW 的衰减是"每步乘 (1−lr·λ)",衰减强度由 lr 和 λ 的乘积决定——你调大 lr 却不改 λ,等于变相增强了衰减;反之亦然,两者要一起看。
小结
这一章给第 9 章那台"只会说废话"的机器装上了操作系统——三件套,每一件都有数学推导、代码实现和数字实证:
- 一次训练迭代 = 四步循环:前向 → 算 loss → 反向求梯度 → 更新参数。标签平滑嵌在第 ② 步,AdamW 与 warmup 嵌在第 ④ 步(图 1);
- 标签平滑:one-hot → soft 标签(目标词 1 − ε + ε / V 1-\varepsilon+\varepsilon/V 1−ε+ε/V,其余 ε / V \varepsilon/V ε/V),总和仍为 1(demo1)。平滑 CE = 普通 CE + ε·KL(均匀‖q),同一份 logits 平滑后 loss 多了整 0.1000(demo2);
- 最优 logits 有界:平滑 CE 梯度 q k − ( 1 − ε ) − ε / V q_k-(1-\varepsilon)-\varepsilon/V qk−(1−ε)−ε/V,令其为零得 KaTeX parse error: Undefined control sequence: \* at position 3: a^\̲*̲=\ln(109)\appro…,该处梯度精确为 -0.000000、loss 0.5262 最小;普通 CE 梯度恒为负,logits 必然冲向 +∞(demo3 + 图 3)——这是全章最硬核的一处数学验证;
- AdamW 解耦衰减:零梯度纯衰减实验中,Adam 被自适应缩放成"常数步长"而过冲振荡(0.5→-0.20→0.10→0.02),AdamW 的 w t = 0.5 × 0.99 t w_t=0.5\times0.99^t wt=0.5×0.99t 与理论值逐位重合(demo4 + 图 4、5)——"解耦"不是玄学,是能被算出来的;
- warmup 调度:Noam 公式一条式子管两段(线性爬升 + 幂次衰减),峰值恰在 step=warmup(demo5 + 图 6);同一起点 w₀=3,固定 lr=2.1 发散(|w| 涨到 6.43),warmup 单调收敛到 1.98(demo6 + 图 7);
- 三件套合体:数值梯度 + 平滑 CE + AdamW + warmup,训练输出层 25 步,loss 从 2.4326 降到 0.6488,argmax 第 5 步就翻到目标词,最终概率 0.9085 恰好"校准"到平滑标签的水平(demo7 + 图 8、9);
- 未训练 → 学会点名:训练前 4×12 概率几乎均匀(0.050.12),训练后每行只有目标词一格深色(0.830.93)——机器从"均匀的废话"变成了"精准的点名";
- 训练技巧的迁移性:三件套与模型结构无关——120 个参数的输出层、3084 个参数的 mini Transformer、6500 万参数的真实模型,用的都是同一套平滑 CE + AdamW + warmup。本章所有数字实验在小模型上成立,结论可以原样迁移到大模型。
到这里,架构篇(第 6-10 章)收官:零件(注意力/多头/位置编码/残差/LN/FFN/掩码/交叉注意力)→ 总装(前向传播)→ 操作系统(三件套),一台能训练的小型 Transformer 全部就位。下一章开始进入预训练篇:真实世界的第一步是"分词"——机器不认识字,只认识 token id,怎么把一句话切成词表里的编号,就是 BPE 子词切分的故事。到时候你会发现,本章的三件套(平滑 CE + AdamW + warmup)会原样出现在 BERT、GPT 的预训练配置里——训练技巧学会了,就永远不会过期。
十一、动手指南:五道练习题,把"看懂"变成"会调"
本章三件套全是"改一个数就能看变化"的活实验,强烈建议动手:
- 改 ε:把 demo3 的 ε 从 0.1 改成 0.05、0.2,重跑 a* 的推导与梯度表——观察最优 logits 怎么跟着变(ε 越大,模型越被要求"谦虚",a* 越小;ε=0.05 时 a* 变成 ln(229)≈5.43);
- 找发散临界点:在 demo6 里把固定学习率从 2.1 改成 1.9、2.0、2.1,找出 f(w)=0.5w² 的收敛临界点(理论上 lr<2 收敛、lr≥2 发散),体会"学习率差一点点,结局两重天";
- 调衰减强度:demo4 里把 λ 从 0.1 改成 0.3、0.01,观察 AdamW 的几何衰减率 (1−lr·λ) 怎么变——λ=0.3 时每步缩到 0.97 倍,30 步后只剩 0.5×0.97³⁰≈0.20;
- 换调度器:把 demo7 的 Noam 换成"固定 lr=0.1"重跑 25 步,对比 loss 曲线——你会发现固定学习率在起步阶段震荡更大,warmup 的价值再次现身;
- 数清"校准":demo7 训练后位置 0 的概率 0.9085 与平滑标签 0.9083 几乎相等——把 ε 改成 0.2 重训,看目标词概率是否收敛到 0.2/12+(1−0.2)=0.8167 附近,亲手验证"平滑标签决定最终概率的形状"。
五题做完,你会对"为什么 Transformer 训练标配这三件套"有肌肉记忆——不是背下来的,是试出来的。还有一条隐藏练习:把 demo7 的 warmup 从 5 改成 20,观察 loss 曲线"峰值后移、前期下降变缓"——你会在自己画出的曲线里,亲眼看到"调度节奏"如何改变训练进程。
下一篇(四十):分词与数据管线——BPE 子词切分
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)