相对精度与绝对精度:为什么同一个数字,换个地方就“消失”了

浮点数有一个容易让人困惑的现象:一个很小的数单独存放时还好好的,加到 1.0 上却可能直接消失。

这不是加法公式写错了,而是结果需要被舍入到有限的可表示数上。要理解它,先要区分两个概念:相对精度描述有效数字有多少,绝对间隔描述当前数量级上相邻浮点数相差多少。

这个差别最近在一个 softmax 交叉熵问题中变得很具体:模型已经极其确信正确类别,目标梯度却被算成了零,而其他类别的梯度仍然非零。本文会从浮点数的表示开始,沿着舍入、softmax 和梯度一路拆开这个现象。

先看结果:梯度少了一块

考虑 logits [9.0, -9.0, -9.0],目标类别是第 0 类。模型给正确类别的概率已经非常接近 1。数学上,交叉熵和目标梯度都很小,但不应该突然变成零。

在论文测试的标准实现中,前向计算可能得到目标 log-prob 和 loss 均为 0.0,反向计算得到目标梯度 0.0;另外两个类别的梯度却各自约为 1.523e-8。于是三个梯度之和不再为零。

这类精度问题被称为 Softmax Collapse。在特定训练设置中,它与 loss 尖峰、grokking 变慢以及准确率退化有关;这些是论文报告的实验现象,不代表每个模型都会出现同样的训练结果。

浮点数:有效数字乘上数量级

以 IEEE 754 的 fp32 正规数为例,它由符号位、指数域和尾数域组成。除去特殊值后,可以把它写成:

这里的 $e$ 是去掉偏置后的指数,$1.f$ 是有效数。fp32 有 23 个显式尾数位,再加上隐含的前导 1,总共 24 位二进制有效数字。指数则把这组有效数字缩放到不同数量级。

因此,“尾数决定精度”需要说完整:尾数主要决定相对精度;指数会改变相邻数的绝对间隔。

相对精度:有效数字有多少

fp32 在 $1.0$ 右侧的下一个可表示数是 $1+2^{-23}$,所以该处的 ULP(相邻可表示数的间隔)为:

这通常也被称为 fp32 的 machine epsilon。严格地说,最近舍入的最大相对误差约为半个 ULP,也就是 $2^{-24}$;不同资料有时会把 machine epsilon 和 unit roundoff 混用。

在同一个二进制数量级区间(binade)内,相邻数的相对间隔大致保持在 $2^{-23}$ 的量级。因此 fp32 通常能提供约 7 位十进制有效数字。bf16 的显式尾数只有 7 位,在 1.0 右侧的间隔是 $2^{-7}$,相对精度明显低于 fp32。

绝对间隔:相邻数实际差多少

把指数 $e$ 固定后,尾数增加一格,数值会增加:

指数变了,这个绝对间隔也随之按比例改变。比如:

数值位置 fp32 相邻数间隔(约)
$1.0$ 附近 $2^{-23}\approx1.19\times10^{-7}$
$10^{-10}$ 附近 $2^{-57}\approx6.94\times10^{-18}$

第二行来自 $10^{-10}$ 落在 $[2^{-34},2^{-33})$ 这一段;该段中每个间隔为 $2^{-34}\times2^{-23}=2^{-57}$。和 $1.0$ 附近相比,绝对间隔相差约 10 个数量级,而尾数位数并没有变化。

科学计数法里,$1.234\times10^0$ 和 $1.234\times10^{-10}$ 都有相同数量的有效数字,但它们对应的绝对误差尺度不同。二进制浮点数也是如此:绝对间隔约等于相对间隔乘以当前数值的数量级。

小数加到 1.0 上,为什么会消失?

softmax 为避免指数溢出,会先减去最大 logit。正确类别是最大项时,分母可以写成:

其中 $t$ 是目标类别,$m$ 是目标 logit 与其他类别 log-sum-exp 的 margin:

在 $1.0$ 附近,fp32 的 ULP 是 $2^{-23}$,最近舍入的分界约为半个 ULP,即 $2^{-24}\approx5.96\times10^{-8}$。如果 $e^{-m}$ 小于这个量级,那么精确的 $1+e^{-m}$ 与 1.0 太近,舍入后就是 1.0。

于是:

标准反向公式再计算 $q_t-1$,得到 0.0。前向的 $\log(1+e^{-m})$ 也可能因同样的舍入变成 log(1)=0。

这个 cliff 的近似位置由半个 ULP 决定:

格式 1.0 右侧 ULP 半个 ULP precision cliff(约)
bf16 $2^{-7}$ $2^{-8}$ $5.55$
fp32 $2^{-23}$ $2^{-24}$ $16.64$
fp64 $2^{-52}$ $2^{-53}$ $36.74$

在 1 + e^-m 里,小量被迫和 1.0 的尺度比较;它不是“自己太小而无法表示”,而是它不足以让和数跨过 1.0 与下一个可表示数之间的舍入中点。

同一个小量,单独存放时仍然可见

若把 $10^{-10}$ 单独保存,它的指数也会很小。在 $10^{-10}$ 附近,间隔约为 $6.94\times10^{-18}$,远小于 $10^{-10}$ 本身,因此它可以被表示得相当精确。

计算 fp32 中的典型结果 原因
$1+10^{-6}$ 约 $1.000001$ 小量足以改变 1.0 附近的舍入结果
$1+10^{-8}$ $1.0$ 小量低于半个 ULP
$0+10^{-20}$ 约 $10^{-20}$ 数值按自身指数表示
$0+10^{-40}$ 次正规数范围内的非零值(若未启用 FTZ) fp32 可渐进下溢

fp32 的最小正规正数约为 $1.18\times10^{-38}$;IEEE 754 次正规数还能把范围延伸到约 $1.40\times10^{-45}$。如果硬件或算子启用了 flush-to-zero(FTZ),次正规数可能被直接冲成零,下溢范围就会提前。它与“加到 1.0 上被舍入吞掉”是两种不同的精度边界。

回到 Softmax Collapse

对硬标签交叉熵,目标类别的精确梯度可以改写为:

非目标类别的梯度是:

两种写法在实数数学中等价,但浮点运算路径不同。1 / (1 + e^-m) - 1 先把小量加到 1 上,再相减;当分母舍入成 1 时,目标梯度就归零。-e^-m / (1 + e^-m) 则把小量留在分子中,分母即便舍入为 1,小梯度仍然保留到指数函数真正下溢为止。

对于 [9.0, -9.0, -9.0],两个非目标指数之和为 $2e^{-18}\approx3.046\times10^{-8}$,所以 margin 是 $m=-\ln(2e^{-18})\approx17.307$。修复后的梯度为:

它们在这个对称例子中相加为 0。注意,每个非目标项单独是 $e^{-18}$,但这里定义的 margin 使用的是所有非目标指数的总和,因此不能把 $m$ 直接写成 18。

稳定实现的思路

修复的关键不是把整个计算都换成更高精度,而是避免构造那个会丢失小量的 1 + e^-m:

  1. 找到最大项,并在计算 log-sum-exp 时把它排除;目标类已知时,也可以直接排除目标项。
  2. 用 logsumexp 得到 margin,再用稳定的 softplus 计算 $\log(1+e^{-m})$。
  3. 目标梯度保持为 $-e^{-m}/(1+e^{-m})$,避免先算 $q_t$ 再减 1。

硬标签交叉熵的 PyTorch 写法可以按这个结构实现:

1
2
3
4
5
6
7
8
9
10
11
12
import torch
import torch.nn.functional as F


def stable_hard_cross_entropy(logits, target):
# logits shape: (..., classes); target shape: (...)
z = logits.float()
z_t = z.gather(-1, target.unsqueeze(-1)).squeeze(-1)
is_target = F.one_hot(target, num_classes=z.shape[-1]).bool()
other_logits = (z - z_t.unsqueeze(-1)).masked_fill(is_target, -torch.inf)
margin = -torch.logsumexp(other_logits, dim=-1)
return F.softplus(-margin).mean()

这里把 logits 提升到 fp32,并用 logsumexp 与 softplus 稳定前向计算。代码用于说明公式结构;生产环境还应检查目标索引、空类别维度、混合精度策略和具体框架的自动微分实现。更完整的 softmax 与软标签交叉熵修复还需要处理其他精度问题,例如蒸馏中两个接近分布相减时的 Softmax Cancellation。

总结

尾数位数决定有效数字规模,也就是相对精度;指数改变数量级,因而改变相邻浮点数的绝对间隔。e^-m 单独保存时可以使用自身的小指数;加到 1.0 上时,结果处在 1.0 的尺度,只能按那里的粗间隔舍入。

Softmax Collapse 正是这个尺度差异进入梯度公式后的结果。数值稳定实现会保留小量的计算路径,不让一个本来仍可表示的梯度先在 1 + 小量 中消失。

参考资料