有人把softmax和交叉熵的数值问题摆上了台面。Jon Barron发布论文《A Fix for Softmax and Cross-Entropy》,称各大框架里这两个标准实现存在两个数值bug——不是“精度不够”,而是带具体、可证伪数字的bug:bf16下margin约5.5、fp32下约16.6、fp64下约36.7处存在精度悬崖,越过之后损失和目标梯度精确舍入为零,而其他梯度不会。
精确的主张值得精确的检验。有人只用NumPy 1.26.4,一个下午复现了两个bug,没有借助任何框架,梯度按q − p解析计算——这正是各框架反向传播所计算的东西。
第一个bug:softmax塌缩,悬崖位置对得上
机制简单得近乎尴尬。对硬目标,损失是log(1 + e^(−m)),m是logit margin,即目标logit高出其余部分log-sum-exp多少。1.0附近的浮点很粗:fp32中任何小于1.0半个ulp(约5.96e−8)的量,加到1.0上都会消失。一旦e^(−m)低于这个值,分母1 + e^(−m)就舍入成精确的1.0,损失舍入成精确的0,目标梯度q_t − 1也舍入成精确的0。
非目标梯度在0附近计算,那里浮点足够细,不会被舍掉。结果是梯度向量不再求和为零——它精确等于被删掉的那个目标梯度。
对3类问题在三种dtype下扫描margin,标准损失首次精确输出0.0的位置(步长0.1):
- float16:实测7.7,理论−log(eps/2)为7.62
- float32:实测16.0,理论16.64
- float64:实测36.1,理论36.74
与论文数字接近但不完全相同,差异本身才是有意思的部分。论文的悬崖假设小项一次性与1.0相加;真实归约中e^(−m)被拆到各非目标类上,每一片单独与半个ulp比较。fp32下检查类别数依赖:K=2时悬崖在margin 16.7,K=3时16.0,K=11时15.8,K=101时16.0。
原始舍入实验能解释这种散布。margin为16时,两个非目标指数各是5.63e−08,刚好低于1.0的半个ulp:sum([1.0, a, a])得1.0,log为0.0;sum([a, a, 1.0])得1.0000001,log为1.19e-07。同样三个数,顺序不同,一个活下来一个没有。所以“fp32悬崖”不是自然常数,而是归约树的属性——顺序求和还是成对求和、多少类、kernel怎么分块。fp32下它落在15.8–16.7这个带里,论文的16.6是干净的两类理想化。
论文的算例逐位复现。z = [9.0, −9.0, −9.0]、目标为0、fp32下:标准实现损失−0.0,梯度[0.0, 1.523e-08, 1.523e-08],和为3.046e-08;修复版损失3.046e-08,梯度[−3.046e-08, 1.523e-08, 1.523e-08],和为0.0,与fp64参考一致。
margin扫描还显示损伤不是干净的台阶。m=15时标准损失读到2.38e−07,正好是2^−22即一个ulp,真值为3.06e−07,误差22%——距离悬崖还有一个nat。梯度和在m=5时也从不精确为零,因为softmax按exp(log_softmax)求值,fp32下每个margin都有约1e−7的舍入残差;越过悬崖后,这点残差不再是噪声,而成了整个梯度。
为什么重要?因为Adam。Adam用梯度的滑动幅度做归一化,目标梯度在悬崖处闪断、非目标梯度照常,正好产生论文归咎于grokking实验中“弹弓”式损失尖峰的那种不对称更新。复现者没有重训grokking模型验证这一环,训练曲线是论文自己的证据;能确认的是其下的算术:闪断真实存在,起点也在论文所说之处,误差取决于你的归约树。
第二个bug:softmax抵消有硬误差地板
第二个bug关于蒸馏:梯度为q − p,学生softmax减教师softmax。学生接近教师时,等于两个几乎相等的概率向量相减,微小的真实差异淹没在各自的舍入里。
论文算例:教师[2, 1, 0],学生扰动[−1e−7, +1e−7, 0],fp32实际存为[−1.19e−07, +1.19e−07, 0]。NumPy复现结果:标准梯度[0.0, 4.470e-08, 2.235e-08],和为6.7e-08;修复版[−4.596e-08, 4.144e-08, 4.513e-09],和为−2.2e-15,与fp64精确值一致。标准梯度第一分量精确为0.0,真值是−4.6e−08,误差100%;第三分量偏差395%。修复版(按论文式9在对数空间用expm1/log1p计算q − p)与fp64参考吻合到9.2e−16,最差分量误差从4.6e−08降到9.2e−16,八个数量级的差距只来自重新排列算术。
扫描扰动幅度显示这是地板而非比例误差:δ=1e−03时标准最大误差5.27e−08,δ=1e−05时4.55e−08,δ=1e−06时1.12e−08,δ=1e−07时4.60e−08;δ=5e−08时扰动本身在fp32中已无法表示。无论真实梯度多小,标准实现的误差都停在约1e−8到5e−8。δ=1e−7时误差达到梯度自身范数的74%。蒸馏的后期训练恰好处在这个区间——学生贴近教师、真实梯度极小——标准软目标损失在那里基本只返回带对符号的舍入噪声。
复现者还报告了一个诚实的否定结果:论文称学生与教师完全相等时梯度也非零,因为前向和反向通过不同算子序列求值softmax;在NumPy设置中两条路径恰好是相同运算,学生教师完全相同时得到精确的0.0。这个子论断取决于框架kernel,但上面的误差地板不取决于——只要δ小且非零,它就会出现。
修复便宜得离谱
对硬目标,永远不要构造1 + e^(−m)。排除目标后计算margin并用softplus。关键在最后一行:目标梯度定义为其余梯度之和的相反数,它就无法独立舍掉,梯度按构造求和为零。上面每个修复后的数字都出自约40行这样的代码,不需要转fp64,也不需要替代损失函数。
两点结论。其一,两个bug都真实存在,量级与论文所称相符。用fp32训练交叉熵的人——也就是所有人——损失在你最有把握的样本上一直精确报告0.0,同时仍在推动非目标logit;蒸馏梯度则带着约5e−8的噪声地板,任何学习率调度都修不好。其二,悬崖不是常数,它随类别数和求和顺序移动了将近一个nat。谁想用“损失是否恰好在16.6归零”来检查自己的设置,量到的是自己的归约树,不是这个现象本身。现象是一个带,你的kernel落在其中某处。
热门跟贴