卷 II · 放大CH 05深度 5/20

有些问题,天生就听不清

卷 I 讲的都是「误差怎么产生」。从这一章开始,问题换成「误差怎么被放大」——而放大倍数是两个独立的数相乘得来的。这一章讲第一个:它完全由问题本身决定,跟你写什么代码、用多少位精度、有多聪明,一点关系都没有。

条件数 κ★★ 威尔金森 20 根问题的属性

▷ 猜一下差多少

考虑这个多项式(它的根一眼可见,就是 1 到 20 这二十个整数):

p(x) = (x−1)(x−2)(x−3)…(x−20)

展开成系数形式:
p(x) = x²⁰ − 210x¹⁹ + 20615x¹⁸ − 1256850x¹⁷ + … + 2432902008176640000

现在把 x¹⁹ 的系数从 −210 改成 −210.00000011920928955078125——也就是减掉 2⁻²³,相对改动是 0.00000000057(约十亿分之零点六)。其他十九个系数一个不动。

问:这二十个根会怎么样?

A 几乎不动。输入只改了十亿分之零点六,输出也就在那个量级上动一动 B 每个根都动一点点,最大的那个(20)动得最多,大约动 0.001 C 二十个根里有十个当场离开实数轴,变成复数,实部最远跑到 19.5,虚部到 ±2.8 D 二十个根全部变成复数

顺便预测:如果改用 100 位十进制精度重算,情况会好转吗?

把「敏感」变成一个数

先定义清楚。假设你要算 y = f(x),而你手上的 x 有一点误差。条件数回答的问题是:输入的相对误差,会被放大几倍变成输出的相对误差?

κ = (输出的相对变化) / (输入的相对变化)

  = |Δy/y| / |Δx/x|

对可导的一元函数,取极限就是:

  κ(x) = | x · f′(x) / f(x) |

看几个具体的:

问题条件数说明
√x1/2输入错 1%,输出错 0.5%。永远良态,开方是最安分的运算之一
2错误翻倍,但也就翻倍
1/(1−x),x→1|x/(1−x)|x=0.99 时 κ=99;x=0.9999 时 κ≈10⁴
a − b,a≈b(|a|+|b|) / |a−b|★ 这就是第 3 章。a=1, b=0.999999999 时 κ = 2×10⁹
tan x,x→π/2→ ∞在奇点附近,任何函数都病态

最后再看一眼减法那一行。第 3 章讲了半天的「灾难性相消」,在这套语言里就是一句话:「两个相近的数相减」这个问题,条件数是 2×10⁹。它不是某个算法写坏了,它是问题本身长成那样。

✎ 术语正名

良态(well-conditioned)和病态(ill-conditioned)说的是问题,不是算法。这两个词在中文技术讨论里经常被拿去形容代码,那是用错了对象。

  • 「这个问题是病态的」= 输入动一点,答案就跑很远。换算法救不了,换精度只能推迟。
  • 「这个算法是不稳定的」= 问题本身没那么敏感,是你的算法自己把误差放大了。这个能救,而且通常免费。那是下一章的事。

κ 有时也叫「条件数」「病态程度」「敏感度」。这本书统一写 κ,并且永远指「相对」条件数。

威尔金森的二十个根

1963 年,英国数值分析家 James Wilkinson 在检验一个自己写的求根程序时,拿了个看起来最无害的例子来测:根是 1 到 20 的多项式。他把系数存成机器数(当时是 30 位二进制尾数),跑了一遍,结果让他愣住了。

二十年后他写了一篇文章专门讲这件事,标题叫《背信弃义的多项式》(The Perfidious Polynomial,1984,获得了当年的 Chauvenet 奖)。他在里面说,这是他数值分析生涯里最令人震撼的一次经历

下面是在这台机器上、用 2026 年的 numpy 重跑一遍的结果。x¹⁹ 的系数减掉 2⁻²³,其余不动:

# 扰动前:1, 2, 3, …, 20   (二十个整数)
# 扰动后:

 ✓  1.000000        ✓  2.000000        ✓  3.000000        ✓  4.000000
 ✓  5.000001        ✓  5.999995        ✓  6.999772        ✓  8.006981
 ✓  8.917724                            ✓ 20.846908

 ✗ 10.094982 ± 0.643556i
 ✗ 11.793686 ± 1.652561i
 ✗ 13.992435 ± 2.518874i
 ✗ 16.730762 ± 2.812625i
 ✗ 19.502444 ± 1.940328i

★ 二十个根里,十个离开了实数轴。

把 Wilkinson 当年发表的数字拿来对一下:他给出的那对最远的复根是 16.730 73 ± 2.812 62 i。上面这台机器算出来是 16.730762 ± 2.812625i——前六位相同。六十多年、两套完全不同的硬件和算法,撞在同一个数上。

(顺带说一句:剩下那点差异本身也是这一章的注脚。求这个多项式的根,问题本身就是病态的,所以连「标准答案」都只能对到六位。这不是谁算错了,是这个问题的信息量就只有这么多。

为什么是这些根

每个根对某个系数的敏感度可以直接算出来。根 rx¹⁹ 系数的敏感度是 |r¹⁹ / p′(r)|

根 r敏感度 |r¹⁹/p′(r)|
18.22 × 10⁻¹⁸纹丝不动
50.608还好
107.59 × 10⁶开始不行了
152.12 × 10⁹
162.41 × 10⁹★ 最敏感的一个
171.90 × 10⁹
204.31 × 10⁷两头的根反而稳一些

系数动了 2⁻²³ ≈ 1.19×10⁻⁷,乘上根 16 的敏感度 2.41×10⁹,得到 287——按线性估计,根 16 应该跑出去两百多。当然它没跑那么远(线性估计在这个尺度上早就失效了),但方向是对的:这些根根本没打算待在原地。

为什么中间的根最敏感?因为 p′(r) 是「r 到其他所有根的距离之积」。根 16 的两边各有十九个根挤着,它被夹在中间,距离乘积最小,分母最小,敏感度最大。根挨得越近,越难分辨。这个几何图像在别处也成立——第 16 章的最小二乘、机器学习里的共线特征,都是同一件事。

◆ 主线

条件数是问题自己的属性,不是你的代码的属性。

这句话有一个很硬的推论,也是这一章唯一需要记住的东西:

如果 κ = 10¹⁰,而你的输入只有 16 位有效数字,那么你的答案最多只能有 6 位是对的——无论你怎么算。换 100 位精度也不行:输入还是那个输入,它的最后一位仍然是不确定的,而问题会把这份不确定放大 10¹⁰ 倍。

这是一条信息论意义上的天花板,不是工程质量问题。撞到它的时候,正确的反应不是「优化代码」,是换一个问法

⌨ 自己跑一遍

这个实验值得亲手做一次,因为「输入改了十亿分之一,输出面目全非」这件事光看数字是没有体感的:

import numpy as np
coef = np.poly(np.arange(1, 21, dtype=float))     # (x−1)(x−2)…(x−20) 的系数
print('x^19 的系数 =', coef[1])                   # −210.0

pert = coef.copy()
pert[1] -= 2.0 ** -23                             # 只改这一个,改动十亿分之零点六
for z in sorted(np.roots(pert), key=lambda z: (round(z.real, 6), z.imag)):
    print(f'{z.real:10.6f} {z.imag:+.6f}i' if abs(z.imag) > 1e-9 else f'{z.real:10.6f}')

然后把 2.0 ** -23 换成 0 跑一遍作对照,会看到二十个漂亮的整数。再把扰动换成 1e-30——根就回来了,因为这时扰动小于 double 本身的抹零量,加了等于没加。

pip install numpy && python3 wilkinson.py

不想装 numpy 的话,用 Google Colab 新建一个笔记本,numpy 是预装的,粘上去直接跑。

▸ 在现实里

条件数这个概念的适用面远远超出数值计算。它其实是一句关于任何测量与推断的话:你的结论对输入有多敏感?

  • 民调。「±3% 误差」说的是输入扰动。如果你要的结论是「谁领先」,而两人差距是 1%,那么这个问题的条件数很大——3% 的输入误差会把结论完全翻转。报道里说的误差范围,和你真正关心的那个结论的误差范围,是两个数。
  • A/B 测试。转化率从 2.00% 到 2.06%,这是「两个相近的数相减」——第 3 章那个 κ = 2×10⁹ 的问题。这就是为什么小提升需要巨大的样本量:不是统计学在为难你,是这个问题本身就病态。
  • 对抗样本。一张熊猫图片加上人眼看不见的扰动就被分成长臂猿,这在数值语言里就是一句话:这个分类函数在这一点上的条件数极大。对抗训练本质上是在压低这个条件数。
  • 期权定价。金融从业者天天算的 Greeks(Delta、Vega、Gamma)就是条件数——「标的动 1%,期权价动几个百分点」。他们比数值分析师更早、更本能地在用这套语言。
  • 复盘和归因。「如果当时不那样做,结果会怎样」——反事实推理的条件数往往极大,而且没人算过。第 3 章的判据在这里同样适用:你的结论是不是从两个相近的大数里减出来的?
✗ 这个直觉是错的

「算不准,那就是算法不够好/精度不够高。找个更好的库,或者上更高精度。」

这句话默认了「答案是可以算准的,只是我没算好」。但当 κ 很大时,答案本身就没有那么多位可以算——你的输入里就不含那些信息。

威尔金森那个例子里,用 100 位十进制重算不会有任何帮助:被改动的那个系数已经改了,100 位精度只会让你更精确地算出「那个被改过的多项式」的根,而它的根确实就在复平面上。你算得越准,越准确地得到那个错答案。

正确的反应有三种: 换一个数值上更稳的表示(别把多项式展开成系数,保留因子形式); 换一个问法(不问「根在哪」,问「根大概在哪个区间」); 接受它,并如实报告只有几位可信。第三种最常被跳过,也最重要。

◇ 对账

正确答案是 C:十个根离开实数轴,最远的一对是 16.730762 ± 2.812625i。换 100 位精度不会有任何帮助。

A 「输入改了十亿分之零点六,输出也就在那个量级上动」——这句话默认 κ ≈ 1。这是个非常自然的默认,日常生活里绝大多数量都是这样的(体重多 1%,BMI 就多 1%)。这本书要做的事,很大程度上就是把这个默认从「总是」降格成「有时」。根 16 的敏感度是 2.41×10⁹。 B 「最大的那个根动得最多」——很合理的猜测,可惜正好反了。动得最多的是中间的根,因为 p′(r) 是「到其他所有根的距离之积」,中间的根被夹得最紧,分母最小。两头的根(1 和 20)反而稳:根 1 的敏感度只有 8.22×10⁻¹⁸,怎么扰都不动。 D 「全部变成复数」——过头了。前四个根(1、2、3、4)连小数点后六位都没动。病态是局部的,不是整体的:同一个问题,有些部分的答案很可靠,有些部分一位都不可信。这一点在实践中很重要——不要因为一处病态就把整份结果全丢掉,也不要因为大部分对就相信全部。
◈ 换一种写法

病态是问题的属性,改不了算法就改表示——同一个数学对象,换一种存法,条件数可以完全不同:

把多项式存成系数数组 [1, −210, 20615, …],然后调求根函数。系数形式对根来说是极度病态的表示。 存成根/因子形式 [(x−1), (x−2), …],或者用 Chebyshev 基(数值上比幂基好几个数量级),或者直接存伴随矩阵。同一个多项式,换个基,κ 差好几个数量级。

这一招在别处也成立:

  • 角度和长度,而不是存两个坐标再去相减(第 3 章的 GPS)。
  • log 概率,而不是存概率再连乘(第 11 章)。
  • 增量,而不是存绝对值再去差分(时间序列、版本控制、事件溯源)。

通则是:把你真正关心的那个量,直接存成一个变量,而不是让它作为两个大数的差出现。

这一章的一句话

条件数是问题写给你的一张字条,上面写着「我最多能给你几位」;它不看你的代码,只看你问了什么。

下一章讲放大倍数的另一半,也是你唯一能动的那一半:算法的稳定性。那里有一个听起来很奇怪的定义——一个好算法的标志,不是它给出准确的答案,而是它给出的答案「恰好是某个邻近问题的精确答案」。顺着这个定义,会得到一条能直接用的经验公式:前向误差 ≲ 条件数 × 后向误差。下一章会把它在六组数据上逐行验一遍,每一行都成立。