跳轉到

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)
  1. 奇異值以一維向量 s 回傳(只存非零對角線),要自己拼回 \(\Sigma\) 。
  2. 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:

\[ A_k = U_k \Sigma_k V_k^T \]

其中 \(U_k\) 取 \(U\) 的前 \(k\) 欄、 \(V_k\) 取 \(V^T\) 的前 \(k\) 列。

Eckart–Young 定理(1936):在所有秩不超過 \(k\) 的矩陣中, \(A_k\) 使誤差最小:

\[ \min_{\operatorname{rank}(B) \le k} \|A - B\|_2 = \sigma_{k+1}, \qquad \min_{\operatorname{rank}(B) \le k} \|A - B\|_F = \sqrt{\sigma_{k+1}^2 + \cdots + \sigma_r^2} \]

兩個實用的推論:

  • 譜範數誤差就是「第一個被丟掉的奇異值」 \(\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\) 小時壓縮效益極大

練習

  1. 寫函數 truncated_svd(A, k) 回傳 \(A_k\) ,並用 Eckart–Young 的兩條公式檢查誤差。
  2. 對一個 \(100 \times 100\) 的隨機矩陣畫奇異值曲線,與本節「有結構的矩陣」比較:哪個衰減快?為什麼?
  3. 對本節的 \(A\) 求「達到 99% 累積能量」所需的最小 \(k\) (提示:np.searchsorted(cum_energy, 0.99) + 1)。
  4. 計算並比較 \(k = 5\) 時的儲存量: \(k(m+n+1)\) 對 \(mn\) 的比值。 \(m = n = 1000\) 時呢?