本节摘要:梯度爆炸会让训练几步内跳到 NaN,梯度裁剪则给梯度设一道"刹车"。本节从爆炸的成因讲起,分词剪法与范数剪法,给出阈值设定建议与早停判断,最后用一段能改的理论演示如何靠裁剪救回一个爆炸中的训练。
第 2 章讲过梯度爆炸是三大顽疾之一。这一节给它上专门的刹车——梯度裁剪。它不改变方向,只限制每个梯度的"冲击力",让训练不至于因为一个失控的梯度瞬间崩盘。
梯度爆炸多半源于反向后链式连乘,某层梯度乘出极大的数,一步就把参数顶飞,损失直接 NaN。常见触发:层太深、激活在饱和区、权重初始化过大、或天涯学习率。
其典型信号非常醒目:训练几步以内损失变为 NaN(不是 0/1,是"不是数"),伴随权重出现极大值甚至无穷。
import math def clip_by_norm(grad, max_norm=1.0): norm = math.sqrt(sum(g * g for g in grad)) if norm > max_norm: scale = max_norm / norm return [g * scale for g in grad] return grad
范数裁剪的直觉:不改变"往哪个方向"的信息,只是把这个方向的"速度"限制在阈值内,让你不会因为一次巨振直接翻车。
裁剪是刹车,不是治本。它把损失从 NaN 拉回来,但根本诱因(比如初始化过大、学习率过高)不修,就会反复爆炸。所以正确的使用法是:
💡 关键直觉:看到 NaN,第一反应不是处理 NaN,而是问"是什么把它顶成 NaN"。裁剪是止损,根因排查(学习率/初始化/激活)才是祛病。
你训一个深层网络,第 3 步损失变 NaN。你打开梯度裁剪,阈值 1.0,训练终于不再崩,但损失卡着不动——原来学习率还是偏大。你把学习率从 1e-2 降到 1e-3,同时保留裁剪作保险,训练平稳收敛到 0.1 以下。
这个案例的价值在于:裁剪让你第一次"看清"真实训练,但真正让模型变好的,是你在它有保护之后再去调的根因。
| 现象 | 信号 | 处理 |
|---|---|---|
| 梯度爆炸 | 几步内 NaN | 裁剪 + 降学习率 + 查初始化 |
| 裁剪后仍卡 | 损失不动 | 根因可能是学习率或激活 |
| 裁剪失效大概率 | 反复爆炸 | 回到初始化/架构找问题 |
逐值裁剪把每个分量各自钳进 [−c, c],实现最快、直觉也最直白,但它有个副作用:它可能改变梯度的方向比例。 举个极端例子,一个方向梯度是 [1000, 1],逐值钳到 1.0 后变成 [1, 1]——原来 1000 的主力方向被压得和 1 一样大,方向信息被严重扭曲。范数裁剪则只把整个向量缩放到阈值 c,[1000, 1] 会按同一比例缩放成接近 [c×0.999, c×0.001],比例完好、只是整体限了速。
所以准则很简单:只想"限大小"就用范数裁剪(更保方向),确需逐分量控制才用按值裁剪。 框架默认多走范数这条路是有原因的。
裁剪作用在梯度那一环、在优化器把梯度变成更新之前的入口处。它跟动量、自适应并不冲突,也不该互相替代:动量让方向更顺,自适应让各参数各走各的,裁剪则给整体冲击力设上限。三者工作在不同环节,可以同时开着。唯一要注意的是,别把"被裁剪"误当成"训练已经安全"——裁剪只是止损,真正的根因(学习率、初始化)没修,你只是在靠安全垫勉强开。
与其拍脑袋设一个 1.0,不如让数据替你说话:抽几个 batch,在裁剪前打印每 batch 的梯度范数,看它们的分布。
# 概念片段:看一批的梯度范数分布来定阈值 for i, (x, y) in enumerate(data): loss = model(x) loss.backward() total_norm = sum(p.grad.norm().item() ** 2 for p in model.parameters()) ** 0.5 print(f"batch {i}: grad norm = {total_norm:.3f}")
如果一个 batch 的范数大多落在 [0.3, 2.0],只有个别随机冲到 50,那把阈值设在 5.0 左右就很合理——恰好挡住极端值,又不压扁正常量级。阈值要"恰好拦下最暴的脉冲",而不是"看上去很大"或"看上去很小"。
把裁剪加上、损失回到非 NaN,只能算"止血"。接下来要按顺序复查真正的病根:
只有把这三点里的"炸药"拆掉,裁剪才能从"常开的安全垫"降级为"偶尔兜底的保险",不再是你训练日常里离不开的拐杖。
刹车装好了,一台车还没"调校到位"。下一节去调校车间,用超参数调优找到最顺手的配比。