损失 & 梯度下降
有了数据和模型,接下来的问题就一个:怎么让模型的几十亿参数,从"随机瞎猜"变成"能预测对"?答案是——先定一个"考试分数"(损失函数),再用"蒙眼下山"的办法(梯度下降)不断降低这个分数。这就是所有神经网络学习的核心引擎。
先把术语翻译成人话
这一节全是公式和希腊字母,听着玄。其实整章就在讲两件事:怎么给模型打分(损失函数),以及怎么照着分数往好的方向改(梯度下降)。说白了就是一张考试扣分表加一套改错方法。
| 术语 | 换成大白话 | 生活里对应什么 |
|---|---|---|
| 损失函数(Loss Function) | 一张扣分表——答得离答案多远就扣多少分 | 考试卷子上的扣分标准 |
| 交叉熵(Cross-Entropy) | 答错要扣分,"自信地答错"扣得更狠 | 模棱两可地猜错扣 2 分,斩钉截铁地答错扣 20 分 |
| 困惑度(Perplexity) | 模型每猜一个字时,心里在几个选项里犹豫 | 点菜时在 5 道菜里拿不定主意 |
| 梯度(Gradient) | 脚底下这块地往哪边斜、斜多陡 | 黑灯瞎火下楼,用脚探台阶的高低 |
| 梯度下降(Gradient Descent) | 顺着往低处的方向挪一小步,反复挪 | 蒙眼下山,一步一探 |
| 学习率(Learning Rate) | 每一步迈多大 | 下楼梯是一格一格走还是三格三格蹦 |
| 批大小(Batch Size) | 一次拿多少条数据来算方向 | 一次备多少菜、一趟拉几个包裹 |
| 动量(Momentum) | 给下山的人加点惯性,别一步一停 | 推购物车——推起来了就顺着劲儿走 |
| Adam / AdamW | 给每个参数各配一个步长,自己调快慢 | 地铁不同线路各排一套发车间隔 |
| warmup 预热 | 开头几步走得特别小心,别一上来就摔 | 刚上公交先扶稳了再往里走 |
| 梯度裁剪(Clipping) | 某一步太猛就掐住,别让它一脚踩空 | 下楼装个扶手,脚滑了有东西抓 |
| 反向传播(Backprop) | 从结果往回一层层算"每个人该背多少责任" | 菜咸了,往回查是腌的时候咸的还是最后加盐加多了 |
想象你被蒙上眼睛,扔在一座大雾弥漫的山上,任务是走到山谷最低点。
你看不见路,但可以用脚感——踩一踩四周,哪个方向的地是往下斜的?就往那个方向走一小步。
走一步、再踩、再判断斜度、再走一步——反复几万次,你终究会走到某个"局部最低点"。
这就是训练神经网络时,几十亿个参数在"参数空间"里的行走方式。
这个"斜度",数学名字叫梯度(gradient);"往低处走",就叫梯度下降(gradient descent)。
一、损失函数:给模型的"考试分数"
要让模型"学会",你得先告诉它什么叫对,什么叫错。这就是损失函数(loss function)——一个数值,衡量模型预测和真实答案之间的差距。差距越大、损失越大;差距越小、损失越小。训练的全部目标就一句话:让损失变得越来越小。
常见的损失函数有两大类:
- MSE 均方误差用于回归任务(预测连续值)——预测房价、明天气温、股票价格。计算方式:(预测 - 真实) 的平方,取平均。
- Cross-Entropy 交叉熵用于分类 / 语言模型——预测下一个词、图片属于哪一类。核心思想:惩罚"自信但错误"的预测——模型越"信心满满地答错",损失越大。
大语言模型用的几乎全是交叉熵。举例:"我 昨天 吃了 ___",正确答案是"苹果"。模型预测的概率分布可能是"苹果 60% / 香蕉 20% / 桌子 5% / ...."——那损失就是 −log(0.6) ≈ 0.51。如果模型对"苹果"只给出 1% 的概率,那损失就是 −log(0.01) ≈ 4.6——损失大很多,反向传播时"惩罚"也就大很多。
为什么偏偏是"取对数再取负"这么一个奇怪的形式?这不是随便定的,它来自信息论。香农在 1948 年给出了一个定义:一个概率为 p 的事件发生时,它携带的信息量是 −log₂(p) 比特。概率越小的事件发生,信息量越大——"太阳明天升起"几乎没信息,"明天下陨石雨"信息量爆炸。
把这个视角套到语言模型上,整件事就通透了:交叉熵损失衡量的是"模型对真实答案感到有多惊讶"。模型给正确答案的概率越高,它越"不惊讶",损失越小。所以训练的目标可以完全重新表述为——让模型对真实世界不再感到意外。
| 模型给正确答案的概率 | 交叉熵损失(自然对数) | 信息量(比特) | 直观含义 |
|---|---|---|---|
| 99% | 0.01 | 0.014 | 几乎完全确定,几乎不惊讶 |
| 60% | 0.51 | 0.74 | 有点犹豫但方向对 |
| 10% | 2.30 | 3.32 | 猜错了但没排除 |
| 1% | 4.61 | 6.64 | 严重误判 |
| 0.001% | 11.5 | 16.6 | 自信地答错,惩罚极重 |
| 随机猜(词表 12.8 万) | 11.76 | 17.0 | 训练开始时的初始损失 |
最后一行提供了一个非常实用的健全性检查:训练一个词表 12.8 万的模型,第一步的损失应该约等于 ln(128000) ≈ 11.76。如果你启动训练看到损失是 11.7 左右,说明模型确实在"完全随机猜"的正确起点上;如果损失一开始就是 20 或者 3,那你的代码有 bug——可能标签对错位了,可能忘了做 softmax,可能初始化炸了。这是每个训练工程师第一件要看的事。
那 MSE 为什么不适合分类?两个原因。第一,MSE 对"自信地答错"惩罚不足:正确答案概率从 0.01 掉到 0.0001,交叉熵损失从 4.6 涨到 9.2 翻了一倍,而 MSE 的变化微乎其微。第二,更致命的是梯度消失:MSE 配 softmax 时,在预测极度错误的区域梯度反而趋近于 0,模型卡在那里出不来;而交叉熵配 softmax 的梯度有一个极其漂亮的形式——
# 交叉熵 + softmax 的梯度(对 logits 求导)
∂L/∂z_i = p_i − y_i
# 就这么简单:预测概率 减去 真实标签(one-hot)
# 例:"苹果"的真实标签是 1,模型给了 0.6
# → 梯度 = 0.6 − 1 = −0.4 (要把这个 logit 推高)
# "香蕉"的真实标签是 0,模型给了 0.2
# → 梯度 = 0.2 − 0 = +0.2 (要把这个 logit 压低)
# 关键性质:错得越离谱,梯度越大,永不饱和
# 若用 MSE + softmax,梯度里会多出一项 p_i(1−p_i)
# → 当 p_i 接近 0 时这一项也接近 0 → 梯度消失 → 学不动
p − y——这个式子的干净程度,本身就是交叉熵能统治分类任务的原因。它不但数学优雅,还有一个直觉解释:误差有多大,就往回推多大的力,不打折。
二、困惑度(Perplexity):把损失翻译成人话
困惑度这个词其实就是"心里在几个选项里犹豫"。困惑度 5 相当于你点菜时在 5 道菜之间拿不定主意;困惑度 128000 相当于你面前摆着一本 12.8 万道菜的菜单,闭着眼指——说白了就是彻底瞎猜。
交叉熵是 2.3 还是 2.5,对人类来说毫无感觉。所以业界发明了一个更直观的指标:困惑度(Perplexity,PPL),定义简单到只有一步——PPL = e^损失(如果损失用自然对数)。
它的含义特别形象:困惑度等于"模型在每一步平均感觉自己在几个等可能选项里犹豫"。困惑度 10,意味着模型每猜一个词时的不确定性,相当于在 10 个候选里抓阄;困惑度 1,意味着它完全确定。困惑度等于词表大小,意味着它在瞎猜。
| 阶段 / 模型 | 交叉熵损失 | 困惑度 | 相当于 |
|---|---|---|---|
| 随机初始化(词表 12.8 万) | 11.76 | 128000 | 完全瞎猜 |
| 训练 1% 时 | 约 4.0 | 约 55 | 已经学会基本语法和常用搭配 |
| 训练 10% 时 | 约 2.6 | 约 13 | 能写通顺句子 |
| GPT-2(WikiText-2 上) | 约 3.1 | 约 22 | 2019 年的最强水平 |
| 现代 7B 模型 | 约 2.0-2.2 | 约 7-9 | 流畅、有知识 |
| 前沿大模型 | 约 1.6-1.8 | 约 5-6 | 接近文本本身的熵下限 |
注意最后一行的"熵下限"——这是一个很深刻的概念。自然语言本身是有内在随机性的:即使一个完美的预言家,也无法确定"我明天想吃___"后面是"面"还是"饭"。这个不可消除的不确定性叫文本的熵,香农 1951 年用人类实验估计英文约为每字符 1 比特左右。所以困惑度不可能降到 1——它有一条由语言本身决定的地板。当曲线趋于平缓时,往往不是模型学不动了,而是它已经接近这块地板。
使用困惑度有两个坑必须知道。第一,不同分词器的困惑度不可比:中文一个字算一个 token 还是两个,会让同样的能力算出完全不同的 PPL。第二,困惑度低不等于回答得好:一个在维基百科上 PPL 极低的模型,可能完全不会遵循指令。这就是为什么评测最终要靠 MMLU、GSM8K 这类下游任务,而不是只看损失。
三、梯度:损失曲面上的"斜度"
假设你的模型只有一个参数 w,损失就是一个关于 w 的函数 L(w)——画出来是一条曲线。你所在的位置对应一个 w 值,你想让 L(w) 变小——怎么办?看曲线在你脚下的斜率:斜率为正(曲线往右上走),你就往左走;斜率为负(曲线往右下走),你就往右走。
推广到几十亿个参数,"斜率"变成了梯度——一个几十亿维的向量,指向"损失上升最快的方向"。所以你要往梯度的反方向走一步——这就是梯度下降的核心公式:
w ← w − η · ∇L(w)
其中 η 是学习率(步长),∇L(w) 是当前位置的梯度。这一行公式,本质上就是 ChatGPT、Claude、DeepSeek 学会说话的全部秘密——只不过它要被执行几百万亿次而已。
关于梯度的几何意义,有三件事值得说透,因为它们能解释后面所有优化器的设计动机。
第一,梯度是"最陡上升方向",这是一个可以证明的性质。在所有单位方向里,沿梯度方向走,函数值增加得最快。所以走它的反方向,是"局部最优"的下降选择。但注意"局部"这个词——最陡的方向不等于最快到底的方向。如果山谷是一条狭长的斜沟,最陡方向会让你横冲到对面坡上,反复横跳,而真正该走的是沿沟底缓缓向前。这个几何图形叫病态曲率(ill-conditioning),是动量法和 Adam 存在的全部理由。
第二,梯度的大小也携带信息。梯度范数大,说明当前位置很"陡",离最优解可能还远;范数接近 0,说明到了平坦区——可能是最优点,也可能是鞍点(一个方向往上、另一个方向往下的马鞍形状)。在高维空间里,鞍点远比局部极小点常见得多,这也是一个反直觉的事实:几十亿维空间里,要所有维度都同时向上弯曲才是极小点,概率极低。所以"大模型会陷入局部最优"这个担心,实际上基本不成立——真正的困扰是鞍点附近的长时间停滞。
第三,损失曲面比"一个山谷"复杂得多。研究者用可视化技术切片观察过大模型的损失曲面,发现它更像一片崎岖的高原,遍布狭窄的沟壑和平台。而且有一个惊人的发现:不同随机种子训出来的模型,虽然参数完全不同,但它们的损失值几乎一样好——高维空间里有海量"同样优秀"的解。这解释了为什么训练大模型不需要找"那个唯一最优解",找到任何一个足够低的谷底就够了。
| 地形 | 梯度表现 | 会出什么问题 | 对策 |
|---|---|---|---|
| 陡坡 | 范数很大 | 一步跨过头,损失爆炸 | 梯度裁剪、warmup |
| 狭长斜沟 | 各维度尺度差异巨大 | 来回震荡,进展缓慢 | 动量、Adam 自适应 |
| 平原 / 鞍点 | 范数接近 0 | 长时间原地不动 | 动量冲过去、加噪声 |
| 悬崖 | 某一步范数突然暴增 | loss spike,可能直接 NaN | 裁剪 + 回滚检查点 |
| 谷底附近 | 范数小且方向摇摆 | 在最低点附近打转 | 学习率衰减 |
为什么叫"蒙眼"?因为几十亿维的参数空间,人类根本无法直观想象——工程师看不见"山谷长什么样",只能靠数学工具计算每一步的斜度。为什么叫"下山"?因为损失越低,模型越准。为什么要"一小步一小步"?因为一步太大就跨过山谷冲到对面山坡——学习率太大就是这个后果;一步太小就永远下不到底——学习率太小就是这个问题。
损失函数这一半:想象一下一张考试卷的扣分表。答对了不扣,答偏了扣一点,答得离谱扣一堆。交叉熵这张扣分表有个特别之处——它不光看你答没答对,还看你有多自信。你写"大概是 A 吧"结果错了,扣 2 分;你在卷面上写"绝对是 A,我拿命保证"结果错了,扣 20 分。说白了就是:错了要罚,嘴还硬的加倍罚。这正是我们想要的——一个说"我不太确定"的错答案,危害本来就比一个斩钉截铁的错答案小得多。
梯度下降这一半:想象一下你被蒙上眼扔在一座山上,任务是走到最低处。你看不见路,只能用脚尖蹭一蹭四周——哪个方向是往下斜的,就往那边挪一步。这个"往下斜的方向"就是梯度的反方向,"挪一步多大"就是学习率。就像半夜停电摸黑下楼,你不敢大步蹦,只能一格一格用脚探。
把这个场景摊开,本节所有名词全都各就各位:
学习率太大——你一步三格往下蹦,结果一脚跨过谷底冲到对面坡上,来回横跳,甚至越跳越高(损失爆炸);
学习率太小——一步一厘米,天亮了还在半山腰;
warmup 预热——刚开始你完全不熟地形,先小步走几十米摸摸情况,稳了再加大步子。好比刚挤上公交先抓住扶手站稳,再往车厢里走;
动量——别每一步都停下来重新起步,让身上带点惯性。相当于推着购物车下坡,推起来了就顺着劲儿走,路上有小坑也能凭惯性滚过去;
梯度裁剪——遇到一处特别陡的地方,梯度突然暴增,一脚踩下去就滑下悬崖。裁剪相当于沿楼梯装了个扶手:脚滑了还有东西能抓一把;
学习率衰减——快到谷底了就得改成小碎步,不然一直大步走只会在最低点附近来回打转,永远落不到底。
换成大白话总结:损失函数负责告诉模型"你这次扣了多少分",梯度告诉它"往哪个方向改能少扣分",学习率决定"这次改多少"。几十亿个参数、重复几百万亿次这三个动作——ChatGPT 就是这么学会说话的。
四、SGD:随机梯度下降的实用版
理论上讲,每次更新参数应该用整个数据集计算梯度——这叫"批量梯度下降"(Batch GD)。但十亿条数据算一次梯度要多久?几个小时。这样一天只能走几步——训练不完了。
解决办法:每次只用一小批(batch)数据计算梯度,比如 32 条、256 条、4096 条——虽然梯度不那么"精确",但每一步都快多了。这就叫随机梯度下降(SGD, Stochastic Gradient Descent)。有点像下山时你不是站在原地环顾四周慢慢判断,而是快速踢两脚感受一下斜度就迈步——虽然每一步方向可能有点偏,但总体上走得快、也能到底。
严格来说,这里有三个不同的东西,名字经常被混用,但差别很实在:
| 方法 | 每步用多少数据 | 梯度质量 | 每步耗时 | 实际地位 |
|---|---|---|---|---|
| 批量梯度下降 BGD | 全部数据 | 精确 | 极慢 | 只在教科书里 |
| 随机梯度下降 SGD(严格版) | 1 条 | 噪声极大 | 极快但不稳 | 理论分析用 |
| 小批量梯度下降 Mini-batch | 32 到几百万 | 噪声可控 | 可并行,最快 | 实际全部在用这个 |
所以今天说"SGD",指的其实都是小批量版本。而这里有个反直觉的要点:梯度里的噪声不是纯粹的坏事,它还是一种免费的正则化。噪声让参数不停小幅抖动,反而更容易跳出尖锐的窄谷、落进平坦的宽谷——而大量研究表明,落在平坦极小点的模型泛化能力更好。直觉上很好理解:宽谷底部意味着参数稍微变一点损失也不会变差,说明这个解是"稳健"的。
这也是为什么批大小不能无限往上加。批越大,噪声越小,梯度越精确——但正则化效果也越弱,容易掉进尖锐的解。同时还有前面提过的临界批大小问题:超过某个点,加倍批大小不再带来加倍的收敛速度,纯粹浪费算力。
五、动量:给下山的人加上惯性
纯 SGD 在那条"狭长斜沟"地形里表现很差:它会垂直于沟方向来回横跳,沿着沟方向却挪动缓慢。1964 年 Polyak 提出的动量法(Momentum)用一个极简的办法解决了它。
# 普通 SGD
w ← w − η · g
# 带动量的 SGD(β 通常取 0.9)
v ← β · v + g # v 是"速度",累积历史梯度
w ← w − η · v
# 为什么有效?
# 沿沟方向:每步梯度方向一致 → v 不断累加 → 越走越快
# 垂直沟方向:梯度左右交替 → 正负相消 → 抖动被抵消
# β=0.9 相当于对最近约 10 步梯度做指数加权平均
# 有效步长最多放大到 1/(1−β) = 10 倍
物理直觉非常贴切:把参数想成一个有质量的小球而不是一个点。没有动量时,小球每一步都要重新静止再启动,遇到坑洼就被绊住;有了动量,它滚起来了——遇到小坑能凭惯性冲过去,遇到反复的左右扰动会自动平均掉,而在持续向下的长坡上会越滚越快。动量法既加速了收敛,又抑制了震荡,还帮助逃离鞍点——一个参数解决三个问题,这是深度学习里性价比最高的技巧之一。
还有一个变体叫 Nesterov 动量:它先按当前速度"往前探一步",在探到的位置算梯度,再更新。相当于小球有了一点预判能力——快到谷底时提前减速,而不是冲过去再回头。在视觉任务上它常常比标准动量略好,但在大语言模型上大家用的是 Adam 系列,Nesterov 反而不常见了。
六、Adam / AdamW:现代大模型的默认优化器
两个缩写先摊开:Adam(Adaptive Moment Estimation,自适应动量估计——给每个参数各配一个步长,梯度大的自动走小步、梯度小的自动走大步),AdamW(Adam with decoupled Weight decay,把"权重衰减"从梯度里拆出来单独作用的改良版)。打个比方:Adam 干的活儿好比地铁调度——不同线路客流差得远,就各排一套发车间隔,而不是全网强行统一每 5 分钟一趟。
纯 SGD 有个问题:所有参数用同一个学习率——但有些参数变化很剧烈,需要小步;有些参数很稳定,可以大步。Adam(2014)解决了这个问题:它给每个参数分别维护一个"惯性"和"变化率"——常变的参数自动放慢、稳定的参数自动加快。就像下山时,脚感灵敏的地方小心一点,脚感稳的地方大胆迈步。
把 Adam 的四行更新写出来,你会发现它就是"动量 + 自适应缩放"的组合:
# Adam 的完整更新(η=3e-4, β1=0.9, β2=0.95, ε=1e-8)
m ← β1 · m + (1−β1) · g # 一阶动量:梯度的均值(方向)
v ← β2 · v + (1−β2) · g² # 二阶动量:梯度平方的均值(幅度)
m̂ ← m / (1 − β1^t) # 偏差校正:t 很小时 m 被初始 0 拖低
v̂ ← v / (1 − β2^t)
w ← w − η · m̂ / (√v̂ + ε) # 关键:用 √v̂ 归一化步长
# 为什么这一步是"自适应"的?
# 某参数梯度一直很大(比如 100)→ √v̂ ≈ 100 → 实际步长 ≈ η×1
# 某参数梯度一直很小(比如 0.01)→ √v̂ ≈ 0.01 → 实际步长 ≈ η×1
# → 无论梯度尺度差多少倍,每个参数的实际移动量都在 η 附近
# → 这就是"每个参数一个学习率"的实现方式
注意最后那个注释里的洞察:Adam 的本质是把每个参数的更新量归一化到相同尺度。这就是它为什么在 Transformer 上如此关键——Transformer 里不同层、不同模块(注意力 vs FFN vs 嵌入层)的梯度尺度可以差几个数量级,用一个全局学习率根本不可能同时照顾好。而 Adam 让你只需要调一个 η,剩下的它自己摆平。
AdamW(2019)是 Adam 的改进版,把权重衰减(weight decay)从梯度里分离出来,正则化效果更好——它是今天几乎所有大语言模型的默认优化器。GPT-3、GPT-4、Llama、Claude、DeepSeek——用的都是 AdamW。
"分离"这个词具体指什么?原来的 Adam 是把 L2 正则项加到梯度里:g ← g + λw,然后这一项也会被 √v̂ 除一遍。后果是——梯度大的参数,衰减反而被削弱了,完全违背正则化的初衷。AdamW 的修正简单得让人拍额头:
# Adam + L2(有问题的写法)
g ← g + λ · w # 正则项混进梯度
... 后续 Adam 步骤 ...
# → 这一项被 √v̂ 除掉,衰减力度被梯度尺度扭曲
# AdamW(正确的写法:解耦)
... 正常 Adam 算出更新量 ...
w ← w − η · m̂/(√v̂+ε) − η · λ · w # ← 衰减独立作用,不过自适应
‾‾‾‾‾‾‾‾‾
就是这一项被"解耦"出来了
# 效果:AdamW 在 ImageNet、GLUE、语言建模上普遍优于 Adam+L2
# 而且让"学习率"和"权重衰减"两个超参可以独立调,不再纠缠
| 优化器 | 特点 | 常见场景 |
|---|---|---|
| SGD | 最朴素,需手动调参 | 视觉任务、小模型 |
| SGD + Momentum | 加入"惯性",走得更稳 | ResNet 等经典视觉 |
| Adam | 自适应学习率,收敛快 | 早期 Transformer |
| AdamW | 分离权重衰减,泛化更好 | 现代大模型默认 |
| Adafactor | 省显存,Adam 的轻量版 | Google T5、PaLM |
| Lion | 只用一阶动量,更省内存 | 2023 新秀,逐渐流行 |
Adam 系列有一个代价必须记住:它要为每个参数额外存两份状态(m 和 v)。回到上一篇算过的账——这两份状态在 FP32 下就是 8N 字节,占了训练显存的一大块。这正是 Adafactor(把 v 分解成行列两个向量,省掉绝大部分)和 Lion(只保留一阶动量,省一半)这些"省显存优化器"存在的理由。优化器的进化史,一半是数学,一半是显存账。
七、学习率调度:节奏比速度更重要
"调度"这个词其实就是"步子该怎么随着时间变"。好比你早上赶地铁:出门前系鞋带的时候不能跑(warmup),大马路上放开走(峰值),进了地铁闸机口就得改成小碎步(衰减)——全程一个速度的人,要么开头摔一跤,要么末了撞在闸机上。
学习率不是从头到尾固定不变的。业界的标准做法是:Warmup + Cosine Decay——一开始学习率从 0 慢慢升起(Warmup),到达峰值后再按余弦曲线缓慢下降到接近 0。为什么这样?
- 开始要慢刚出生的模型权重随机、梯度混乱,大步会把它一下"甩飞"——先小步稳定几步。
- 中间要快模型进入"学习状态"后,大步能高效地走下山。
- 结尾要慢越接近山谷底部,越需要精细调整——大步会在最低点附近来回震荡。
这跟人学新技能的节奏很像:入门慢慢来、中间发力练、大成之前反复打磨。节奏对了,事半功倍;节奏错了,可能永远学不成。
把它写成代码只有几行,但每一行都对应一个真实的失败教训:
def lr_at_step(step, peak_lr=3e-4, warmup=2000, total=100000, min_ratio=0.1):
if step < warmup:
return peak_lr * step / warmup # 线性升温
# 余弦从 peak 衰减到 peak × min_ratio
progress = (step - warmup) / (total - warmup)
cos = 0.5 * (1 + math.cos(math.pi * progress))
return peak_lr * (min_ratio + (1 - min_ratio) * cos)
# 各阶段的实际值(peak=3e-4, warmup=2000, total=10万步)
step 0 : lr = 0 ← 完全不更新,先让统计量稳定
step 1000 : lr = 1.5e-4 ← 升温一半
step 2000 : lr = 3.0e-4 ← 达到峰值
step 50000 : lr = 1.65e-4 ← 余弦中段
step 100000 : lr = 3.0e-5 ← 收尾,保留 10%
Warmup 为什么是必需的而不是可选的?有一个和 Adam 直接相关的解释:Adam 的 v(二阶动量)在训练最初几十步里估计极不准确——它只见过几个梯度样本,方差估计会严重偏小,导致 1/√v̂ 变得非常大,于是第一步就迈出巨大一跃,把随机初始化的权重直接甩到一个糟糕的区域。Warmup 用极小的 η 把这段"统计量还没热起来"的时期熬过去。这也解释了为什么 batch 越大、模型越深,需要的 warmup 就越长——统计量要更多步才能稳定。
为什么用余弦而不是线性或阶梯?实践对比的结果是余弦在同等步数下损失最低,而且它有个好性质:中段衰减慢(充分利用高学习率的探索能力),末段衰减快(精细收敛)。但余弦有个实用缺点——它需要预先知道总步数。如果你训到一半想延长训练,余弦曲线就得重新规划。这就是为什么 2024 年之后出现了 WSD 调度(Warmup-Stable-Decay):中间一段保持恒定学习率,最后才快速衰减。它的好处是可以在任意时刻决定"就在这里收尾",中途的检查点也都是可用的,MiniCPM 和 DeepSeek 的一些模型用的就是它。
| 调度策略 | 形状 | 优点 | 缺点 | 用者 |
|---|---|---|---|---|
| 固定学习率 | 一条平线 | 简单 | 末期无法精细收敛 | 只在调试时用 |
| 阶梯衰减 | 台阶下降 | 直观、易实现 | 跳变处损失会抖 | 经典视觉任务 |
| Warmup + Cosine | 先升后余弦降 | 效果最好、最通用 | 必须预知总步数 | GPT-3、Llama、绝大多数 |
| WSD | 升 → 平 → 快降 | 可随时收尾、便于续训 | 较新,经验少 | MiniCPM、部分国产模型 |
| 带重启的余弦 | 锯齿反复 | 可跳出局部解 | 大模型上收益不明 | 少数视觉任务 |
八、梯度裁剪与混合精度:让训练不崩的两道保险
两个名字先翻译:梯度裁剪(Gradient Clipping)=某一步太猛就掐住,相当于楼梯边上装个扶手,脚滑了有东西能抓;混合精度(Mixed Precision)=能糙着算的地方就糙着算、必须精确的地方才拿高精度,好比菜市场买菜——称重量用秤,估个头大小用眼睛就够了,没必要拿游标卡尺量黄瓜。
训练一个大模型跑几个月,最怕的不是慢,而是崩——损失突然飙到 NaN,几天的算力白费。有两个几乎是标配的保险措施。
梯度裁剪(Gradient Clipping)解决"悬崖"问题。偶尔会遇到一个异常样本(乱码、超长重复串),产生一个范数巨大的梯度,一步就把权重推到不可恢复的位置。裁剪的做法极简:如果整个梯度向量的范数超过阈值,就等比例缩回去——方向不变,只限制长度。
# 全局范数裁剪(PyTorch 一行搞定)
total_norm = sqrt(sum(g.pow(2).sum() for g in all_grads))
if total_norm > max_norm: # max_norm 几乎都设 1.0
scale = max_norm / (total_norm + 1e-6)
for g in all_grads:
g.mul_(scale) # 等比例缩小,方向不变
# 实际调用
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# 注意:要"按全局范数"裁,不要"逐参数裁"
# 逐参数裁会改变梯度方向 → 破坏下降方向的正确性
为什么阈值几乎都是 1.0?因为它是个经验值,而不是理论值——GPT-3、Llama 全系、Qwen、DeepSeek 都用 1.0。实用建议是:把梯度范数当作监控指标画出来。正常训练时它应该平稳且大部分时候低于阈值;如果频繁被裁剪,说明学习率偏大或数据里有脏样本。
混合精度训练(Mixed Precision)解决速度和显存问题。原理是:用 16 位做计算,用 32 位存权重和做累加。16 位矩阵乘法在现代 GPU 的张量核心上快 2 到 8 倍,显存也省一半;而累加与权重更新保持 32 位,避免精度损失累积。
| 格式 | 符号+指数+尾数 | 动态范围 | 精度 | 是否需要 loss scaling |
|---|---|---|---|---|
| FP32 | 1 + 8 + 23 | 约 1e±38 | 高 | 不需要 |
| FP16 | 1 + 5 + 10 | 约 6e-5 到 6.5e4(窄) | 较高 | 必须要 |
| BF16 | 1 + 8 + 7 | 与 FP32 同 | 较低 | 基本不需要 |
| FP8 (E4M3) | 1 + 4 + 3 | 很窄 | 低 | 需要精细的分块缩放 |
这张表里最重要的对比是 FP16 vs BF16。两者都是 16 位,但字段分配完全不同:FP16 把位数给了尾数(精度高但范围窄),BF16 把位数给了指数(范围和 FP32 一样宽但精度低)。深度学习训练里,"范围"远比"精度"重要——因为梯度经常小到 1e-8,用 FP16 会直接变成 0(下溢),必须靠 loss scaling(先把损失乘 1024 再算梯度,更新前除回来)这种技巧硬撑;而 BF16 天生就能表示,几乎不需要额外处理。这就是为什么 A100 之后的所有大模型训练都默认 BF16。
2024 年 DeepSeek-V3 更进一步做了FP8 训练——这是第一个在超大规模上成功的 FP8 实践。他们的关键手段是"细粒度量化":不是整个张量共用一个缩放因子,而是按 1×128 的小块分别缩放,同时把累加过程提升到更高精度。代价是工程复杂度暴增,收益是训练速度和显存都近乎翻倍——这也是他们能把 671B 模型的训练成本压到 557 万美元的原因之一。
九、反向传播:梯度是怎么算出来的
这个词翻译成人话就是:从结果往回查,看每一步各该背多少责任。打个比方,一盘菜咸了,你得往回捋——是腌的时候盐放多了?还是案板上又撒了一把?还是最后收汁时又加了酱油?从最终结果一层层倒推到每一道工序、每一勺料,算出各自该改多少——这就是反向传播干的活儿。
梯度下降公式说得很好,但那个"梯度"到底怎么算?答案是反向传播(backpropagation)——链式法则的应用。前向传播时,数据从输入一路流到输出、算出损失;反向传播时,损失的"责任"从输出一路推回输入,每一层的参数都能算出"我该改变多少"。
你不需要真的会推导反向传播——PyTorch、TensorFlow、JAX 都自动帮你做了这件事,写代码只需要一行 loss.backward()。但要理解一点:整个训练过程就是"前向 → 算损失 → 反向 → 更新权重"这四步的无限循环,一个 175B 的模型可能要重复这个循环几百万次,才能训练完成。
把这个循环写成代码,你会发现它短到不可思议——所有大语言模型的训练主循环,本质上就是这十几行:
for step, batch in enumerate(dataloader):
# ① 前向:算出每个位置对下一个 token 的预测
logits = model(batch.input_ids) # [B, T, vocab]
# ② 算损失:预测 vs 真实的下一个 token(标签就是输入右移一位)
loss = cross_entropy(logits[:, :-1].flatten(0, 1),
batch.input_ids[:, 1:].flatten())
# ③ 反向:链式法则自动把"责任"推回每一个参数
loss.backward() # ← 一行触发几千亿次求导
# ④ 裁剪 + 更新
clip_grad_norm_(model.parameters(), 1.0)
set_lr(optimizer, lr_at_step(step)) # 学习率调度
optimizer.step() # AdamW 更新权重
optimizer.zero_grad() # 清空梯度,准备下一轮
if step % 500 == 0:
save_checkpoint(model, optimizer, step) # 万卡集群必须频繁存盘
log(loss=loss.item(), ppl=math.exp(loss.item()))
注意第 ② 行那个"输入右移一位"的技巧:自回归语言模型的标签就是输入本身,只是错开一格。这是它能用无标注文本训练的全部魔法——不需要人工标注,文本自己就是自己的答案。这也是"自监督学习"这个名字的来源。
关于反向传播还有一件工程上很重要的事:它需要保存前向传播的所有中间激活值,因为链式法则要用到它们。这些激活值的显存开销随批大小和序列长度线性增长,在长上下文训练时甚至超过权重本身。解法叫激活重算(activation checkpointing):只保存每几层的边界值,反向传播时用它重新前向算一遍中间结果。代价是多算约 30% 的时间,收益是激活显存降到 1/√N 量级——这是长上下文训练的必备手段。
十、损失曲线诊断:一张图看出训练出了什么问题
训练大模型时,工程师大部分时间在干一件事:盯着损失曲线。这条曲线是唯一的实时体检报告,而它的每一种异常形状都对应特定病因。这一节把最常见的五种情况列成对照表——它比任何理论都更接近日常工作。
| 曲线形状 | 最可能的原因 | 诊断方法 | 对策 |
|---|---|---|---|
| 完全不降(一条平线) | 学习率太小;或数据/标签对错位;或梯度没回传(忘了 zero_grad 之外的某个环节) | 先看初始损失是否等于 ln(词表);再打印梯度范数是否为 0 | 放大学习率 10 倍试;用 3 条数据做过拟合测试(应能降到接近 0) |
| 剧烈震荡(锯齿形) | 学习率偏大;或批太小噪声过大;或数据里混入极端样本 | 看梯度范数是否频繁触发裁剪 | 降学习率、加大批、加长 warmup |
| 突然发散(尖刺后飙到 NaN) | 遇到脏样本产生巨大梯度;或 FP16 溢出;或某层数值不稳定 | 定位是哪一步崩的,回看那批数据 | 回滚到上一个检查点、跳过该批数据、换 BF16、加严裁剪 |
| 训练降验证升(分叉) | 过拟合——模型开始背训练集 | 看两条曲线何时开始分开 | 早停、加权重衰减、加数据、加 dropout |
| 降到某个值就卡住 | 可能已接近数据的熵下限;也可能学习率衰减过快、或模型容量不足 | 算一下当前困惑度是否合理 | 先确认不是学习率问题;确实到底了就加数据或加参数 |
这里有一个每个人都该知道的调试技巧,叫单批过拟合测试:拿 2 到 3 条数据,反复训练几百步,看损失能不能降到接近 0。如果能,说明整条链路(数据加载、前向、损失、反向、更新)是通的,问题在超参或数据规模;如果不能,说明代码里有 bug——标签错位、梯度被截断、某层被冻结了。这个测试只要几分钟,能省下几天的瞎猜。
再说说损失尖刺(loss spike),这是超大模型训练最著名的顽疾。Google 的 PaLM 训练报告里记录了一件很有意思的事:他们遇到了 20 多次损失尖刺,尝试的排查发现不是单个坏样本的问题——因为从尖刺前的检查点重新开始、跳过那批数据,往往就不会再崩;但如果用同样的数据从更早的检查点开始,也不一定崩。换句话说,尖刺是"特定参数状态 + 特定数据批次"的组合触发的,而不是数据本身有毒。
所以工业界的应对是纯工程化的:频繁存检查点 + 自动检测 + 自动回滚跳批。监控脚本发现损失比移动平均高出若干个标准差,就自动回退到上一个检查点,跳过接下来的几百个批次继续跑。这听起来很粗暴,但它是万卡集群上唯一可行的办法——你不能让几千张卡停在那儿等人来分析。
最后是关于"损失该降到多少"的现实感。缩放定律给出的关系是幂律:损失随算力、参数、数据以幂律形式下降。这意味着一个残酷的事实——想让损失再降 0.1,需要的算力可能要翻倍。所以到了训练后期,一条几乎水平的曲线并不代表失败,反而是正常的。判断"要不要继续训"的依据不该是损失曲线的斜率,而是下游任务评测有没有还在涨。
损失函数告诉模型"你错在哪、错多少";梯度告诉模型"该往哪个方向改";优化器决定"每一步的步长和节奏"。这三样合起来,就是神经网络学习的全部机制。听起来简单,但正是这个"一小步一小步下山"的朴素办法,让几十亿参数的巨兽学会了写代码、聊天、画画、推理——数学的力量,就在于此。
十条要点收束这一节:(1) 交叉熵的信息论含义是"模型对真实答案有多惊讶",训练就是让它不再感到意外;(2) 它配 softmax 的梯度是干净的 p − y——错得越离谱梯度越大、永不饱和,这是 MSE 做不到的;(3) 困惑度 = e^损失,等于"平均在几个选项里犹豫",从 12.8 万降到 5-6 就是整个训练过程,而 ln(词表) 是检查代码有没有 bug 的第一道关;(4) 高维损失曲面里鞍点远多于局部极小点,"陷入局部最优"基本不是真问题;(5) 实际用的都是小批量梯度下降,而梯度噪声是免费的正则化——它帮模型落进泛化更好的平坦谷底;(6) 动量一个参数解决三个问题:加速、抑震荡、逃鞍点;(7) Adam 的本质是把每个参数的更新量归一化到同一尺度,AdamW 则把权重衰减从自适应缩放里解耦出来;(8) warmup 是为了熬过 Adam 二阶动量估计不准的初期,余弦衰减要预知总步数,WSD 则可以随时收尾;(9) 梯度裁剪阈值 1.0 + BF16 是训练不崩的两道标配保险,BF16 胜过 FP16 是因为范围比精度更重要;(10) 损失曲线的五种异常形状各有病因,而"拿 3 条数据看能不能过拟合到 0"是性价比最高的调试手段。