7.2 SVD 計算與低秩近似#
本節目標#
- 熟練使用
np.linalg.svd:full_matrices、compute_uv參數與(U, s, Vt)的分離 - 觀察真實資料的奇異值衰減現象:少數奇異值就佔了大部分能量
- 理解 Eckart–Young 定理:截斷 SVD 是最佳的低秩近似
- 做重建誤差隨 \(k\) 變化的曲線實驗,並畫出不同 \(k\) 的近似網格圖
np.linalg.svd 的使用心法#
NumPy 一次算齊三個因子,但回傳形式有兩個細節要注意:
import numpy as np
A = np.array([[3., 1.],
[2., 4.],
[1., 1.]]) # 3 x 2
U, s, Vt = np.linalg.svd(A)
print(U.shape) # (3, 3)——方陣!
print(s.shape) # (2,)——一維向量,不是矩陣
print(Vt.shape) # (2, 2)
- 奇異值以一維向量
s回傳(只存非零對角線),要自己拼回 \(\Sigma\) 。 U是 \(m \times m\) 方陣,即使 \(A\) 是長方陣。多出來的欄對應「零空間方向」, 有時用不到,可以用full_matrices=False改拿「精簡版」(thin SVD):
U, s, Vt = np.linalg.svd(A, full_matrices=False)
print(U.shape) # (3, 2)——只留會用到的欄
重建矩陣的兩種寫法:
# 寫法一:拼出完整 Sigma(full 版)
Sigma = np.zeros(A.shape)
Sigma[:len(s), :len(s)] = np.diag(s)
A_rec = U @ Sigma @ Vt
# 寫法二:精簡版直接乘,最省事
U, s, Vt = np.linalg.svd(A, full_matrices=False)
A_rec = (U * s) @ Vt # 廣播:把 s 乘到 U 的每一欄
print(np.allclose(A_rec, A)) # True
只要奇異值不要向量?compute_uv=False 省下算 \(U, V\) 的力氣:
s = np.linalg.svd(A, compute_uv=False)
奇異值會衰減:資料的「階層結構」#
真實資料(影像、文字、感測訊號)的奇異值通常快速衰減: 前幾個奇異值很大,後面幾十、幾百個趨近於零。 這代表資料本質上「接近低秩」——看似幾千個維度,其實主要結構只有幾層。
import numpy as np
import matplotlib.pyplot as plt
# 模擬「真實資料」:低秩結構 + 少量雜訊
rng = np.random.rand(200, 50) # 這裡先示範用隨機矩陣
s = np.linalg.svd(rng, compute_uv=False)
plt.plot(s, "o-")
plt.xlabel("索引 $i$"); plt.ylabel(r"奇異值 $\sigma_i$")
plt.title("奇異值衰減曲線")
plt.show()
第 7.3 節會用真實圖片重畫這張圖——影像的衰減曲線會陡峭得多, 這正是「圖片可以被壓縮」的數學原因。
重點直覺:奇異值按大小排序, \(\sigma_i^2\) 可解讀為「第 \(i\) 個方向承載的能量」。 衰減越快,能用越少的 \(k\) 逼近原矩陣。
Eckart–Young 定理:截斷 SVD 是最佳低秩近似#
把排序後的奇異值只保留前 \(k\) 個,其餘歸零,得到秩 \(k\) 截斷 SVD:
其中 \(U_k\) 取 \(U\) 的前 \(k\) 欄、 \(V_k\) 取 \(V^T\) 的前 \(k\) 列。
Eckart–Young 定理(1936):在所有秩不超過 \(k\) 的矩陣中, \(A_k\) 使誤差最小:
兩個實用的推論:
- 譜範數誤差就是「第一個被丟掉的奇異值」 \(\sigma_{k+1}\) ,一行就能算
- Frobenius 誤差等於「被丟掉的能量的平方和開根號」, 所以誤差比例可寫成 \(\sqrt{1 - \sum_{i \le k}\sigma_i^2 / \sum_i \sigma_i^2}\)
用 NumPy 驗證:
import numpy as np
rng = np.random.default_rng(0)
A = rng.random((30, 20))
U, s, Vt = np.linalg.svd(A, full_matrices=False)
k = 5
Ak = (U[:, :k] * s[:k]) @ Vt[:k, :]
# 譜範數誤差應等於 s[k](第 k+1 大的奇異值)
print(np.linalg.norm(A - Ak, 2), s[k])
# Frobenius 誤差應等於剩餘奇異值的平方和開根號
print(np.linalg.norm(A - Ak, "fro"), np.sqrt((s[k:]**2).sum()))
實驗一:重建誤差隨 \(k\) 的曲線#
沿著奇異值排序逐個累加,誤差會怎麼掉?我們對一個有結構的矩陣做實驗:
import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(1)
# 有明顯低秩結構的矩陣:兩層結構 + 雜訊
base = np.outer(rng.random(60), rng.random(40)) \
+ np.outer(rng.random(60), rng.random(40))
A = base + 0.05 * rng.random((60, 40))
U, s, Vt = np.linalg.svd(A, full_matrices=False)
energy = s**2 / (s**2).sum() # 每個方向的能量占比
cum_energy = np.cumsum(energy) # 前 k 個方向累積能量
k_all = np.arange(1, len(s) + 1)
err2 = np.sqrt((s**2).sum() - np.cumsum(s**2)) # Frobenius 誤差
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].plot(k_all, cum_energy, "o-")
axes[0].set_xlabel("$k$"); axes[0].set_ylabel("累積能量比例")
axes[0].set_title("前 $k$ 個奇異值保留多少能量")
axes[1].plot(k_all, err2, "o-", color="tomato")
axes[1].set_xlabel("$k$"); axes[1].set_ylabel(r"$\|A - A_k\|_F$")
axes[1].set_title("重建誤差隨 $k$ 衰減")
plt.tight_layout(); plt.show()
讀圖的方法:累積能量曲線「爬到 0.99」所需的 \(k\) ,就是「這份資料大約有幾層結構」; 誤差曲線的拐點之後再增加 \(k\) ,報酬就非常低了。
實驗二:不同 \(k\) 的近似網格圖#
數字看久了沒感覺,直接把 \(A_k\) 畫出來(先以熱圖呈現矩陣本身, 下一節換成真實圖片會更直觀):
import numpy as np
import matplotlib.pyplot as plt
# 延續上一格的 A, U, s, Vt
ks = [1, 2, 3, 5, 10, 40]
fig, axes = plt.subplots(2, 3, figsize=(12, 6))
for ax, k in zip(axes.ravel(), ks):
Ak = (U[:, :k] * s[:k]) @ Vt[:k, :]
im = ax.imshow(Ak, cmap="viridis")
rel = np.linalg.norm(A - Ak, "fro") / np.linalg.norm(A, "fro")
ax.set_title(f"$k={k}$,相對誤差 $={rel:.3f}$")
ax.axis("off")
fig.suptitle("截斷 SVD:不同 $k$ 的低秩近似")
plt.tight_layout(); plt.show()
你會觀察到經典的模式:
- \(k = 1\) :只剩「最大的一層結構」,細節全數消失
- \(k\) 越過誤差曲線的拐點後:肉眼幾乎看不出與原圖的差別
- \(k\) 接近滿秩:完美重建,但什麼都沒省
壓縮的帳本:儲存 \(A_k\) 只需要 \(k(m + n + 1)\) 個數字 ( \(U_k\) 有 \(mk\) 個、 \(\Sigma_k\) 有 \(k\) 個、 \(V_k\) 有 \(nk\) 個)。 當 \(k \ll \min(m, n)\) 時,這遠小於 \(mn\) ——下一節就把它用在真實圖片上。
小結與練習#
本節重點
np.linalg.svd回傳(U, s, Vt):s是一維向量、U預設是方陣;full_matrices=False給精簡版,(U * s) @ Vt是最省事的重建寫法- 真實資料的奇異值快速衰減 → 資料本質上接近低秩
- Eckart–Young 定理:截斷 SVD \(A_k\) 是所有秩 \(k\) 矩陣中誤差最小的, 譜範數誤差 \(= \sigma_{k+1}\) ,Frobenius 誤差 \(= \sqrt{\sigma_{k+1}^2 + \cdots}\)
- 儲存 \(A_k\) 只需 \(k(m+n+1)\) 個數字, \(k\) 小時壓縮效益極大
練習
- 寫函數
truncated_svd(A, k)回傳 \(A_k\) ,並用 Eckart–Young 的兩條公式檢查誤差。 - 對一個 \(100 \times 100\) 的隨機矩陣畫奇異值曲線,與本節「有結構的矩陣」比較:哪個衰減快?為什麼?
- 對本節的 \(A\) 求「達到 99% 累積能量」所需的最小 \(k\) (提示:
np.searchsorted(cum_energy, 0.99) + 1)。 - 計算並比較 \(k = 5\) 時的儲存量: \(k(m+n+1)\) 對 \(mn\) 的比值。 \(m = n = 1000\) 時呢?