跳到内容

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 的数相减。

python
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}}. $$

python
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

这些方法提高精度,不保证任意输入上的精确结果。并行系统还需在速度、确定性和可复现性之间权衡。

python
import math

values = [1e16, 1.0, -1e16]
print(sum(values))        # 常见结果:0.0
print(math.fsum(values))  # 1.0

6. 选择公式时先看边界

数值稳定的实现经常使用专门函数:

  • 小 $x$ 时用 log1p(x) 计算 $\log(1+x)$;
  • 小 $x$ 时用 expm1(x) 计算 $e^x-1$;
  • hypot(x, y) 计算 $\sqrt{x^2+y^2}$,降低中间上溢或下溢风险;
  • 解线性方程用分解和 solve,不显式求逆;
  • 概率损失尽量从 logits 直接计算,不先舍入成概率。

“数学等价”不保证“浮点执行等价”。算法设计要把中间值范围、消减、分支连续性和舍入次数纳入考虑。

7. 数值代码的验证清单

  1. 使用解析答案或高精度结果建立小规模基准;
  2. 测试零、极小量、极大量、相近值和符号变化;
  3. 明确是否接受 NaN、无穷大和次正规数;
  4. 检查绝对误差、相对误差、残差或守恒量;
  5. 改变运算顺序、批大小和并行度,观察敏感性;
  6. 对迭代算法记录收敛历史,不只看最后一次输出;
  7. 记录数据类型、舍入规则、库版本和硬件假设。

误差阈值应与问题尺度相连。只断言“小数点后六位相等”,可能对零附近太宽松,对大数又太严格。

常见误区

  • 公式正确,实现就正确:中间运算可能溢出、下溢或消减。
  • 高精度能修好任何问题:它推迟误差暴露,但不修复病态模型或错误算法。
  • 残差小就说明参数准确:病态问题中二者可能相差很远。
  • 稳定算法会给出精确答案:稳定性控制误差放大,不消除输入和舍入误差。

练习

  1. 比较 sqrt(1+x)-1 两种公式在不同 $x$ 下的相对误差。
  2. 实现稳定的 logsumexp,并与直接公式比较大输入结果。
  3. 构造一个条件数较大的 $2\times2$ 线性系统,轻微扰动 $b$ 后比较解。
  4. 为批量聚合服务设计“快速非确定求和”和“可复现求和”两种模式。

小结

条件数描述问题对扰动的先天敏感性,稳定性描述算法是否额外放大误差。识别消减、控制中间值范围、选择稳定函数并验证极端输入,是把数学公式变成可靠软件的必要步骤。

数学塔的专业内容到这里收束:逻辑帮助你写清命题,离散结构帮助你组织关系,概率帮助你处理不确定性,线性代数与微积分帮助你建模和优化,信息论与数值分析则告诉你表达和计算本身的边界。馆长与塔中的故事还没有结束;在全站技术校验完成后,这些知识会重新接回人物的选择、失败和下一站。

Built with VitePress | Software Systems Atlas