卷 II · 放大CH 07深度 7/20

残差很小,答案全错

「把答案代回原式,看看对不对」——这是每个人验算的第一反应,也是几乎所有数值软件默认的收敛判据、几乎所有机器学习训练循环里盯着的那个数。这一章要给出一个反例,而且是一个不留余地的反例:残差 4.44 × 10⁻¹⁶,答案错了一半。然后再把精度调到无限,看它还错不错。

★★ 希尔伯特 12残差 ≠ 误差零舍入误差的求解器也救不了

▷ 猜一下差多少

做一件最标准不过的事:

n = 12
H = [[1 / (i + j + 1) for j in range(n)] for i in range(n)]   # 希尔伯特矩阵
x = [1] * n                                                   # 真解:全是 1
b = H @ x                                                     # 造出右端项
x̂ = numpy.linalg.solve(H, b)                                  # 假装不知道 x,解回来

解完把答案代回去,残差 ‖b − Hx̂‖∞4.44 × 10⁻¹⁶——比机器 epsilon 还小,任何一个收敛判据都会当场通过。

问:解出来的十二个分量(真值全是 1)里,有几个对到两位小数?

A 十二个全对。残差都到 1e-16 了,解不可能有问题 B 十个左右,最后一两个分量差一点 C 只有四个。其余八个里最离谱的一个是 0.4579 D 一个都不对

第二问,也是这一章真正要问的:如果换成完全精确的有理数算术(零舍入误差,一次抹零都不发生)来解同一个方程组,答案会对吗?

先看解出来的东西

# numpy.linalg.solve 解出来的 x̂(真值应该是十二个 1)

 ✓ 1.000000   ✓ 1.000007   ✓ 0.999786   ✓ 1.002894
 ✗ 0.978876   ✗ 1.092466   ✗ 0.743197   ✗ 1.463547
 ✗ 0.457885   ✗ 1.396169   ✗ 0.835605   ✗ 1.029568

★ 残差 ‖b − Hx̂‖∞ = 4.440892098500626e-16
★ 前向相对误差 = 0.542       ← 54% 是错的
★ 对到两位小数的分量:4 / 12

第九个分量是 0.457885。真值是 1。它连一位有效数字都不对——甚至连数量级的一半都没沾上。

与此同时,残差是 4.44 × 10⁻¹⁶

把这两件事并排放:一个错得只剩 45% 的解,代回方程之后,误差比机器 epsilon 还小两倍。

残差在回答另一个问题

上一章的语言可以直接套上来:残差量的是后向误差,不是前向误差。

残差 r = b − H·x̂ 很小
  ⇕
x̂ 是「H·x = b − r」的精确解
  ⇕
x̂ 精确地解了一个和你的问题只差 4×10⁻¹⁶ 的问题
  ⇕
算法完全没有失职

而 κ(H₁₂) = 1.611 × 10¹⁶

前向误差 ≲ κ × 后向误差 ≈ 1.6 × 10¹⁶ × 10⁻¹⁷ ≈ 0.16 … 1.6
实测 0.542。公式在这一行也成立。

所以残差小这件事,一点问题都没有——它只是没有在回答「答案对不对」。它回答的是「你的答案属于哪个问题」。当 κ 是 10¹⁶ 的时候,「属于一个几乎一样的问题」和「答案几乎一样」之间,隔了十六个数量级。

第二问:把精度调到无限

现在做一个能把这件事钉死的实验。

如果误差是算法造成的,那么换一个零误差的算法就该解决。所以:用 Python 的 Fraction——精确有理数,分子分母都是任意精度整数,加减乘除一次抹零都不发生——重写高斯消元,再解一遍。

分三种情形:

H 怎么存b 怎么存用什么算术解结果
精确有理数精确有理数精确有理数十二个 1,一位不差
精确有理数只存成 double 一次,再变回精确有理数精确有理数(零舍入)最大偏离 0.307
doubledoubledouble(LAPACK)最大偏离 0.542

看中间那一行。求解过程一次舍入都没有。唯一发生过抹零的地方,是把右端项 b 存进 double 那一下——每个分量的相对误差在 10⁻¹⁷ 到 10⁻¹⁶ 之间:

b 的十二个分量存成 double 时的相对误差:
4.01e−17  2.13e−17  8.36e−18  4.97e−17  5.69e−17  7.85e−17
7.84e−17  2.52e−17  2.32e−17  2.93e−17  7.52e−18  7.49e−17

用完全精确的算术解出来的答案:
1.000000  0.999996  1.000123  0.998340  1.012075  0.947291
1.146020  0.737023  1.306916  0.776131  1.092737  0.983347

★ 最大偏离真值 0.30691588782574364 —— 错了 30.7%。
◆ 主线

错误不在算法里,在数据里。

只要 b 在某个环节被存进过一次 double,这个方程组的答案就已经废了——之后你用多高的精度、多好的算法去解,都只是在更精确地算出那个错答案

这条结论有一个很实用的推论:当 κ 大到和你的数据精度相抵时,「提高计算精度」是无效投资,「提高数据精度」才有用。而绝大多数时候数据精度是提不了的——传感器就那么准,用户就填了两位小数。这时唯一的出路是换问题

✎ 术语正名

残差(residual)和误差(error)在日常里常被混用,这一章就是它们的分水岭:

  • 残差 r = b − Ax̂:把你的答案代回去,差多少。你能算,因为它只用到已知量。
  • 误差 e = x̂ − x:你的答案离真答案多远。你算不了,因为你不知道 x(知道就不用解了)。

所有的收敛判据、loss、验算,用的都是残差——因为误差根本没法直接量。这不是懒惰,这是没办法。而 误差 ≲ κ × 残差 / ‖A‖ 这条式子,就是唯一一座能从残差走到误差的桥。桥的宽度是 κ。κ 不知道,这座桥就没法走。

⌨ 自己跑一遍

这一章的实验值得完整做一次,因为「用无限精度也救不回来」这件事只有自己跑过才会真信。下面这段不需要 numpy,只用标准库:

from fractions import Fraction as F

def solve(A, rhs):                      # 精确有理数高斯消元,零舍入
    m = len(A)
    M = [row[:] + [rhs[i]] for i, row in enumerate(A)]
    for c in range(m):
        p = max(range(c, m), key=lambda r: abs(M[r][c]))
        M[c], M[p] = M[p], M[c]
        for r in range(m):
            if r != c:
                f = M[r][c] / M[c][c]
                for k in range(c, m + 1): M[r][k] -= f * M[c][k]
    return [M[i][m] / M[i][i] for i in range(m)]

n = 12
H = [[F(1, i + j + 1) for j in range(n)] for i in range(n)]
b = [sum(row) for row in H]             # 精确 b = H·(1,1,…,1)

print('精确 b   :', all(v == 1 for v in solve(H, b)))          # True
bd = [F(float(v)) for v in b]           # ★ 只在这里抹一次零
print('double b :', ' '.join('%.6f' % float(v) for v in solve(H, bd)))

会打出:

精确 b   : True
double b : 1.000000 0.999996 1.000123 0.998340 1.012075 0.947291
            1.146020 0.737023 1.306916 0.776131 1.092737 0.983347

n 改成 8 试试——答案基本对;改成 16——整排数字都不像话了。κ 每涨 10 倍,你就少一位。

python3 hilbert.py # 纯标准库,不需要 numpy

在线:python.org/shell。有 numpy 的话再加一行 numpy.linalg.cond(H),会打出 1.611e+16。

▸ 在现实里

多项式拟合。1, x, x², …, xⁿ 去拟合数据时,正规方程的矩阵就是希尔伯特矩阵的近亲(当 x 分布在 [0,1] 上时几乎就是它)。这就是为什么「高次多项式拟合」在实践中口碑极差:不是多项式不好,是幂基这个表示法病态。换成正交多项式基(Legendre、Chebyshev)或者样条,同一份数据同一个次数,条件数能降好几个数量级。

回归里的共线性。两个高度相关的特征,等价于矩阵里两列几乎平行 ⇒ κ 巨大 ⇒ 回归系数极不稳定:数据动一点点,系数就正负翻转。统计课上讲的「方差膨胀因子」(VIF)本质上就是条件数。而 R² 和残差在这时候看起来完全正常。这是这一章在数据科学里最直接的对应物。

训练 loss 很低但模型不能用。结构上是一模一样的:loss 是残差,泛化误差是前向误差,中间隔着一个没人算过的条件数。「loss 降到 0.001 了,模型应该没问题」和「残差 4×10⁻¹⁶,解应该没问题」是同一句话。

反过来的用法也很有价值。如果你的残差,那基本可以断定是代码有 bug——因为后向稳定的算法残差一定小。残差大 ⇒ 查代码;残差小 ⇒ 查条件数。这个二分法能省掉很多时间。

✗ 这个直觉是错的

「把答案代回去验算,对得上就说明算对了。」

代回去验算,验的是后向误差。它能抓出 bug(残差大一定有问题),但抓不出病态(残差小不代表答案对)。

这个直觉之所以顽固,是因为它在日常算术里从来没错过——那些问题的 κ 都接近 1,残差小就等于答案对。「验算」这个习惯本身是好的,它只是自带一个没人说破的前提:问题得是良态的。

要把验算变成真验算,得补上第二步:估一下条件数numpy.linalg.cond(A) 一行的事;对一般的问题,也可以直接做数值实验——把输入扰动 1%,看输出动多少。这一步几乎没人做,而它比再写十个单元测试都有用。

◇ 对账

正确答案是 C:只有 4 个分量对到两位小数,最离谱的一个是 0.457885(真值 1)。第二问:用完全精确的有理数算术解,照样错,最大偏离 30.7%

A 「残差 1e-16,解不可能有问题」——这正是这一章要拆的那条直觉。残差小是算法的成绩,不是答案的成绩。这两件事之间隔着 κ = 1.6×10¹⁶。 B 「最后一两个分量差一点」——这个答案背后有个隐含模型:误差是「渐变」的,靠后的分量更不准。实际上误差在十二个分量里的分布毫无规律可言:第 1 个精确,第 2、3、4 个几乎精确,第 9 个错了一半,第 12 个又基本对(1.029568)。病态问题的误差分布由矩阵的奇异向量决定,跟下标顺序没关系。 D 「一个都不对」——太悲观了,前四个是对的。这也是个值得记住的点:病态问题的结果不是「全部作废」,而是「部分可信」。正确的做法不是把结果全丢掉,是搞清楚哪部分可信——而这需要算条件数或者做扰动实验,不能靠眼看。
◈ 换一种写法

这一章的改写不在代码层,在问题的表示层:

用幂基 1, x, x², x³, … 建立设计矩阵,然后解正规方程。矩阵是希尔伯特型,κ 随次数指数爆炸。 正交基(Chebyshev / Legendre 多项式):同一个函数空间、同一个拟合结果,但矩阵接近正交,κ 接近 1。这一步不改变你能拟合出什么,只改变你能不能算出来。 或者换局部基(B 样条):设计矩阵变成带状稀疏矩阵,κ 与次数无关,还快很多。

另外三条常用的「降 κ」手段,都在这个思路上:

  • 中心化和标准化。把 x 减去均值、除以标准差再拟合。这一步在统计软件里是默认的,理由就是这一章。
  • 正则化。岭回归给 AᵀA 加上 λI,直接把最小的奇异值抬起来,κ 应声下降。正则化的数值意义,就是花一点偏差买一大堆条件数。
  • 如实报告。算出 κ,然后说「输入 16 位,κ 是 10¹⁶,所以这个结果 0 位可信」。这不是失败,这是唯一诚实的输出。

这一章的一句话

残差量的是「我的答案属于哪个问题」,不是「我的答案对不对」;这两件事之间隔着一个叫条件数的东西,不去看它,验算就只是一种仪式。

下一章是这本书的招牌。同一份账、同一台机器、同一个语言,只是把「求和」这个动作用六种写法各写一遍——得到六个不同的余额。而最自然、最多人会写的那一种,把账算差了 1.08 元。更意外的是:其中一种教科书推荐的写法赢了,但它赢得非常可耻;另一种大名鼎鼎的补偿算法,输了。