2.7 数值实验:亲手看见正交与谱
本节属“补充篇 · 数值线性代数工具箱”:2.x 是补充篇内部编号,不是课堂第 2 章的节号。课堂第 2 章是线性方程组的解法。
本章的四个核心论断,在教科书证明里都是干净的定理,但在浮点现实里是否成立、以什么量级成立,必须亲手测一遍。本节的前三组实验分别对应:
- 实验一(对应 2.3.3):四种正交化方法的正交性损失量级—— vs vs ;
- 实验二(对应 2.4.1、2.3.6):条件数如何预测有效数字损失,以及正态方程 vs QR 的分水岭;
- 实验三(对应 2.6.5、2.6.6):Eckart–Young 最优性与低秩压缩 / PCA。
所有代码都可以直接复制运行,只依赖 numpy;各段输出均为本教材运行环境下的真实结果。
每题都给出「报告要求」;提交时请包含四部分:① 可复现的脚本(含随机种子);② 实测输出(原样粘贴);③ 与理论预测的逐项对照表(理论值 / 实测值 / 比值);④ 对「为什么是这个量级」的一段解释。只贴代码、不给对照表的报告不合格——这些实验的目的始终是「用数据检验理论」,而不是「跑通代码」。
实验一 · 四种正交化方法的正交性退化
目的: 复现 2.3.3 的结论:三种正交化方法的正交性损失分属「 / / 」三档,分界线则位于 。预期看到:CGS 先按 增长、越过 后迅速饱和,MGS 按 增长,Householder 始终停在 量级。
做法: 取 Vandermonde 矩阵 ( 均匀取自 ),令 从 增到 , 随之增长约 10 个数量级;分别用经典 Gram–Schmidt(CGS)、修正 Gram–Schmidt(MGS)、Householder 反射做 QR 分解,测量每种方法得到的 ,它直接反映算出的 偏离正交的程度。通过标准:三种方法的实测值分别落在 、、 三条参考线上。
import numpy as np
u = np.finfo(float).eps / 2 # 单位舍入误差,双精度 ≈1.11e-16
def cgs(A):
"""经典 Gram–Schmidt:r_ij 用【原始】列 a_j"""
m, n = A.shape
Q = A.astype(float).copy(); R = np.zeros((n, n))
for j in range(n):
for i in range(j):
R[i, j] = Q[:, i] @ A[:, j] # ← 原始列
Q[:, j] -= R[i, j] * Q[:, i]
R[j, j] = np.linalg.norm(Q[:, j])
Q[:, j] /= R[j, j]
return Q, R
def mgs(A):
"""修正 Gram–Schmidt:r_ij 用【当前】列(就地更新)"""
m, n = A.shape
Q = A.astype(float).copy(); R = np.zeros((n, n))
for j in range(n):
for i in range(j):
R[i, j] = Q[:, i] @ Q[:, j] # ← 当前列
Q[:, j] -= R[i, j] * Q[:, i]
R[j, j] = np.linalg.norm(Q[:, j])
Q[:, j] /= R[j, j]
return Q, R
def house(A):
"""Householder 反射(逐步消元),返回 Q, R"""
m, n = A.shape
R = A.astype(float).copy(); Q = np.eye(m)
for k in range(min(m - 1, n)):
x = R[k:, k]
alpha = -np.sign(x[0]) * np.linalg.norm(x)
v = x - alpha * np.eye(1, len(x), 0).ravel()
nv = np.linalg.norm(v)
if nv < 1e-300: continue
v = v / nv
R[k:, :] -= 2 * np.outer(v, v @ R[k:, :])
Q[:, k:] -= 2 * np.outer(Q[:, k:] @ v, v)
return Q, np.triu(R[:n, :])
print(f"{'n':>3} {'cond(A)':>10} {'CGS':>11} {'MGS':>11} {'Householder':>12} "
f"{'cond*u':>10} {'cond^2*u':>10}")
for n in [6, 8, 10, 12, 14, 16]:
x = np.linspace(0.1, 1.0, n)
A = np.vander(x, n, increasing=True)
c = np.linalg.cond(A)
f = lambda Q: np.linalg.norm(Q.T @ Q - np.eye(Q.shape[1]))
print(f"{n:3d} {c:10.2e} {f(cgs(A)[0]):11.2e} {f(mgs(A)[0]):11.2e} "
f"{f(house(A)[0]):12.2e} {c*u:10.2e} {c*c*u:10.2e}")
实测输出(本教材运行结果):
n cond(A) CGS MGS Householder cond*u cond^2*u
6 1.02e+04 8.78e-10 1.47e-13 1.22e-15 1.1e-12 1.2e-08
8 7.40e+05 2.51e-05 2.28e-11 1.29e-15 8.2e-11 6.1e-05
10 5.60e+07 1.19e+00 8.11e-10 2.45e-15 6.2e-09 3.5e-01
12 4.35e+09 2.47e+00 1.24e-07 2.12e-15 4.8e-07 2.1e+03
14 3.42e+11 4.48e+00 4.70e-06 2.18e-15 3.8e-05 1.3e+07
16 2.73e+13 6.55e+00 4.70e-04 3.00e-15 3.0e-03 8.3e+10
结论(逐条对照上面的实测输出表):
- Householder 列始终 :从 到 都停在 ,与 增长 9 个数量级毫无关系。这是「每一步都是正交变换」的直接回报(定理 2.1.6:正交变换不放大误差)。
- MGS 列按 增长:表中 行的理论值( 列)为 、实测值(MGS 列)为 ; 行则是 与 ——同量级(差 6 倍左右,正是常数因子应有的幅度)。
- CGS 列先按 增长,随后饱和于 :表中 行的理论值( 列)为 、实测值为 ; 行理论值高达 、实测值却只有 ——完全饱和( 的平凡上界是 )。饱和不代表变好,它代表 CGS 已经错到顶。
分界线的实测证据: 在实验一输出表中,从 行()到 行(),CGS 列的数值由 跳到 ;而 恰好落在这个区间内——实测与「 时 CGS 失效」的预测吻合。
报告要求: ① 打印每列的「实测 / 理论」比值,确认 Householder 列的比值恒为 量级的常数;② 把取样点 换成几何分布 (更病态),观察 CGS 列是否更早饱和;③ 额外加一列「CGS 跑两遍(再正交化)」的结果,验证它能把误差压到 MGS 量级(这是历史上的实用方案)。
实验二 · 条件数预测有效数字,正态方程 vs QR
目的: 验证「损失的有效数字位数 」(2.4.2),并复现 2.3.6 的分水岭:条件数超过 后,正态方程失效而 QR 仍可用。预期看到:实测误差与 同量级;分水岭两侧,正态方程的误差由「尚可使用」跳到「完全崩溃」,QR 误差则始终停在 量级。
做法(两部分): (a) 对 阶 Hilbert 矩阵取真解 、构造 ,用双精度求解,比较实测误差与 ; (b) 对病态 Vandermonde 最小二乘问题,分别用正态方程与 QR 求解,比较系数的误差。通过标准:(a) 的实测误差与 同量级;(b) 中当 时,正态方程误差暴涨,而 QR 误差仍停在 量级。
import numpy as np
u = np.finfo(float).eps / 2 # ≈1.11e-16
print("== (a) Hilbert 矩阵:条件数 vs 实测误差 ==")
print(f"{'n':>3} {'cond2':>10} {'log10(cond)':>12} {'实测误差':>11} {'cond*u':>10}")
for n in [4, 6, 8, 10, 12]:
H = np.array([[1/(i + j + 1.0) for j in range(n)] for i in range(n)])
x = np.ones(n); b = H @ x
xh = np.linalg.solve(H, b) # 双精度求解
c = np.linalg.cond(H, 2)
print(f"{n:3d} {c:10.2e} {np.log10(c):12.1f} "
f"{np.max(np.abs(xh - x)):11.2e} {c*u:10.2e}")
print()
print("== (b) 最小二乘:正态方程 vs QR ==")
print(f"{'m':>3} {'n':>3} {'cond(A)':>10} {'正态方程误差':>13} {'QR 误差':>11}")
for m, n in [(12, 10), (20, 15)]:
xd = np.linspace(0, 1, m)
A = np.vander(xd, n, increasing=True)
c0 = np.ones(n); b = A @ c0 # 相容右端(真解 c0)
# 正态方程
c_ne = np.linalg.solve(A.T @ A, A.T @ b)
# QR
Q, R = np.linalg.qr(A)
c_qr = np.linalg.solve(R, Q.T @ b)
print(f"{m:3d} {n:3d} {np.linalg.cond(A):10.2e} "
f"{np.max(np.abs(c_ne - c0)):13.2e} {np.max(np.abs(c_qr - c0)):11.2e}")
实测输出(本教材运行结果):
== (a) Hilbert 矩阵:条件数 vs 实测误差 ==
n cond2 log10(cond) 实测误差 cond*u
4 1.55e+04 4.2 4.996e-14 1.72e-12
6 1.50e+07 7.2 2.287e-10 1.66e-09
8 1.53e+10 10.2 1.613e-07 1.69e-06
10 1.60e+13 13.2 7.441e-04 1.78e-03
12 1.72e+16 16.2 2.201e-01 1.91e+00
== (b) 最小二乘:正态方程 vs QR ==
m n cond(A) 正态方程误差 QR 误差
12 10 6.84e+06 1.31e-04 5.41e-11
20 15 6.03e+10 2.81e+01 2.23e-06
结论:
- (a) 部分验证了 :对照输出表 (a),每一行的实测误差都与 同量级( 行 对 , 行 对 )。但实测常比这个界小几倍( 行 对 ,差 34 倍)——因为(2.18)给出的正是最坏情形界。
- (a) 部分的 是「危险信号」的教科书案例:,误差已达 (真值为 )——求解器不报错,结果却毫无意义。工程判据: 就必须重新表述问题。
- (b) 部分给出最干净的分水岭:对照输出表 (b),第一行 ,正态方程误差 (已丢 4 位,但还可用);第二行 ,正态方程误差涨到 (完全崩溃),而 QR 误差仍是 。理论预测 QR 误差为 ,与实测同量级 ✓。
报告要求: ① 为 (a) 画「实测误差 / 」随 变化的曲线,确认该比值稳定在 量级(说明这个界紧到只差常数因子);② 为 (b) 把 再推大一档(如 ),找到正态方程误差超过 的临界点,并与 对照;③ 用 np.linalg.lstsq 作第三方对照,确认它与 QR 结果一致(lstsq 内部用 SVD 或 QR)。
实验三 · 低秩逼近、截断伪逆与 PCA
目的: 验证 Eckart–Young 定理( 精确成立),实测低秩压缩的「存储–误差」曲线,并用 SVD 做一次 PCA,与协方差矩阵的特征值分解对照。预期看到:截断误差与 一致到机器精度;很小的存储占比就能复原图像的大部分能量;SVD 的奇异值与协方差特征值满足 。
import numpy as np
rng = np.random.default_rng(0)
# ---------- (a) Eckart–Young:误差 = 被砍掉的第一个奇异值 ----------
print("== (a) Eckart–Young 验证 ==")
m, n = 60, 40
U_, _, Vt_ = np.linalg.svd(rng.normal(size=(m, n)), full_matrices=False)
dec = np.exp(-np.arange(n) / 2.5) # 精确指定奇异值(指数衰减)
A = (U_ * dec) @ Vt_
U, S, Vt = np.linalg.svd(A)
for k in [1, 2, 3, 5, 8, 10]:
Ak = (U[:, :k] * S[:k]) @ Vt[:k]
e2 = np.linalg.norm(A - Ak, 2)
eF = np.linalg.norm(A - Ak, 'fro')
print(f"k={k:2d} ||A-Ak||_2={e2:.6e} (σ{k+1}={S[k]:.6e}) "
f"||A-Ak||_F={eF:.6e} (√Σσ²={np.sqrt((S[k:]**2).sum()):.6e})")
# ---------- (b) 合成图像的低秩压缩 ----------
print()
print("== (b) 图像低秩压缩 ==")
N = 120
xx, yy = np.meshgrid(np.linspace(0, 1, N), np.linspace(0, 1, N))
img = (np.sin(3*np.pi*xx)*np.cos(2*np.pi*yy) + 0.6*np.sin(7*np.pi*yy)
+ 0.4*np.cos(11*np.pi*xx*yy) + (np.abs(xx - 0.5) < 0.08)*1.2)
U, S, Vt = np.linalg.svd(img, full_matrices=False)
rngv = img.max() - img.min()
print("前 12 个奇异值:", np.round(S[:12], 3))
for k in [1, 2, 3, 5, 8, 12, 20]:
Ak = (U[:, :k] * S[:k]) @ Vt[:k]
rmse = np.sqrt(((img - Ak)**2).mean())
print(f"k={k:2d} 存储 2k/N={2*k/N:5.1%} "
f"||E||F/||A||F={np.linalg.norm(img-Ak,'fro')/np.linalg.norm(img,'fro'):.5f} "
f"RMSE={rmse:.4f} PSNR={20*np.log10(rngv/rmse):.2f} dB")
# ---------- (c) PCA:SVD 与协方差特征值分解对照 ----------
print()
print("== (c) PCA ==")
rng2 = np.random.default_rng(3)
L = np.linalg.cholesky(np.array([[3., 2.], [2., 2.]]))
X = rng2.normal(size=(400, 2)) @ L.T
Xc = X - X.mean(0) # 必须中心化!
C = Xc.T @ Xc / (len(X) - 1) # 样本协方差
w, V = np.linalg.eigh(C) # 特征值分解
print("样本协方差 =", np.round(C, 4).tolist())
print("特征值 =", np.round(w, 4), " (真实协方差的理论值 4.5616 / 0.4384)")
print("主方向 =", np.round(V[:, 1], 4), " 方差解释比 =", np.round(w[::-1]/w.sum(), 4))
print("SVD 对照: σ =", np.round(np.linalg.svd(Xc, compute_uv=False), 4),
" sqrt((n-1)λ) =", np.round(np.sqrt((len(X)-1)*w), 4))
# ---------- (d) 截断伪逆:截断点决定成败 ----------
print()
print("== (d) 截断伪逆 ==")
eps_n = 1e-9
A3 = rng.normal(size=(30, 3)) @ rng.normal(size=(3, 20))
A3n = A3 + eps_n * rng.normal(size=(30, 20)) # 加噪声
s = np.linalg.svd(A3n, compute_uv=False)
thr = eps_n * np.linalg.norm(A3, 2)
print(f"噪声阈值 ≈ {thr:.3e};σ3={s[2]:.3e} σ4={s[3]:.3e}")
print(f"默认阈值 rank = {np.linalg.matrix_rank(A3n)} → 按噪声阈值截断 rank = "
f"{(s > thr).sum()}")
实测输出(本教材运行结果):
== (a) Eckart–Young 验证 ==
k= 1 ||A-Ak||_2=6.703200e-01 (σ2=6.703200e-01) ||A-Ak||_F=9.033085e-01 (√Σσ²=9.033085e-01)
k= 2 ||A-Ak||_2=4.493290e-01 (σ3=4.493290e-01) ||A-Ak||_F=6.055058e-01 (√Σσ²=6.055058e-01)
k= 3 ||A-Ak||_2=3.011942e-01 (σ4=3.011942e-01) ||A-Ak||_F=4.058827e-01 (√Σσ²=4.058827e-01)
k= 5 ||A-Ak||_2=1.353353e-01 (σ6=1.353353e-01) ||A-Ak||_F=1.823748e-01 (√Σσ²=1.823748e-01)
k= 8 ||A-Ak||_2=4.076220e-02 (σ9=4.076220e-02) ||A-Ak||_F=5.493025e-02 (√Σσ²=5.493025e-02)
k=10 ||A-Ak||_2=1.831564e-02 (σ11=1.831564e-02) ||A-Ak||_F=2.468175e-02 (√Σσ²=2.468175e-02)
== (b) 图像低秩压缩 ==
前 12 个奇异值: [75.876 71.599 23.769 10.148 10.148 10.148 10.142 9.827 7.829 6.711 5.492 3.607]
k= 1 存储 2k/N= 1.7% ||E||F/||A||F=0.72427 RMSE=0.6642 PSNR=17.27 dB
k= 2 存储 2k/N= 3.3% ||E||F/||A||F=0.31815 RMSE=0.2917 PSNR=24.42 dB
k= 3 存储 2k/N= 5.0% ||E||F/||A||F=0.23359 RMSE=0.2142 PSNR=27.10 dB
k= 5 存储 2k/N= 8.3% ||E||F/||A||F=0.19379 RMSE=0.1777 PSNR=28.72 dB
k= 8 存储 2k/N=13.3% ||E||F/||A||F=0.11216 RMSE=0.1029 PSNR=33.47 dB
k=12 存储 2k/N=20.0% ||E||F/||A||F=0.01524 RMSE=0.0140 PSNR=50.81 dB
k=20 存储 2k/N=33.3% ||E||F/||A||F=0.00000 RMSE=0.0000 PSNR=201.71 dB
== (c) PCA ==
样本协方差 = [[3.0504, 2.0862], [2.0862, 2.0822]]
特征值 = [0.4247 4.708 ] (真实协方差的理论值 4.5616 / 0.4384)
主方向 = [-0.783 -0.6221] 方差解释比 = [0.9173 0.0827]
SVD 对照: σ = [43.3414 13.0172] sqrt((n-1)λ) = [13.0172 43.3414]
== (d) 截断伪逆 ==
噪声阈值 ≈ 3.750e-08;σ3=4.766e-08 σ4=3.758e-08
默认阈值 rank = 20 → 按噪声阈值截断 rank = 3
结论:
- (a) 部分的每一行都精确成立:、,与输出中括号里的理论值逐位吻合到机器精度——这是 定理 2.6.4 的直接验证。注意误差随 下降极快: 时为 、 时 、 时 ,(满秩)时降到 (机器精度)——因为构造时奇异值按指数衰减,前 个就抓住了几乎全部能量( 已占 )。
- (b) 中合成图像的「奇异值谱形状」决定可压缩性: 四个几乎相等( 不在此列)(对应图像里那条亮带:它低秩,贡献几个几乎相同的奇异值,同时也是 与 项的重合之处); 时 PSNR 只从 涨到 (收益小), 时却从 跳到 (收益巨大,因为跨过了 这段陡降)。 时 RMSE 已降到 (PSNR dB,即机器精度级别)——该合成图像的数值秩只有 (前 个奇异值超过 ),所以用 的存储即可无损重建。可见压缩性能由奇异值的衰减速度(即矩阵的「有效秩」)决定,而与矩阵大小无关。
- (c) 中 SVD 与特征值分解给出同一答案: 精确成立( 对 ✓)。但两者的排列顺序相反——
eigh输出升序、svd输出降序。样本特征值 与真实值 有偏差,这是采样波动( 的有限样本)造成的,不是算法误差。 - (d) 是本节最重要的「实践警告」:加噪后 、——两者都在噪声水平 附近。此时
np.linalg.matrix_rank的默认阈值()判定 rank ,完全错误;按噪声水平截断得 rank ,才是真相。这就是「数值秩的判定必须依赖数据噪声水平」(2.6.4 思考题)的实测证据。
报告要求: ① 为 (a) 画 与 的重合图(二者应完全重合),再叠加一条「200 个随机秩 矩阵的最小误差」曲线(它应始终在 曲线上方);② 为 (b) 画「存储占比 vs PSNR」曲线,标出拐点,解释拐点与奇异值谱的对应;③ 为 (d) 把噪声水平从 扫到 ,画出「默认 rank 判定」与「噪声阈值 rank 判定」的对照曲线,指出两者开始分离的噪声水平(提示:当噪声超过 量级时)。
实验四(选做)· 幂法、Gerschgorin 与特征值敏感性
目的: 验证 2.5.3 的收敛速度 、定理 2.5.2 的圆盘围栏,以及 2.5.5 的「对称 vs 非正规 」。预期看到:幂法相邻两次误差的比值稳定在 ;三个行圆盘罩住全部特征值;同一扰动量级下,非正规矩阵的特征值移动 ,对称矩阵却只有 。
import numpy as np
np.set_printoptions(precision=6, suppress=True)
# (a) 幂法 + Rayleigh 商:验证速度 (λ2/λ1)^2
A = np.array([[5., 2, 0], [2, 4, 1], [0, 1, 3]])
w = np.sort(np.abs(np.linalg.eigvals(A)))[::-1]
print("== (a) 幂法:|λ2/λ1| =", f"{w[1]/w[0]:.6f}", " 理论误差比 =",
f"{(w[1]/w[0])**2:.6f}")
x = np.ones(3) / np.sqrt(3); prev = None
for k in range(1, 13):
y = A @ x; x = y / np.linalg.norm(y); r = x @ A @ x
ratio = "" if prev is None else f" 实测比值={abs(r-w[0])/prev:.4f}"
prev = abs(r - w[0])
print(f" k={k:2d} Rayleigh={r:.10f} 误差={abs(r-w[0]):.3e}{ratio}")
# (b) Gerschgorin 圆盘
print()
print("== (b) Gerschgorin ==")
A = np.array([[10., -1, 2], [0, 4, 1], [-2, 1, 5]])
print("真实特征值 =", np.sort(np.linalg.eigvals(A)))
for i in range(3):
r_i = np.abs(A[i]).sum() - abs(A[i, i])
print(f" 行圆盘 {i+1}: 中心 {A[i,i]:5.1f} 半径 {r_i:4.1f} "
f"区间 [{A[i,i]-r_i:6.2f}, {A[i,i]+r_i:6.2f}]")
# (c) 特征值对扰动的敏感性
print()
print("== (c) 对称 vs Jordan ==")
J = np.array([[1., 1], [0, 1]]); S = np.array([[1., 0], [0, 2.]])
for t in [1e-2, 1e-4, 1e-8, 1e-12]:
ej = np.linalg.eigvals(J + np.array([[0., 0], [t, 0]]))
es = np.linalg.eigvalsh(S + np.array([[0., t], [t, 0]]))
print(f" t={t:.0e} Jordan 移动={max(abs(ej-1)):.3e} (√t={np.sqrt(t):.1e}) "
f"对称 移动={max(abs(es-np.array([1.,2.]))):.3e}")
预期结果与报告要求: (a) 输出中「实测比值」一列应稳定在 附近(理论值 );若改用 计算特征值估计,比值约为 (只有一阶收敛、慢一倍)。(b) 三个行圆盘 覆盖全部真特征值 ,且 孤立地只含 个特征值。(c) Jordan 矩阵的特征值移动量精确为 ,对称矩阵的特征值移动量约为 ——同一扰动量级下, 越小差得越悬殊: 时约 倍, 时已达 倍。报告要点:解释 (c) 中「为什么对称矩阵的移动量是 而不是 」(提示:本例扰动的一阶项 恰为零,主导的是二阶项,见 2.5.4 的变分刻画)。