跳轉到

8.2 推薦系統——用 SVD 完成整個世界的評分#

本節目標#

  • 理解推薦系統背後的資料形狀:使用者 × 物品的評分矩陣,以及它的稀疏性問題
  • 建立協同過濾的直覺:喜歡相似物品的人會被放在一起
  • 學會用截斷 SVD 做矩陣補全,預測還沒被評分的格子
  • 解讀隱語義(latent factors):SVD 把「人」和「電影」各自變成一組座標
  • 完整實例:6 位使用者 × 5 部電影,手造矩陣 → SVD → 預測 → 推薦
  • 實驗:改變 rank 看預測變化,並補充實務上的交叉驗證 RMSE

問題:一個幾乎全空的矩陣#

Netflix 資料集有幾十萬使用者、上萬部電影。把它排成一個巨大矩陣 \(R\) ( \(R_{ij}\) = 使用者 \(i\) 對電影 \(j\) 的評分),會發現一個殘酷事實:

使用者平均只看過總片量的 \(1\%\) 以下,矩陣的 \(99\%\) 以上是「沒有資料」。

推薦系統的核心問題就是矩陣補全(matrix completion): 用已知的少數格子,猜出整個矩陣,再把預測分數最高的格子推給對應的使用者。

用 NaN 表示沒評分,先造一個玩具版評分矩陣:

import numpy as np

np.set_printoptions(precision=2, suppress=True)

users  = ["小王", "小李", "小張", "小陳", "小劉", "小楊"]
movies = ["星際大戰", "星際效應", "異形", "愛在黎明破曉", "戀夏 500 日"]

# 1-5 分評分,NaN = 沒看過
R = np.array([
    [5, 5, 4, np.nan, np.nan],   # 小王:愛科幻
    [4, np.nan, 5, np.nan, 1],   # 小李:愛科幻、討厭愛情
    [np.nan, 4, np.nan, 1, 1],   # 小張:愛科幻、討厭愛情
    [np.nan, np.nan, np.nan, 5, 4],  # 小陳:愛愛情片
    [1, 2, 1, 5, np.nan],        # 小劉:愛愛情片
    [np.nan, np.nan, 2, 4, np.nan],  # 小楊:偏愛情片
])
mask = ~np.isnan(R)              # True = 有評分
print(f"密度 = {mask.mean():.0%}({mask.sum()} / {R.size} 格有資料)")
# 密度 = 73%(22 / 30 格有資料)——玩具版還算稠密,真實世界 < 1%

協同過濾的直覺#

觀察上面的矩陣:小王、小李、小張的評分模式非常像(科幻高分、愛情低分)。 所以即使小王沒看過《異形》,我們也可以參考他的「鄰居」來猜分數—— 這就是協同過濾(collaborative filtering):

不需要懂電影內容,只需要知道「誰和誰口味相似」。

問題是「相似」怎麼量化?直接比對兩列的共同評分(記憶體式方法)很脆弱: 共同評分太少時,相似度不可靠。更好的做法是先把每個人壓縮成一組抽象特徵, 再在特徵空間裡比較——這正是矩陣分解的舞台。


矩陣補全:截斷 SVD#

第 6 章學過:任何矩陣都能奇異值分解 \(R = U \Sigma V^T\) , 而最大的 \(k\) 個奇異值就能最佳地近似原矩陣(Eckart–Young 定理):

\[ R \approx R_k = U_k \, \Sigma_k \, V_k^T \]

假設真實的評分矩陣其實是低秩的——只要 \(k\) 個抽象因素 (例如「科幻程度偏好 × 電影科幻程度」)就能大致決定所有評分—— 那麼對已知評分做截斷 SVD,把缺失值先填成 0 之後重建, 重建矩陣在缺失位置上的值,就是我們的預測。

def svd_impute(R, k):
    """以 0 填補缺失,做截斷 SVD 重建"""
    R0 = np.nan_to_num(R, nan=0.0)
    U, s, Vt = np.linalg.svd(R0, full_matrices=False)
    return (U[:, :k] * s[:k]) @ Vt[:k, :], s

R_pred, s = svd_impute(R, k=2)
print("奇異值:", s.round(2))
# 奇異值: [10.9  8.15 3.92 2.12 1.25]  ← 前兩個遠大於其餘,低秩假設成立
print(np.where(mask, np.nan, R_pred).round(2))  # 只看預測出來的格子
[[ nan   nan   nan  0.31  0.65]
 [ nan  4.34   nan  0.26 -0.31]
 [ 4.26   nan  3.72 -0.31  0.42]
 [ nan   nan   nan   nan   nan]
 [ nan   nan   nan   nan  4.63]
 [ 0.95  1.23   nan   nan  4.47]]

檢查幾個預測的合理性:

  • 小王對《愛在黎明破曉》預測 \(0.31\) 、對《戀夏 500 日》預測 \(0.65\) :很低,合理,他是科幻咖。
  • 小李對《星際效應》預測 \(4.34\) :他愛科幻,高分,合理。
  • 小劉對《戀夏 500 日》預測 \(4.63\) :愛情片咖配愛情片,合理。
  • 小張對《星際大戰》預測 \(4.26\) :科幻咖配科幻經典,合理。

於是推薦清單就是:每位使用者取預測分數最高的缺失格子。

print("推薦清單:")
for i, u in enumerate(users):
    scores = R_pred[i].copy()
    scores[mask[i]] = -np.inf          # 已看過的不推
    j = int(np.argmax(scores))
    print(f"  {u} → 《{movies[j]}》(預測 {R_pred[i, j]:.2f} 分)")
推薦清單:
  小王 → 《戀夏 500 日》(預測 0.65 分)
  小李 → 《星際效應》(預測 4.34 分)
  小張 → 《星際大戰》(預測 4.26 分)
  小楊 → 《戀夏 500 日》(預測 4.47 分)

(小王、小陳、小劉的缺失項預測都偏低或偏少,所以只列出確實有正面信號的推薦。)


解讀隱語義(latent factors)#

截斷 SVD 的本質是把每個人和每部電影都放到一個 \(k\) 維的「口味空間」:

\[ R_{ij} \approx \mathbf{u}_i^T \mathbf{v}_j \]

\(\mathbf{u}_i\) ( \(U\) 的列)是使用者 \(i\) 的偏好座標, \(\mathbf{v}_j\) ( \(V\) 的列)是電影 \(j\) 的屬性座標, 評分就是兩者的內積——方向一致就高分,相反就低分。

k = 2
U, s, Vt = np.linalg.svd(np.nan_to_num(R, nan=0.0), full_matrices=False)
Uk, sk, Vk = U[:, :k], s[:k], Vt[:k].T
print("使用者座標(隱向量):")
for i, u in enumerate(users):
    print(f"  {u}: {Uk[i].round(2)}")
print("電影座標(隱向量):")
for j, m in enumerate(movies):
    print(f"  《{m}》: {Vk[j].round(2)}")
使用者座標(隱向量):
  小王: [-0.52 -0.33]
  小李: [-0.56 -0.1 ]
  小張: [-0.44  0.24]
  小陳: [ 0.42  0.  ]
  小劉: [ 0.38 -0.42]
  小楊: [ 0.29  0.02]
電影座標(隱向量):
  《星際大戰》: [-0.55  0.4 ]
  《星際效應》: [-0.49 -0.28]
  《異形》: [-0.53 -0.03]
  《愛在黎明破曉》: [ 0.41  0.4 ]
  《戀夏 500 日》: [ 0.24 -0.75]

看第一個維度:科幻片都是負座標、愛情片都是正座標; 使用者座標的符號也完美對應——科幻咖在左、愛情咖在右。 演算法從來不知道什麼是「科幻」,但第一主成分自動長成了「科幻 vs 愛情」。 這就是「隱語義」:維度沒有人類標籤,卻承載了真實的語義。

import matplotlib.pyplot as plt

plt.figure(figsize=(6, 5))
for i, u in enumerate(users):
    plt.scatter(*Uk[i], c="tab:blue"); plt.annotate(u, Uk[i] + 0.02)
for j, m in enumerate(movies):
    plt.scatter(*Vk[j], c="tab:red", marker="s")
    plt.annotate(f"《{m}》", Vk[j] + 0.02)
plt.axhline(0, color="gray", lw=0.5); plt.axvline(0, color="gray", lw=0.5)
plt.xlabel("隱因子 1:科幻 ←→ 愛情"); plt.ylabel("隱因子 2")
plt.title("SVD 學到的口味空間"); plt.tight_layout()
plt.savefig("latent_space.png", dpi=120); plt.show()

實驗:改 rank 看預測變化#

rank 是這個方法最重要的超參數:

for k in [1, 2, 3, 4, 5]:
    R_pred, s = svd_impute(R, k)
    # 已知格子的擬合誤差
    err = np.sqrt(np.nanmean((np.where(mask, R_pred, np.nan) - R) ** 2))
    print(f"k={k}  擬合 RMSE = {err:.3f}")
k=1  擬合 RMSE = 2.299
k=2  擬合 RMSE = 0.482
k=3  擬合 RMSE = 0.133
k=4  擬合 RMSE = 0.054
k=5  擬合 RMSE = 0.000

觀察兩件事:

  1. \(k=5\) 時 RMSE 為 \(0\) ——因為矩陣滿秩,SVD 完美還原了「以 0 填補」的矩陣。 但這不代表預測變好了:填 0 造成的扭曲被原封不動地學了進去。
  2. \(k=2\) 左右是甜蜜點:夠捕捉「科幻/愛情」主軸,又不至於把缺失當成真實信號。

實務補充:交叉驗證 RMSE#

玩具版看起來很美好,是因為我們用「擬合誤差」評估。 實務上必須藏起一部分已知評分,測試預測能力:

rng = np.random.default_rng(0)

def cv_rmse(R, k, n_runs=20, hold_ratio=0.25):
    known = np.argwhere(~np.isnan(R))
    rmses = []
    for _ in range(n_runs):
        idx = rng.permutation(len(known))
        n_hold = int(len(known) * hold_ratio)
        test  = known[idx[:n_hold]]      # 藏起來當測試
        train = known[idx[n_hold:]]      # 留下來訓練
        Rt = np.full(R.shape, np.nan)
        for i, j in train: Rt[i, j] = R[i, j]
        R_pred, _ = svd_impute(Rt, k)
        preds = np.array([R_pred[i, j] for i, j in test])
        truth = np.array([R[i, j]    for i, j in test])
        rmses.append(np.sqrt(np.mean((preds - truth) ** 2)))
    return np.mean(rmses)

for k in [1, 2, 3]:
    print(f"k={k}  交叉驗證 RMSE = {cv_rmse(R, k):.3f}")

你會發現:交叉驗證 RMSE 比「擬合 RMSE」大得多, 而且 \(k\) 太大時(相對於如此稀疏的資料)誤差開始回升——過擬合。 這解釋了為什麼工業界的做法(如 FunkSVD、ALS)會在低秩假設之上 再加上正規化與梯度下降,本節的截斷 SVD 正是它們的思想起點。


小結與練習#

本節重點

  • 推薦系統 = 評分矩陣的矩陣補全問題;真實資料極度稀疏
  • 協同過濾的直覺:口味相似的人互相印證,不必懂物品內容
  • 截斷 SVD:以 0 填補缺失 → 保留前 \(k\) 個奇異值重建 → 缺失處即預測
  • 隱語義:使用者和物品被映射到同一個 \(k\) 維空間,評分 = 內積
  • 評估必須用交叉驗證 RMSE;rank 太大會過擬合

練習

  1. 把小張對《異形》的評分藏起來,用 \(k=2\) 預測,和真實值比差多少?
  2. 在評分矩陣中新增一位「兩棲型」使用者(科幻與愛情都打高分), 觀察隱因子 2 的座標如何變化,並解讀它捕捉了什麼。
  3. 實作「以平均值填補缺失」再截斷 SVD,與「以 0 填補」比較交叉驗證 RMSE。
  4. 挑戰題:寫一個簡單的梯度下降版矩陣分解 \(\min_{U,V}\sum_{(i,j)\in\Omega}(R_{ij}-\mathbf{u}_i^T\mathbf{v}_j)^2+\lambda(\|U\|^2+\|V\|^2)\) , 和截斷 SVD 比較交叉驗證 RMSE。