交叉熵过早归零:浮点精度边界复现
作者使用 NumPy 复现 softmax 与交叉熵的数值问题:较大 logit 间隔下,浮点舍入会让损失及目标类别梯度归零,而其他类别梯度仍非零。素材给出的临界间隔约为 bf16 的 5.5、fp32 的 16.6 和 fp64 的 36.7。
AI 深度解读
交叉熵损失显示为 0,模型就真的没有误差了吗?作者用 NumPy 1.26.4 复现了两类数值问题:模型非常自信时,目标类别的梯度会先归零;学生模型接近教师时,蒸馏梯度可能被舍入误差淹没。两者都和浮点运算的计算顺序有关。
这篇最有用的地方,是把问题落到了可检查的数字上,也划清了证据边界:作者验证了底层算术,没有复跑论文里的训练实验。对程序员来说,它提醒我们,公式等价,不代表代码算出来也等价。
为什么 loss 已经是零,参数却还在更新
对于硬标签,也就是每个样本只有一个正确类别的情况,交叉熵可以写成:
loss = log(1 + exp(-m))
其中 m 是目标类别的 logit 减去其他类别的 log-sum-exp。可以把它理解为:正确答案领先其他答案多少。这里用的是自然对数,间隔的单位是 nat。
问题出在接近 1.0 的加法。fp32 在这个位置的半个 ulp 约为 5.96e−8。ulp 可以理解为相邻浮点数之间的距离。当 exp(-m) 太小时,1 + exp(-m) 会直接舍入成 1.0,随后取对数就得到零。
目标类别的梯度是 q_t − 1。当目标概率 q_t 被舍入成 1,这个梯度也会归零。但其他类别的概率靠近零,那里的浮点间距更细,微小梯度仍能保留下来。
loss 归零时,目标梯度和其他类别梯度可能已经失去平衡。
论文给出的理论断崖约为 bf16 的 5.5、fp32 的 16.6、fp64 的 36.7。作者则用三分类问题、以 0.1 为步长扫描,测得 float16、float32、float64 首次返回零的位置分别为 7.7、16.0、36.1;按 −log(eps/2) 算出的理论值分别是 7.62、16.64、36.74。注意,实测里的 float16 与前面提到的 bf16 是不同格式。
为什么实测更早?因为小数可能分散在多个类别里,逐个加到 1 上时分别被吞掉。作者在 fp32 下测到:K=2 时断崖为 16.7,K=3 为 16.0,K=11 为 15.8,K=101 为 16.0。
在 margin 为 16 时,两个非目标指数项各约为 5.63e−08。先把它们相加,再加 1,还能留下结果;先让它们各自与 1 相加,就可能全部消失。因此,15.8–16.7 是作者这些实验里的范围,不能当成所有框架的固定边界。
损失在归零前也会失真。m=15 时,标准结果为 2.3842e−07,修正版约为 3.0590e−07,误差已经达到约 22%。
蒸馏接近收敛时,为什么梯度反而不可信
第二个问题发生在软标签训练中。蒸馏的梯度可以写成 q − p,即学生概率减去教师概率。
学生离教师很远时,两边的差值足够大,舍入误差不显眼。两者越来越接近后,真实差值变小,而两次 softmax 各自产生的舍入误差未必同步变小。两个近似值相减,就可能把误差留在结果里。
作者采用教师 logits [2, 1, 0],给学生加入 [−1e−7, +1e−7, 0] 的扰动。fp32 实际存下来的扰动是 [−1.19e−07, +1.19e−07, 0]。下面是原文的梯度对照:
standard grad: [ 0.0, 4.470e-08, 2.235e-08] sum = 6.7e-08
fixed grad: [-4.596e-08, 4.144e-08, 4.513e-09] sum = -2.2e-15
exact (fp64): [-4.596e-08, 4.144e-08, 4.513e-09]
重点看第一行的首个分量:它已经变成零,而参考值约为 −4.6e−08;第三个分量的误差则达到约 395%。
修正版按论文 Eq. 9,借助 expm1、log1p 在对数空间组织差值计算。它们分别适合计算接近零时的 exp(x)−1 和 log(1+x),避免先算出接近 1 的数,再丢掉微小差异。
扰动从 1e−3 降到 1e−7 时,标准实现的最大误差仍在约 1e−8–5e−8;修正版的最大误差则从 2.27e−11 降到 9.16e−16。在 δ=1e−7 的例子里,标准误差约为真实梯度范数的 74%。继续降到 5e−8,扰动本身就在该输入上被 fp32 舍入掉了,后续算法也无从恢复。
这里还有一个反例:论文称,师生完全相同时,也可能因前向、反向的运算路径不同而出现非零梯度。作者的 NumPy 实验得到的却是严格零。因此,这个子结论依赖具体内核,不能直接推广。
修复的关键,是别先把微小差值算丢
对于硬标签,作者先排除目标类别计算 margin,再用 softplus 求损失,并通过其他类别的梯度构造目标梯度:
def fixed_ce_and_grad(z, t):
diffs = np.array([z[j] - z[t] for j in range(len(z)) if j != t],
dtype=z.dtype)
mx = np.max(diffs)
m = -(mx + np.log(np.sum(np.exp(diffs - mx)))) # 间隔计算排除目标类别
loss = np.logaddexp(np.zeros((), dtype=z.dtype), -m) # softplus(-m)
g = np.zeros_like(z)
gj = np.exp(diffs - loss) # 非目标类别梯度
idx = 0
for j in range(len(z)):
if j != t:
g[j] = gj[idx]; idx += 1
g[t] = -np.sum(gj) # 由其他梯度构造目标梯度
return loss, g
最关键的是 g[t] = -np.sum(gj):目标梯度不再单独通过 q_t − 1 计算,因此不会独自消失。这个构造在数学上保持梯度和为零,但实际再做浮点求和时,仍可能留下舍入残差。
在 z = [9.0, −9.0, −9.0]、目标为 0 的 fp32 示例中,标准 loss 为 -0.0,目标梯度为零,另外两个梯度各为 1.523e−08;修正版 loss 为 3.046e−08,目标梯度为 −3.046e−08,与 fp64 参考结果吻合。
作者没有通过转成 fp64 获得这些修复结果,也没有更换损失目标。改变的是运算组织方式。不过,上面的代码只是 NumPy 演示,原文没有提供完整的软标签修复代码,也没有给出生产内核的吞吐、显存或耗时对照。
我的判断
这篇最扎实的证据,是输入、精度、误差和反例都比较具体。尤其是断崖随类别数、求和顺序变化,说明复现时应该检查现象,而不是执着于是否恰好在 16.6 归零。
但作者开头转述的「所有主要框架都有问题」,超出了他这次 NumPy 实验直接覆盖的范围。他解析计算了 q − p,并未逐个验证框架的融合交叉熵内核、混合精度路径和反向实现。具体项目是否受影响,仍要测实际调用链。
Adam 会按梯度的历史量级调整更新尺度,因此这种不对称可能影响训练。可它是否导致 grokking 实验里的「slingshot」损失尖峰,作者没有复跑,只引用了论文证据。更不能由此推导出所有训练任务都会得到可观收益。
相比直接提高精度,调整公式在这些样例里很有效;相比替换成熟框架算子,它还缺性能和训练效果的对照。我的取舍是:把它当作数值诊断依据很有价值,把它当作普遍提分方案则证据不足。
能拿来做什么
- 维护训练脚本或自定义 loss 时,可以用
[9.0, −9.0, −9.0]做一个小型诊断,同时查看 loss、目标梯度和梯度总和,再扩展扫描 margin 与类别数。 - 做蒸馏时,可以对接近教师的学生 logits 逐步减小扰动,并与 fp64 参考结果比较,检查梯度是否已经被固定量级的误差盖住。
- 在 Node 或前端编写数值演示时,可以用求和顺序不同却产生不同结果的例子解释浮点误差;实际训练仍应检查所用后端和 dtype,不能直接套用 NumPy 的断崖位置。
- 准备引入修正版算子时,先验证数值,再比较实际训练曲线与运行成本;原文尚未证明它能给一般团队带来多少收益。