12.3 条件数与数值稳定性:让误差不被算法放大
你的模型损失突然变成 NaN。公式在纸上没有除以零,输入数据也不是无穷大。问题出在计算路径:指数先上溢,两个接近的大数又发生消减,微小输入误差最后被放大成完全不同的答案。
数值分析要分清两件事:问题本身是否敏感,以及算法是否额外放大了敏感性。
本课目标
- 区分前向误差、后向误差与问题条件;
- 识别灾难性消减、上溢、下溢和累计误差;
- 实现稳定的 softmax、log-sum-exp 与求和;
- 为数值代码设计边界测试和误差检查。
1. 条件描述问题,稳定性描述算法
若输入 $x$ 有小扰动 $\Delta x$,输出 $f(x)$ 的变化可能很小,也可能很大。条件数概括问题对输入扰动的敏感程度。
对可逆矩阵的线性系统 $A\mathbf{x}=\mathbf{b}$,某个矩阵范数下的条件数是:
$$ \kappa(A)=\lVert A\rVert\lVert A^{-1}\rVert. $$
条件数很大时,输入或舍入的微小相对误差可能在解中被显著放大。条件数为无穷对应奇异矩阵。条件数依赖所选范数,也不单独给出每次计算的实际误差。
算法的数值稳定性则问:计算结果是否接近某个轻微扰动输入的精确答案。一个病态问题无法靠稳定算法变得不敏感,但不稳定算法会让原本条件良好的问题也产生糟糕结果。
2. 前向误差与后向误差
设精确答案为 $y=f(x)$,计算答案为 $\hat y$:
- 前向误差比较 $\hat y$ 与 $y$;
- 后向误差寻找一个小扰动 $\Delta x$,使 $\hat y=f(x+\Delta x)$。
后向稳定意味着算法算出的结果,可以解释为“对几乎相同的输入做了精确计算”。若问题条件良好,小后向误差通常导向小前向误差;若问题病态,即使后向稳定,前向误差也可能很大。
这解释了为什么只打印最终小数位不够。还要检查残差、输入尺度和条件数。例如线性系统可检查:
$$ \mathbf{r}=\mathbf{b}-A\hat{\mathbf{x}}. $$
小残差是重要信号,但病态系统中“小残差”和“解接近真实参数”仍不是同一件事。
3. 灾难性消减
两个非常接近的浮点数相减时,前面的有效位被抵消,输入中已有的舍入误差相对于结果会被放大。
典型例子是小 $x$ 时计算:
$$ \sqrt{1+x}-1. $$
把分子有理化:
$$ \sqrt{1+x}-1 =\frac{x}{\sqrt{1+x}+1},
$$
数学上等价,数值上后者避免了两个接近 1 的数相减。
import math
def unstable(x: float) -> float:
return math.sqrt(1.0 + x) - 1.0
def stable(x: float) -> float:
return x / (math.sqrt(1.0 + x) + 1.0)
for x in [1e-8, 1e-12, 1e-16]:
print(x, unstable(x), stable(x))“避免大数减小数”不是准确规则;真正危险的是两个接近的数相减,尤其结果远小于操作数时。
4. 稳定的 softmax 与 log-sum-exp
直接计算
$$ \operatorname{softmax}(x_i)=\frac{e^{x_i}}{\sum_j e^{x_j}} $$
可能因大正数上溢。利用平移不变性,令 $m=\max_i x_i$:
$$ \operatorname{softmax}(x_i) =\frac{e^{x_i-m}}{\sum_j e^{x_j-m}}. $$
import math
def softmax(values: list[float]) -> list[float]:
if not values:
raise ValueError("values 不能为空")
if not all(math.isfinite(value) for value in values):
raise ValueError("示例只接受有限输入")
maximum = max(values)
shifted = [math.exp(value - maximum) for value in values]
denominator = math.fsum(shifted)
return [value / denominator for value in shifted]
print(softmax([1000.0, 1001.0, 1002.0]))对应的 log-sum-exp:
$$ \log\sum_i e^{x_i} =m+\log\sum_i e^{x_i-m}. $$
减去最大值让最大指数为 1,避免正向上溢。非常小的项仍可能下溢为零,但它们对总和的相对贡献本来就可能低于当前精度;是否可接受要由误差要求决定。
5. 累计求和也需要算法
大量不同数量级的数相加时,小量可能不断被舍掉。改进方法包括:
- 从绝对值较小的数开始求和;
- 成对求和,降低误差随项数增长的速度;
- Kahan 等补偿求和,保留部分丢失低位;
- 使用语言或库提供的高质量实现,如 Python
math.fsum。
这些方法提高精度,不保证任意输入上的精确结果。并行系统还需在速度、确定性和可复现性之间权衡。
import math
values = [1e16, 1.0, -1e16]
print(sum(values)) # 常见结果:0.0
print(math.fsum(values)) # 1.06. 选择公式时先看边界
数值稳定的实现经常使用专门函数:
- 小 $x$ 时用
log1p(x)计算 $\log(1+x)$; - 小 $x$ 时用
expm1(x)计算 $e^x-1$; - 用
hypot(x, y)计算 $\sqrt{x^2+y^2}$,降低中间上溢或下溢风险; - 解线性方程用分解和
solve,不显式求逆; - 概率损失尽量从 logits 直接计算,不先舍入成概率。
“数学等价”不保证“浮点执行等价”。算法设计要把中间值范围、消减、分支连续性和舍入次数纳入考虑。
7. 数值代码的验证清单
- 使用解析答案或高精度结果建立小规模基准;
- 测试零、极小量、极大量、相近值和符号变化;
- 明确是否接受 NaN、无穷大和次正规数;
- 检查绝对误差、相对误差、残差或守恒量;
- 改变运算顺序、批大小和并行度,观察敏感性;
- 对迭代算法记录收敛历史,不只看最后一次输出;
- 记录数据类型、舍入规则、库版本和硬件假设。
误差阈值应与问题尺度相连。只断言“小数点后六位相等”,可能对零附近太宽松,对大数又太严格。
常见误区
- 公式正确,实现就正确:中间运算可能溢出、下溢或消减。
- 高精度能修好任何问题:它推迟误差暴露,但不修复病态模型或错误算法。
- 残差小就说明参数准确:病态问题中二者可能相差很远。
- 稳定算法会给出精确答案:稳定性控制误差放大,不消除输入和舍入误差。
练习
- 比较
sqrt(1+x)-1两种公式在不同 $x$ 下的相对误差。 - 实现稳定的
logsumexp,并与直接公式比较大输入结果。 - 构造一个条件数较大的 $2\times2$ 线性系统,轻微扰动 $b$ 后比较解。
- 为批量聚合服务设计“快速非确定求和”和“可复现求和”两种模式。
小结
条件数描述问题对扰动的先天敏感性,稳定性描述算法是否额外放大误差。识别消减、控制中间值范围、选择稳定函数并验证极端输入,是把数学公式变成可靠软件的必要步骤。
数学塔的专业内容到这里收束:逻辑帮助你写清命题,离散结构帮助你组织关系,概率帮助你处理不确定性,线性代数与微积分帮助你建模和优化,信息论与数值分析则告诉你表达和计算本身的边界。馆长与塔中的故事还没有结束;在全站技术校验完成后,这些知识会重新接回人物的选择、失败和下一站。