残差很小,答案全错
「把答案代回原式,看看对不对」——这是每个人验算的第一反应,也是几乎所有数值软件默认的收敛判据、几乎所有机器学习训练循环里盯着的那个数。这一章要给出一个反例,而且是一个不留余地的反例:残差 4.44 × 10⁻¹⁶,答案错了一半。然后再把精度调到无限,看它还错不错。
做一件最标准不过的事:
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)里,有几个对到两位小数?
第二问,也是这一章真正要问的:如果换成完全精确的有理数算术(零舍入误差,一次抹零都不发生)来解同一个方程组,答案会对吗?
先看解出来的东西
# 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 |
| double | double | double(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 元。更意外的是:其中一种教科书推荐的写法赢了,但它赢得非常可耻;另一种大名鼎鼎的补偿算法,输了。