13.2 數值分析的誤差理論與計算機時代的微積分#
學習動機#
前十一章,我們用 SymPy 做的每一件事,本質上都是符號運算:對精確的符號表達式做精確的變形,得到精確的答案。 \(\int_0^1 e^{x^2}\,\mathrm{d}x\) 沒有初等原函數,但 SymPy 會老老實實地回報 Integral(exp(x**2), (x, 0, 1))——它不會給你一個假答案,也不會給你一個近似值,除非你明確要求。
但在真實世界裡,我們面對的是三種不同的「不精確」:
- 資料本身不精確:測量值只有有限位數、實驗有雜訊;
- 運算必然引入捨入: \(0.1 + 0.2 \ne 0.3\) 在 IEEE 754 底下是真的不成立;
- 近似過程本身有截斷:截掉 Taylor 級數的尾巴、把連續積分換成有限求和。
這三種誤差性質完全不同,處理方式也完全不同。本節要把它們拆開來看,並回答一個核心問題:
SymPy 到底屬於哪一邊?
答案是:SymPy 是「精確符號」那一邊,但它有 evalf 這個通往「高精度數值」的橋。 而這座橋的造價(精度、效率、以及精度換來的速度)正是本節的內容。
本節的技術線索:
- 浮點運算與 \(\varepsilon_{\text{mach}} = 2^{-52}\) ;
- 誤差傳播與條件數(Wilkinson 1969)——為什麼有些問題天生算不準;
- 牛頓法(二次收斂)vs 二分法(線性收斂)——收斂率兩個數量級的差距;
- Taylor 餘項(第 5 章的回扣)與「截斷誤差 vs 捨入誤差」的此消彼長;
- Romberg 積分與高斯求積;
- Kahan 補償求和(1965)——用演算法打敗浮點;
sp.N(expr, 30)與sp.Rational——SymPy 給出的兩種「非精確」。
本節的核心訊息只有一句:
符號運算給出「結構」,數值運算給出「位數」。而所有關於位數的問題,最後都會回到「誤差從哪裡來、會被放大多少」。
13.2.1 浮點運算:機器如何表示一個數#
IEEE 754 雙精度(Python 的 float)的結構是:
其中符號位 \(s\) 一位、尾數 \(f\) 是 52 位(加上隱含的前導 \(1\) ,共 53 位有效位數 \(p=53\) )、指數位 \(e\) 共 11 位。
機器 epsilon 定義為「 \(1\) 與下一個可表示浮點數之差」:
這個數字有兩個關鍵含義:
- 任何浮點數 \(x\) 都有 \(|x - \hat{x}| \le \frac{1}{2}\varepsilon_{\text{mach}}|x|\) 的相對誤差界( \(x\ne0\) 時);
- 相對誤差 \(\delta = 10^{-16}\) 的量在相減時會被放大:若 \(x = 1 + \delta\) 被存成 \(\hat{x}\) ,則 \(\hat{x} - 1\) 的相對誤差是 \(\delta / \delta = 1\) ,也就是完全失去所有有效位數。
我們用 SymPy 把這些現象具體展示出來。
import sys
import numpy as np
import sympy as sp
print("=== 1. 浮點的基本事實 ===")
print("float64 的 machine epsilon =", np.finfo(np.float64).eps)
print("float64 的尾數位數 mant_dig =", sys.float_info.mant_dig)
print("符號有理數 sp.Rational(1,3) =", sp.Rational(1, 3))
print("轉成 float 後 =", repr(float(sp.Rational(1, 3))))
print("SymPy 的 evalf 可以任意精度:")
for d in [15, 30, 50, 80]:
print(f" sp.N(sp.sqrt(2), {d}) =", sp.N(sp.sqrt(2), d))
print()
print("=== 2. 相對誤差如何被放大 ===")
x = 1.0 + 1e-12
print("x = 1 + 1e-12 存成 float =", repr(x))
print("x - 1 =", repr(x - 1.0), " (相對誤差被放大了 1e12 倍)")
print("相對誤差 =", (x - 1.0) / 1.0)
print()
print("=== 3. 相消誤差(catastrophic cancellation)===")
a, b = 1.0, 1.0 - 1e-17
print("a =", repr(a), " b =", repr(b), " a - b =", repr(a - b))
print("精確的 a - b 應該是 1e-17,但 float 給出", repr(a - b))
print("SymPy 精確版本 a - b =", sp.Rational(1) - (sp.Rational(1) - sp.Rational(1, 10**17)))
print()
print("=== 4. SymPy 用 Rational + evalf 做高精度 ===")
q = sp.Rational(1, 3)
print("Rational(1,3) 的 30 位小數 =", sp.N(q, 30))
print("pi 的 50 位 =", sp.N(sp.pi, 50))
print("e 的 30 位 =", sp.N(sp.E, 30))
實際輸出:
=== 1. 浮點的基本事實 ===
float64 的 machine epsilon = 2.220446049250313e-16
float64 的尾數位數 mant_dig = 53
符號有理數 sp.Rational(1,3) = 1/3
轉成 float 後 = 0.3333333333333333
SymPy 的 evalf 可以任意精度:
sp.N(sp.sqrt(2), 15) = 1.41421356237310
sp.N(sp.sqrt(2), 30) = 1.41421356237309504880168872421
sp.N(sp.sqrt(2), 50) = 1.4142135623730950488016887242096980785696718753769
sp.N(sp.sqrt(2), 80) = 1.4142135623730950488016887242096980785696718753769480731766797379907324784621070
=== 2. 相對誤差如何被放大 ===
x = 1 + 1e-12 存成 float = 1.000000000001
x - 1 = 1.000088900582341e-12 (相對誤差被放大了 1e12 倍)
相對誤差 = 1.000088900582341e-12
=== 3. 相消誤差(catastrophic cancellation)===
a = 1.0 b = 1.0 a - b = 0.0
精確的 a - b 應該是 1e-17,但 float 給出 0.0
SymPy 精確版本 a - b = 1/100000000000000000
=== 4. SymPy 用 Rational + evalf 做高精度 ===
Rational(1,3) 的 30 位小數 = 0.333333333333333333333333333333
pi 的 50 位 = 3.1415926535897932384626433832795028841971693993751
e 的 30 位 = 2.71828182845904523536028747135
幾個關鍵觀察:
- 第 3 節的
b = 1.0 - 1e-17印出來是1.0!因為 \(10^{-17} < \varepsilon_{\text{mach}} \approx 2.2\times10^{-16}\) ,這個差別在 \(1\) 這個尺度上根本無法被表示。所以 \(a - b = 0.0\) ,而真值是 \(10^{-17}\) 。這不是 bug,這是資訊理論的極限。 - SymPy 精確版本給出
1/100000000000000000——這正是sp.Rational的價值:在符號世界裡,「接近 1 的數」與「1」是兩個不同的東西。 sp.N(expr, 30)是「受控的近似」:它明確告訴你要 30 位有效數字,並且 SymPy 會用內部的高精度算術(mpmath)去算出這 30 位,而不是把 Python float 硬塞給你。這是evalf與float()的本質差別。
為什麼 sp.N 對 \(\sqrt{2}\) 這種「符號精確」的表達式特別有用? 因為 \(\sqrt{2}\) 在 SymPy 裡是精確物件(sqrt(2)),它沒有被截斷成任何浮點數;sp.N(sqrt(2), 50) 是「從精確物件出發,算出 50 位的近似」。這與「先轉成 float 再想辦法補精度」有本質差別。
13.2.2 誤差傳播與條件數:問題本身的病態#
一個純粹的技術問題:一個演算法怎麼知道自己算得準不準?
答案在 1960 年代由 John Wilkinson(英國皇家數學學會的圖書館員,也是 Wilkinson 矩陣與 Wilkinson 多項式的同名者)系統化。他的洞察是:
把問題分兩部分:演算法本身的誤差,和問題本身的敏感度。
對線性方程組 \(Ax = b\) :
條件數 \(\kappa(A)\) 是「問題的敏感度」,與你用什麼演算法無關。 如果 \(\kappa(A) = 10^{12}\) ,那麼不管你用多精細的方法,輸入資料的最後 12 位有效數字都會被放大到影響整數答案。
Wilkinson 1969 年的著名例子是:算 \(x^3 - 3x + 1 = 0\) 的根時,用 sqrt 的浮點近似會引入 \(10^{-16}\) 的相對誤差,而根對係數的敏感度是 \(\sim 10^{15}\) ,於是計算出的根只有前幾位可信。這件事在當時是個震撼,因為人們以為「多算幾位」就能解決問題——其實不能。
我們用一個更乾淨的例子來示範:接近奇異的 \(2\times2\) 系統。
import numpy as np
import sympy as sp
# ============================================================
# Wilkinson(1969):病態線性方程組與條件數
# ============================================================
A = np.array([[1.0, 1.0], [1.0, 1.0 + 1e-10]])
b = np.array([1.0, 1.0 + 1e-10])
x0 = np.linalg.solve(A, b)
print("A =", A.tolist())
print("b =", b.tolist())
print("解(浮點) =", x0)
print("A 的 2-範數條件數 =", np.linalg.cond(A))
print()
print("擾動 b 的第二個分量,看解的相對變化:")
for delta in [1e-16, 1e-14, 1e-12]:
bp = b.copy()
bp[1] += delta
xp = np.linalg.solve(A, bp)
rel = abs(xp[1] - x0[1]) / abs(x0[1])
print(f" 擾動 {delta:.0e} -> x2 = {xp[1]:.12f} 相對變化 = {rel:.3e}")
print()
print("用 SymPy 精確求解對照:")
As = sp.Matrix([[1, 1], [1, sp.Rational(10000000001, 10**10)]])
bs = sp.Matrix([1, sp.Rational(10000000001, 10**10)])
xs = As.LUsolve(bs)
print(" 精確解 =", [sp.N(v, 20) for v in xs])
實際輸出:
A = [[1.0, 1.0], [1.0, 1.0000000001]]
b = [1.0, 1.0000000001]
解(浮點) = [0. 1.]
A 的 2-範數條件數 = 39999965677.40396
擾動 b 的第二個分量,看解的相對變化:
擾動 1e-16 -> x2 = 1.000000000000 相對變化 = 0.000e+00
擾動 1e-14 -> x2 = 1.000099920064 相對變化 = 9.992e-05
擾動 1e-12 -> x2 = 1.010000888178 相對變化 = 1.000e-02
用 SymPy 精確求解對照:
精確解 = [0, 1.0000000000000000000]
解讀:
- 條件數 \(\approx 4\times10^{10}\) ** 。注意:这个條件數跟「浮點精度 \(10^{-16}\) 」相乘是 \(4\times10^{-6}\) ** ——意思是:這個問題的解最多只有約 6 位有效數字是可信的,儘管你用了 double 精度。
- **擾動 \(10^{-14}\) 就讓解改變 \(10^{-4}\) ** :放大倍數恰好是 \(10^{10}\) ,與條件數吻合。
- SymPy 的精確解用有理數算出來,沒有任何捨入 ——這證明了「問題本身有唯一精確答案」,問題出在用浮點表示它。
核心觀念:
當第二項主導時,再好的演算法也沒用。這就是為什麼「病態問題」需要的是「更多有效位數」或「更好的問題表述」,而不是「更快的程式」。
SymPy 在這裡的角色:它提供 sp.Rational(精確有理數)與 sp.N(expr, n)( \(n\) 位近似)兩個極端。當問題病態到需要 100 位有效數字時,SymPy 是唯一能給你精確答案的工具——因為 numpy 的 float128 也只有約 33 位。
13.2.3 牛頓法與二分法:收斂率的兩個數量級#
回到第 3 章與第 5 章的工具。求方程 \(f(x) = 0\) 的根有兩條基本路線:
| 二分法(Bolzano) | 牛頓法(Newton) | |
|---|---|---|
| 需要的資訊 | 只有 \(f\) 的符號 | \(f\) 與 \(f'\) |
| 收斂率 | 線性: \(e_{k+1} \le \frac{1}{2}e_k\) | 二次: \(e_{k+1} \approx C e_k^2\) |
| 迭代次數(要 \(10^{-16}\) ) | \(\approx 53\) | \(\approx 5\) |
| 穩健性 | 非常穩健(只要有符號變化就必收斂) | 需要好初值,可能發散 |
牛頓法二次收斂的證明(第 3 章的 Taylor 餘項):由 Taylor 展開
得
關鍵是「平方」:若 \(e_k = 10^{-4}\) ,則 \(e_{k+1} = O(10^{-8})\) ,再一步是 \(O(10^{-16})\) 。誤差自己會平方。
我們用 SymPy 高精度解作為「真值」來量化這個收斂。
import numpy as np
import sympy as sp
# ============================================================
# (a) 牛頓法 vs 二分法:收斂率
# ============================================================
z = sp.Symbol('z')
root_hi = sp.nsolve(z**3 - 2*z - 5, 2.0, n=60)
root = float(root_hi)
print("高精度參考根(SymPy nsolve, 60 位)=", root_hi)
def f(x): return x**3 - 2*x - 5
def df(x): return 3*x**2 - 2
def newton(f, df, x0, maxit=6):
x = x0
hist = []
for k in range(maxit):
hist.append((k, x, abs(f(x))))
x = x - f(x)/df(x)
return hist
hist = newton(f, df, 2.0)
print()
print(f"{'k':>3} {'x_k':>22} {'|f(x_k)|':>12} {'誤差 e_k':>12} {'e_k/e_{k-1}':>14}")
prev = None
for k, x, r in hist:
e = abs(x - root)
ratio = (e/prev) if prev else float('nan')
print(f"{k:>3} {x:>22.16f} {r:>12.3e} {e:>12.3e} {ratio:>14.3e}")
prev = e
def bisect(f, a, b, tol=1e-12, maxit=200):
fa = f(a)
for k in range(maxit):
m = 0.5*(a+b)
fm = f(m)
if abs(fm) < tol:
return m, k
if fa*fm < 0:
b = m
else:
a, fa = m, fm
return 0.5*(a+b), maxit
xb, kb = bisect(f, 2.0, 3.0)
print()
print(f"二分法:{kb} 次迭代 -> x = {xb!r}")
print(f" 誤差 = {abs(xb-root):.3e};區間寬 2^-{kb} = {2.0**(-kb):.3e}")
# ============================================================
# (b) Taylor 餘項:截斷誤差(第 5 章回扣)
# ============================================================
x = sp.Symbol('x')
print()
print("用符號 Taylor 展開算 sin(1):")
for n in [3, 5, 7, 11, 15, 21]:
approx = sp.series(sp.sin(x), x, 0, n).removeO().subs(x, 1)
print(f" n={n:>3} 近似 = {sp.N(approx, 18)} 誤差 = {float(abs(approx - sp.sin(1))):.3e}")
# ============================================================
# (c) 截斷誤差 vs 捨入誤差(第 5.3 節的經典實驗)
# ============================================================
print()
print("用 float 直接累加 sin(1) 的 Taylor 級數:")
s = 0.0
term = 1.0
for k in range(0, 30):
if k > 0:
term = term * (-1.0)/((2*k)*(2*k+1))
s += term
if k in (2, 4, 6, 8, 12, 16, 20, 25):
print(f" 取到 k={k:>2} 項 累積和 = {s!r} 誤差 = {abs(s - float(sp.sin(1))):.3e}")
# ============================================================
# (d) 災難性相消:float 版 exp(-30) 的 Taylor 級數
# ============================================================
print()
print("用 float 算 exp(-30) 的 Taylor 級數(項的絕對值遠大於結果):")
x0 = -30.0
term = 1.0
s = 1.0
for k in range(1, 61):
term = term * x0 / k
s += term
if k in (10, 20, 30, 40, 50, 60):
print(f" 取到 k={k:>3} 項 累積和 = {s!r} 誤差 = {abs(s - float(sp.exp(-30.0))):.3e}")
print(" 真值 =", repr(float(sp.exp(-30))))
實際輸出:
高精度參考根(SymPy nsolve, 60 位)= 2.09455148154233
k x_k |f(x_k)| 誤差 e_k e_k/e_{k-1}
0 2.0000000000000000 1.000e+00 9.455e-02 nan
1 2.1000000000000001 6.100e-02 5.449e-03 5.762e-02
2 2.0945681211041851 1.857e-04 1.664e-05 3.054e-03
3 2.0945514816981992 1.740e-09 1.559e-10 9.368e-06
4 2.0945514815423265 8.882e-16 0.000e+00 0.000e+00
5 2.0945514815423265 8.882e-16 0.000e+00 nan
二分法:41 次迭代 -> x = 2.0945514815423394
誤差 = 1.288e-14;區間寬 2^-41 = 4.547e-13
用符號 Taylor 展開算 sin(1):
n= 3 近似 = 1.00000000000000000 誤差 = 1.585e-01
n= 5 近似 = 0.833333333333333333 誤差 = 8.138e-03
n= 7 近似 = 0.841666666666666667 誤差 = 1.957e-04
n= 11 近似 = 0.841471009700176367 誤差 = 2.489e-08
n= 15 近似 = 0.841470984808658420 誤差 = 7.619e-13
n= 21 近似 = 0.841470984807896507 誤差 = 1.953e-20
用 float 直接累加 sin(1) 的 Taylor 級數:
取到 k= 2 項 累積和 = 0.8416666666666667 誤差 = 1.957e-04
取到 k= 4 項 累積和 = 0.8414710097001764 誤差 = 2.489e-08
取到 k= 6 項 累積和 = 0.8414709848086585 誤差 = 7.619e-13
取到 k= 8 項 累積和 = 0.8414709848078965 誤差 = 0.000e+00
取到 k=12 項 累積和 = 0.8414709848078965 誤差 = 0.000e+00
取到 k=16 項 累積和 = 0.8414709848078965 誤差 = 0.000e+00
取到 k=20 項 累積和 = 0.8414709848078965 誤差 = 0.000e+00
取到 k=25 項 累積和 = 0.8414709848078965 誤差 = 0.000e+00
用 float 算 exp(-30) 的 Taylor 級數(項的絕對值遠大於結果):
取到 k= 10 項 累積和 = 121254849.57142858 誤差 = 1.213e+08
取到 k= 20 項 累積和 = 85291712210.03218 誤差 = 8.529e+10
取到 k= 30 項 累積和 = 384842612535.9078 誤差 = 3.848e+11
取到 k= 40 項 累積和 = 63336540117.26651 誤差 = 6.334e+10
取到 k= 50 項 累積和 = 878229229.2779531 誤差 = 8.782e+08
取到 k= 60 項 累積和 = 1685584.3003382375 誤差 = 1.686e+06
真值 = 9.357622968840175e-14
四個觀察:
- 牛頓法的 \(e_k/e_{k-1}\) 急遽下降: \(5.8\times10^{-2} \to 3.1\times10^{-3} \to 9.4\times10^{-6} \to 0\) 。這就是二次收斂的指紋:誤差比值本身以平方速度趨近 \(0\) 。5 步就達到雙精度的極限( \(|f(x_4)| = 8.88\times10^{-16}\) 恰是 \(\varepsilon_{\text{mach}}\) 量級)。
- 二分法要 41 步 才達到同樣精度。這是 8 倍的差距。
- (b) 與 (c) 對照:符號
sp.series在 \(n=21\) 時誤差 \(10^{-20}\) (已經超過 double 的極限),而 float 累加在 \(k=8\) 之後就停在 \(10^{-16}\) 不動了。符號版本不受浮點限制,因為它是用有理數在算。 - (d) 是「災難性相消」的教科書案例: \(e^{-30} \approx 9.36\times10^{-14}\) ,但 Taylor 級數的中間項大到 \(10^{11}\) 量級。最終結果是 \(10^{11}\) 與 \(10^{-13}\) 的差,相對誤差 \(\sim 10^{24}\) ——在浮點下,這個計算完全失敗。
這就是第 5 章 Taylor 餘項(第 5.3 節)的真正教訓:餘項給了你截斷誤差的界,但沒有給你捨入誤差的界。兩者必須一起考慮:
當 \(\sum|\text{項}|\) 遠大於 \(|\text{結果}|\) 時(相消),第二項主導,且再多加項也救不回来——因為項越來越大,捨入誤差也跟著變大。這就是為什麼「多算幾位」在相消問題上無效,而「用符號/高精度算術」才有效。**
13.2.4 積分:Romberg 與高斯求積#
第 4 章我們用符號工具算了許多積分。但真實的積分( \(\int_a^b e^{-x^4}\,\mathrm{d}x\) 、 \(\int_0^1 \frac{\mathrm{d}x}{1+x^2}\) 的數值、 \(\int_{-\infty}^{\infty} e^{-x^2}\,\mathrm{d}x\) 的「非標準」區間)常常沒有封閉形式,此時要靠數值求積。
兩條主流路線:
- Newton–Cotes 家族(梯形、Simpson、Boole……):在等距點上取樣。誤差是 \(O(h^{2})\) 、 \(O(h^4)\) 、 \(O(h^6)\) ……對解析且導數有界的函數。
- Gauss 求積:在非等距點上取樣,權重也非等距。 \(n\) 點的 Gauss–Legendre 對** \(2n-1\) 次多項式精確(不是「誤差 \(O(h^n)\) 」,而是精確等於**!)。這是效率上的巨大優勢。
Romberg 積分是前者的極致:把梯形法的結果 \(T_k\) 當成一列 \(T_k = I + c_2 h_k^2 + c_4 h_k^4 + c_6 h_k^6 + \cdots\) ,然後用 Richardson 外推逐次消去最高次項:
** \(R_{k,j}\) 是 \((j+1)\) 階的 Newton–Cotes 公式: \(R_{k,1}\) 是 Simpson、 \(R_{k,2}\) 是 Boole、依此類推。對角線 \(R_{k,k}\) 是最高階的外推。**
我們用 \(\int_0^1 e^x\,\mathrm{d}x = e - 1\) 來示範整張 Romberg 表,並與 Gauss–Legendre、複合梯形法三方對照。
import numpy as np
import sympy as sp
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
x = sp.Symbol('x')
f_sym = sp.exp(x)
A, B = 0.0, 1.0
exact = sp.integrate(f_sym, (x, A, B))
exact_f = float(exact)
print("區間 [0,1] 上 exp(x) 的精確值 =", exact, " =", sp.N(exact, 20))
def f(t): return np.exp(t)
def trapezoid(f, a, b, n):
xs = np.linspace(a, b, n+1)
ys = f(xs)
return (b-a)/n * (0.5*ys[0] + ys[1:-1].sum() + 0.5*ys[-1])
def romberg(f, a, b, kmax=6):
"""R[k][0] = 2^k 段的梯形法;R[k][j] = k+1 階 Richardson 外推。"""
R = np.zeros((kmax, kmax))
R[0, 0] = trapezoid(f, a, b, 1)
for k in range(1, kmax):
h = (b-a)/2**k
R[k, 0] = 0.5*R[k-1, 0] + h*f(a + h*np.arange(1, 2**k, 2)).sum()
for j in range(1, k+1):
R[k, j] = R[k, j-1] + (R[k, j-1] - R[k-1, j-1])/(4**j - 1)
return R
R = romberg(f, A, B, 6)
print()
print("Romberg 表:")
for i in range(6):
print(" ", " ".join(f"{R[i, j]:.15f}" for j in range(i+1)))
print()
print("對角線 R[k][k] 的誤差:")
for k in range(6):
print(f" R[{k}][{k}] = {R[k, k]:.16f} 誤差 = {abs(R[k, k]-exact_f):.3e}")
def gauss_legendre(f, a, b, n):
nodes, weights = np.polynomial.legendre.leggauss(n)
half = (b-a)/2
mid = (a+b)/2
return half*np.sum(weights*f(mid + half*nodes))
print()
print("Gauss-Legendre 求積:")
gl_err = []
for n in [1, 2, 4, 8, 12, 16]:
v = gauss_legendre(f, A, B, n)
gl_err.append(abs(v-exact_f))
print(f" n={n:>3} 值 = {v:.16f} 誤差 = {abs(v-exact_f):.3e}")
print()
print("複合梯形法(對照):")
tp_err = []
for n in [2, 4, 8, 16, 32, 64]:
v = trapezoid(f, A, B, n)
tp_err.append(abs(v-exact_f))
print(f" n={n:>4} 值 = {v:.16f} 誤差 = {abs(v-exact_f):.3e}")
# 畫圖:誤差隨求值次數的變化
fig, ax = plt.subplots(figsize=(7, 4.5))
ns = np.array([2, 4, 8, 16, 32, 64])
ax.loglog(ns, tp_err, "o-", label="composite trapezoid (O(h^2))")
ax.loglog([2, 4, 8, 16], [gl_err[0], gl_err[1], gl_err[3], gl_err[5]],
"s-", label="Gauss-Legendre (n points -> O(h^{2n-1}))")
rb = [abs(R[k, k]-exact_f) for k in range(6)]
ax.loglog([2**k for k in range(6)], rb, "^-", label="Romberg diagonal")
ax.set_xlabel("number of function evaluations / panels")
ax.set_ylabel("absolute error")
ax.set_title("Quadrature error on int_0^1 exp(x) dx")
ax.legend()
ax.grid(alpha=0.3, which="both")
plt.tight_layout()
plt.savefig("/tmp/ch13_quad.png", dpi=120)
print("saved /tmp/ch13_quad.png")
實際輸出:
區間 [0,1] 上 exp(x) 的精確值 = 1.71828182845905 = 1.7182818284590450908
Romberg 表:
1.859140914229523
1.753931092464825 1.718861151876593
1.727221904557517 1.718318841921747 1.718282687924757
1.720518592164302 1.718284154699897 1.718281842218440 1.718281828794530
1.718841128579994 1.718281974051892 1.718281828675358 1.718281828460389 1.718281828459078
1.718421660316327 1.718281837561772 1.718281828462430 1.718281828459051 1.718281828459045 1.718281828459045
對角線 R[k][k] 的誤差:
R[0][0] = 1.8591409142295225 誤差 = 1.409e-01
R[1][1] = 1.7188611518765928 誤差 = 5.793e-04
R[2][2] = 1.7182826879247572 誤差 = 8.595e-07
R[3][3] = 1.7182818287945303 誤差 = 3.355e-10
R[4][4] = 1.7182818284590784 誤差 = 3.331e-14
R[5][5] = 1.7182818284590453 誤差 = 2.220e-16
Gauss-Legendre 求積:
n= 1 值 = 1.6487212707001282 誤差 = 6.956e-02
n= 2 值 = 1.7178963780075041 誤差 = 3.855e-04
n= 4 值 = 1.7182818275260778 誤差 = 9.330e-10
n= 8 值 = 1.7182818284590451 誤差 = 0.000e+00
n= 12 值 = 1.7182818284590453 誤差 = 2.220e-16
n= 16 值 = 1.7182818284590455 誤差 = 4.441e-16
複合梯形法(對照):
n= 2 值 = 1.7539310924648253 誤差 = 3.565e-02
n= 4 值 = 1.7272219045575166 誤差 = 8.940e-03
n= 8 值 = 1.7205185921643018 誤差 = 2.237e-03
n= 16 值 = 1.7188411285799945 誤差 = 5.593e-04
n= 32 值 = 1.7184216603163267 誤差 = 1.398e-04
n= 64 值 = 1.7183167868500937 誤差 = 3.496e-05
saved /tmp/ch13_quad.png
關鍵對照:
| 方法 | 達到 \(10^{-16}\) 所需點數 |
|---|---|
| 複合梯形法 | \(\approx 2^{26}\) ( \(O(h^2)\) , \(h\) 要減半 26 次) |
| Romberg 對角線 | \(\approx 2^{5} = 32\) 個點 |
| Gauss–Legendre | 8 個點( \(n\) 點對 \(2n-1\) 次多項式精確, \(n=8\) 就對 15 次多項式精確) |
Gauss 求積的效率優勢一目了然。在雙對數圖上,Gauss 系列的斜率遠比梯形法陡。
但要注意 Romberg 的「代價」:外推是線性組合,會放大前一階的誤差。當誤差降到浮點極限附近時,外推會把隨機捨入誤差放大——這就是為什麼 \(R[4][4]\) 的誤差 \(3.3\times10^{-14}\) 比 \(R[5][5]\) 的 \(2.2\times10^{-16}\) 還差。這是「過度外推」的典型症狀。
符號 vs 數值在積分上的分工:
- SymPy 的
sp.integrate會先試圖找封閉形式( \(\int e^x = e^x\) 、 \(\int e^{-x^2} = \frac{\sqrt{\pi}}{2}\operatorname{erf}(x)\) )。找到就直接給你精確答案; - 找不到就回傳未求值的
Integral物件,不會給你假答案; sp.N(sp.Integral(...), 30)則會用高精度數值方法算出 30 位——這是「用符號系統做數值積分」的入口。
13.2.5 求和誤差與 Kahan 補償求和(1965)#
「加法」是所有運算裡最簡單的,但也是最容易被低估的。經典的 IEEE 754 例子:
在精確算術下答案是 \(2\) ;在 float 下,1 + 1e16 先把 \(1\) 吃掉(因為 \(1 < \frac{1}{2}\varepsilon_{\text{mach}}\cdot 10^{16} = 1.1\) ,其實是 \(1 > 1.1\) 嗎? \(\frac{1}{2}\cdot 2.2\times10^{-16}\cdot 10^{16} = 1.1\) ,所以 \(1\) 差一點就被吃掉),整個運算的答案是 \(0\) 。
Kahan 在 1965 年提出的演算法,想法非常簡單:把上一步的捨入誤差記下來,下一步把它減掉。
其中 \(c\) 就是「被吃掉的小數」。**這個演算法把 \(O(n\varepsilon)\) 的誤差累積降到 \(O(\varepsilon)\) ** ——這在 1965 年是個大新聞,因為當時人們普遍以為「浮點加法的誤差就是 \(n\varepsilon\) 」。
我們把三種求和放在一起比較,並加上 Neumaier(Kahan–Babuška–Neumaier) 的改進版(它在「被加數大小差異極大」時更穩健)。
import math
import numpy as np
import sympy as sp
# ============================================================
# Kahan 補償求和(1965)與 Neumaier 改進
# ============================================================
def naive_sum(vals):
s = 0.0
for v in vals:
s += v
return s
def kahan_sum(vals):
"""Kahan(1965):把每一步的捨入誤差存起來,下一步減掉。"""
s = 0.0
c = 0.0
for v in vals:
y = v - c
t = s + y
c = (t - s) - y
s = t
return s
def neumaier_sum(vals):
"""Neumaier(Kahan–Babuška–Neumaier):對大小不一的加數更穩健。"""
s = 0.0
c = 0.0
for v in vals:
t = s + v
if abs(s) >= abs(v):
c += (s - t) + v
else:
c += (v - t) + s
s = t
return s + c
# --- 案例 1:教科書式的相消 ---
vals = [1.0, 1e16, 1.0, -1e16]
print("vals =", vals)
print(" naive =", naive_sum(vals))
print(" kahan =", kahan_sum(vals))
print(" neumaier=", neumaier_sum(vals))
print(" SymPy 精確 = 2")
# --- 案例 2:0.1 加十次 ---
vals2 = [0.1]*10
print()
print("0.1 加 10 次:")
print(" naive =", repr(naive_sum(vals2)))
print(" kahan =", repr(kahan_sum(vals2)))
print(" neumaier=", repr(neumaier_sum(vals2)))
print(" SymPy 精確 =", sp.Rational(1, 10)*10)
# --- 案例 3:等比級數,累積誤差隨 N 成長 ---
print()
N = 200000
r = 1.0000001
g = [1e-8 * r**k for k in range(N)]
print("N =", N, " 的等比級數 sum 1e-8 * r^k")
print(" naive =", naive_sum(g))
print(" kahan =", kahan_sum(g))
print(" neumaier=", neumaier_sum(g))
print(" math.fsum =", math.fsum(g))
print(" 閉式精確值 =", 1e-8*(1 - r**N)/(1 - r))
print()
print("累積誤差 |naive - neumaier| 隨 N 成長:")
for n in [10**3, 10**4, 10**5, N]:
vv = g[:n]
print(f" N={n:>8} |naive - neumaier| = {abs(naive_sum(vv)-neumaier_sum(vv)):.3e}")
實際輸出:
vals = [1.0, 1e+16, 1.0, -1e+16]
naive = 0.0
kahan = 0.0
neumaier= 2.0
SymPy 精確 = 2
0.1 加 10 次:
naive = 0.9999999999999999
kahan = 1.0
neumaier= 1.0
SymPy 精確 = 1
N = 200000 的等比級數 sum 1e-8 * r^k
naive = 0.002020133900667315
kahan = 0.002020133900667288
neumaier= 0.002020133900667288
math.fsum = 0.002020133900667288
閉式精確值 = 0.002020133900667298
累積誤差 |naive - neumaier| 隨 N 成長:
N= 1000 |naive - neumaier| = 1.016e-20
N= 10000 |naive - neumaier| = 8.132e-20
N= 100000 |naive - neumaier| = 2.602e-18
N= 200000 |naive - neumaier| = 2.689e-17
三個觀察:
- 案例 1 是本節最精彩的一行:naive 與 Kahan 都給出
0.0,而 Neumaier 給出2.0——完全正確。為什麼 Kahan 在這裡失敗?因為 Kahan 的補償量 \(c\) 只記錄了「上一步」的誤差,而這裡的問題需要跨多步的補償( \(10^{16}\) 吃掉的 \(1\) 要在 \(10^{16}\) 被減回來之後才有意義)。Neumaier 把所有被吃掉的誤差累積在一個獨立的變數裡,所以能救回來。這個教訓:演算法的細節比演算法的名稱重要。 - 案例 2:naive 給
0.9999999999999999,Kahan 與 Neumaier 都給1.0(正確)。 - 案例 3 的累積誤差表: \(N\) 從 \(10^3\) 增到 \(2\times10^5\) ,naive 與 Neumaier 的差距從 \(10^{-20}\) 增到 \(2.7\times10^{-17}\) ——大約按 \(N\) 線性成長( \(N\) 增 200 倍,誤差增約 2600 倍,接近線性)。Kahan/Neumaier 把這個差距維持在「最後幾位」而不是「越積越多」。
SymPy 的對照:如果用 sp.Rational 做同樣的求和,誤差是零,因為有理數運算完全精確。代價是:分母會爆炸( \(10^{N}\) 位數),算得慢。
這裡有一個 beautiful 的三角:
| 方法 | 精度 | 速度 |
|---|---|---|
sp.Rational 精確求和 | 完美 | 慢(分母膨脹) |
| Kahan/Neumaier + float | 接近完美 | 快 |
| 純 float 累加 | 差 | 最快 |
沒有普遍最優,只有在特定誤差結構下的取捨。
13.2.6 符號計算 vs 數值計算:SymPy 的位置#
現在我們可以明確地回答本節的核心問題。SymPy 在誤差光譜上的位置是獨特的:
完全精確 完全不精確
|<---------------------- SymPy ---------------------->|
Rational, Integer, Symbol, Add, Mul, Pow ... evalf 到 n 位
(精確算術,零誤差) (高精度數值近似)
SymPy 的三種「精度模式」:
| 模式 | 例子 | 誤差 | 用途 |
|---|---|---|---|
| 完全精確 | sp.Rational(1,3)、sp.Integer(2)**100、sp.sqrt(2) | \(0\) | 代數化簡、證明、封閉形式 |
| 符號近似 | sp.pi、sp.E、sp.I | \(0\) (這些是精確定義的常數) | 精確運算的基礎 |
| 受控數值 | sp.N(sp.pi, 50) | \(\le 10^{-50}\) | 報告具體數值 |
關鍵區別:sp.pi 不是 3.14159... ,它是「圓周率」這個精確物件。sp.N(sp.pi, 50) 是「用 50 位去近似那個精確物件」。而 Python 的 math.pi 一出生就是 16 位近似——它無法再變精確。
我們用一段程式碼把這個分工演示清楚,並且展示符號系統在「找封閉形式」上的威力。
import numpy as np
import sympy as sp
# ============================================================
# 符號 vs 數值:分工的實際示範
# ============================================================
n = sp.Symbol('n', integer=True, positive=True)
# --- 1) 符號端:精確封閉形式 ---
s1 = sp.summation(1/n**2, (n, 1, sp.oo))
print("sum 1/n^2 =", s1, "=", sp.N(s1, 20))
s2 = sp.summation(sp.Rational(1, 2)**n, (n, 1, sp.oo))
print("sum (1/2)^n =", s2)
# --- 2) 符號端:精確有理數運算 ---
print()
print("Rational(1,3) + Rational(1,6) =", sp.Rational(1, 3) + sp.Rational(1, 6), "(精確,不是浮點)")
print(" =", sp.N(sp.Rational(1, 3) + sp.Rational(1, 6), 20), "(轉成小數)")
# --- 3) 數值端:直接浮點累加會怎樣 ---
print()
vals = [1.0/n**2 for n in range(1, 200001)]
s_float = 0.0
for v in vals:
s_float += v
s_exact = float(s1)
print("sum_{n=1}^{200000} 1/n^2 的浮點累加 =", repr(s_float))
print("sum_{n=1}^{inf} 1/n^2(符號精確) =", s_exact)
print("差 =", abs(s_float - s_exact))
# --- 4) 符號端:積分也能精確到封閉形式 ---
x = sp.Symbol('x')
I = sp.integrate(sp.exp(-x**2), (x, -sp.oo, sp.oo))
print()
print("int exp(-x^2) dx(整個實軸)= ", I, "=", sp.N(I, 20))
print("這在數值上需要無窮多點;符號運算一步到位。")
# --- 5) 混合:符號求導 + 高精度數值代入 ---
print()
f = x**2 * sp.exp(-x)
df = sp.diff(f, x)
print("f'(x) =", df)
for d in [15, 30, 50]:
print(f" f'(1.7) 到 {d} 位 =", sp.N(df.subs(x, sp.Rational(17, 10)), d))
實際輸出:
sum 1/n^2 = pi**2/6 = 1.6449340668482264365
sum (1/2)^n = 1
Rational(1,3) + Rational(1,6) = 1/2 (精確,不是浮點)
= 0.50000000000000000000 (轉成小數)
sum_{n=1}^{200000} 1/n^2 的浮點累加 = 1.6449290668607415
sum_{n=1}^{inf} 1/n^2(符號精確) = 1.6449340668482264
差 = 4.999987484932689e-06
int exp(-x^2) dx(整個實軸)= sqrt(pi) = 1.7724538509055160273
這在數值上需要無窮多點;符號運算一步到位。
f'(x) = -x**2*exp(-x) + 2*x*exp(-x)
f'(1.7) 到 15 位 = 0.0931685972668947
f'(1.7) 到 30 位 = 0.0931685972668946716141894272571
f'(1.7) 到 50 位 = 0.093168597266894671614189427257059341242538795175326
解讀:
sum 1/n^2 = pi**2/6:巴塞爾問題。符號運算給出精確的封閉形式,它把 \(\pi\) 與 \(\zeta(2)\) 放進同一個式子裡。數值方法做不到這件事——它只能給你一個小數。- 浮點累加 20 萬項的誤差是 \(5\times10^{-6}\) ** :這是 \(O(n\varepsilon) = 2\times10^5\times 2.2\times10^{-16} \approx 4.4\times10^{-11}\) 的估計大了 5 個數量級——因為 \(\sum 1/n^2\) 的累積和本身就是 \(\sim 1.6\) ,而每一步的相對誤差是 \(\varepsilon\) ,累積起來是 \(n\varepsilon\cdot 1.6\) 。更精確地說,誤差與 \(\sum |a_i|\) 成正比**,這正是 Kahan 演算法要對付的量。
- 最後一段展示了「混合模式」的威力:
sp.diff給出精確的符號導數,然後sp.Rational(17,10)給出精確的代入值,最後sp.N(..., 50)給出 50 位近似。整條鏈路上沒有任何一步是近似的,除了最後「報告」那一步。
這就是「計算機輔助證明」的日常形態(第 1.3.6 節已經出現過):符號負責結構與精確,數值負責驗證。
13.2.7 一張圖串起本節#
flowchart TD
A["真實問題<br/>資料不精確 / 運算有捨入 / 近似有截斷"] --> B["誤差三分法"]
B --> C["截斷誤差<br/>Taylor 餘項 O(h^{n+1})<br/>第 5 章"]
B --> D["捨入誤差<br/>累積 O(n * eps)<br/>Kahan 補償求和 1965"]
B --> E["問題敏感度<br/>條件數 kappa(A)<br/>Wilkinson 1969"]
C --> F["總誤差 = 截斷 + 捨入 + 敏感度放大"]
D --> F
E --> F
F --> G["對策一:提高精度<br/>sp.N(expr, 50)<br/>sp.Rational"]
F --> H["對策二:改善演算法<br/>Romberg / 高斯求積<br/>牛頓法 / 補償求和"]
F --> I["對策三:改寫問題<br/>避免相消<br/>用符號封閉形式"]
G --> J["SymPy 精確算術區<br/>Rational / Integer / Symbol"]
H --> K["SymPy 高精度數值區<br/>evalf / nsimplify"]
I --> J
J --> L["分界線:sp.N(expr, n)<br/>報告 n 位,但物件本身仍是精確的"]
K --> L習題#
習題 13.2.1(浮點與 sp.Rational)
(a) 用 sp.Rational 計算 \(\frac{1}{3} + \frac{1}{7} + \frac{1}{11}\) ,並與 float 版本比較; (b) 找出最小的正整數 \(n\) 使得 float(1 + 10**(-n)) == 1.0 (即 \(10^{-n}\) 小於 machine epsilon); (c) 解釋為什麼 sp.N(sp.Rational(1,3), 30) 與 float(sp.Rational(1,3)) 的「資訊量」不同。
習題 13.2.2(條件數與 Wilkinson 例子)
(a) 對 \(A = \begin{pmatrix} 1 & 1 \\ 1 & 1+\varepsilon \end{pmatrix}\) ,用 sp 推導 \(\kappa_2(A)\) 的封閉形式,並在 \(\varepsilon = 10^{-6}, 10^{-10}, 10^{-14}\) 時數值驗證; (b) 重現 Wilkinson 的例子:對 \(p(x) = x^3 - 3x + 1\) ,用符號求根並用 float 求根,比較有效位數; (c) 說明:為什麼「把 \(\varepsilon\) 從 \(10^{-10}\) 改成 \(10^{-14}\) 」會讓解的相對誤差變大 \(10^4\) 倍?
習題 13.2.3(牛頓法需要好初值)
對 \(f(x) = x^3 - 2x + 2\) :
(a) 從 \(x_0 = 0\) 開始牛頓迭代,觀察它發散(進入一個 2-週期的循環); (b) 從 \(x_0 = 1\) 開始,觀察它收斂; (c) 用 sp.nsolve 找出真正的根,並說明牛頓法為什麼會被卡住(提示:週期 2 的軌道對應 \(f\) 上的一個 2-週期,這與「導數接近零」有關); (d) 討論:這說明「二次收斂」的保證需要什麼前提?
習題 13.2.4(Romberg 表的建構)
(a) 用 sp.integrate 與 sp.N 求 \(\int_0^{\pi/2}\sin^2 x\,\mathrm{d}x\) 的精確值; (b) 手寫 Romberg 演算法(不要用 scipy),對同一積分建出 \(4\times4\) 的表; (c) 驗證 \(R_{k,1}\) 恰好是 Simpson 公式(對 \(k = 1, 2\) 檢查), \(R_{k,2}\) 是 Boole 公式; (d) 比較 Romberg 對角線與複合梯形法在同樣求值次數下的誤差。
習題 13.2.5(Kahan 演算法的失效案例)
13.2.5 節的例子中 Kahan 在 [1.0, 1e16, 1.0, -1e16] 上失敗,但 Neumaier 成功。
(a) 逐步追蹤 Kahan 演算法中的 \(s\) 與 \(c\) (用 print 印出每一步),說明為什麼它失敗; (b) 修改 Kahan 演算法使得它變成 Neumaier 版本,驗證結果; (c) 找另一組數字(例如 [1e16, 1.0, -1e16, 1.0])讓 Neumaier 失敗; (d) 說明:這是否表示「補償求和」這個想法有缺陷?還是只是「這個特定實作有侷限」?
習題 13.2.6(符號 vs 數值:何時用哪個)
(a) 用 sp.integrate 求 \(\int_0^1 e^{-x^2}\,\mathrm{d}x\) ;說明為什麼它回傳的是 erf 而不是小數; (b) 用 sp.N 求它到 30 位; (c) 用 sp.integrate 求 \(\int_0^1 \frac{\mathrm{d}x}{1+x^2}\) ;它有初等原函數嗎?與 (a) 對照,說明 SymPy 的積分器策略; (d) 討論:一個工程師拿到 \(\int_0^{100}\frac{\mathrm{d}x}{1+x^2}\) 這個任務,應該用符號還是用數值?
習題 13.2.7(誤差的三個來源)
對計算 \(\int_0^1 \frac{\mathrm{d}x}{1+x^2} = \frac{\pi}{4}\) 這個任務:
(a) 列出截斷誤差、捨入誤差、問題敏感度各是什麼; (b) 用複合梯形法與 Romberg 各算一次,量化各自的主要誤差來源; (c) 說明:對這個問題,用符號方法( \(\pi/4\) )比任何數值方法都好,為什麼?
小結#
- IEEE 754 雙精度有 53 位尾數, \(\varepsilon_{\text{mach}} = 2^{-52} \approx 2.22\times10^{-16}\) 。相對誤差 \(\delta\) 在相減時會被放大 \(1/\delta\) 倍,這是「相消誤差」的根源。
- 誤差的三個來源必須分開看:截斷誤差(近似過程,Taylor 餘項給界)、捨入誤差(浮點表示, \(O(n\varepsilon)\) 累積)、問題敏感度(條件數 \(\kappa\) )。總誤差 \(\approx\) 截斷 + 捨入 + 敏感度 \(\times\) 輸入誤差。
- Wilkinson(1969) 的貢獻是把「問題的病態」與「演算法的優劣」分開:病態問題再多算位數也救不回來。
- 牛頓法二次收斂( \(e_{k+1} = O(e_k^2)\) ,5 步到雙精度極限)vs 二分法線性收斂(41 步),差距是 8 倍。二次收斂需要「 \(f' \ne 0\) 且初值夠好」的前提,否則可能發散或陷入 2-週期。
- Romberg 積分用 Richardson 外推把 \(O(h^2)\) 的梯形法提升到任意階;Gauss–Legendre 用 \(n\) 個點對 \(2n-1\) 次多項式精確,效率遠勝 Newton–Cotes。但外推會放大浮點誤差,過度外推會變差。
- Kahan(1965) 用「把捨入誤差帶到下一步」的技巧把求和誤差從 \(O(n\varepsilon)\) 降到 \(O(\varepsilon)\) ;Neumaier 改進後能處理「被加數大小懸殊」的情況。但 Kahan 在
[1, 1e16, 1, -1e16]上仍會失敗——演算法細節比名稱重要。 - SymPy 的位置是獨特的:它預設在完全精確的那一邊(
Rational、Integer、sqrt(2)、pi都是精確物件),而sp.N(expr, n)是一座通往高精度數值的橋。整條鏈路上唯一近似的一步是「報告」那一步。 - 符號給出結構,數值給出位數。 而所有關於位數的問題,最後都回到「誤差從哪裡來、會被放大多少」。
下一節我們把「梯度下降」這條線拉回來,用 SymPy 做符號最佳化(sp.solve、sp.minimum、sp.maximum、Lagrange 乘數法),並且揭開 sp.diff 的黑箱:它其實是「超前的微分算子 + 替換」,和深度學習框架裡的自動微分是同一族譜。