opencode×AI-Math
Lab 2·現場 30 分·用數學檢查結果

降維:設計一組真的抓得到錯的驗證

這一關你要叫 agent 手刻 PCA,然後證明它對不對。聽起來很簡單。 但你會發現,一組看起來很完整、跑起來全部 PASS 的驗證, 可能對某一類錯誤完全免疫。

先轉一轉:把平面上的點投影到一條線

假設每個點代表一筆只有兩個特徵的資料。現在只准用一個數字描述它:把點垂直投影到藍線,記下沿線的位置。你會把線轉向哪裡,讓投影後的點盡量分散?

二維資料與一維投影 圓點是原始資料,方框是垂直投影到藍線後的位置。轉動藍線,比較投影後的分散程度與點到線的距離。
實心圓:原始資料 空心方框:投影與重建的位置 虛線:重建的偏差
投影變異
—
平均重建平方距離
—

拖曳滑桿,也可用鍵盤方向鍵調整。先找出你認為最好的方向,再按「轉到主成分方向」比較。

主成分方向保留最多變異,也讓重建的平方距離總和最小。這裡的「重建」是把線上的位置放回平面:它仍在投影線上,通常不會回到原來的點。

把剛才看到的圖寫成公式

這組固定點雲的平均為零。令單位方向 $u=(\cos\theta,\sin\theta)^\top$,第 $i$ 點投影的位置是 $z_i=x_i^\top u$,重建為 $\hat x_i=z_i u$。

上方分別顯示樣本變異 $\sum_i z_i^2/(n-1)$ 與平均平方距離 $\sum_i\|x_i-\hat x_i\|^2/n$。兩者分母不同,不能直接相加;方向最佳化的結論不受這兩個固定分母影響。

把方向反過來,$u$ 換成 $-u$,投影座標會變號,但重建位置與這兩個指標都不變。稍後比對兩種 PCA 實作時,這點很有用。

從投影接到 PCA

剛才是二維變一維;接下來每張手寫數字有 64 個像素,我們要保留 10 個方向。先讀懂「減去平均、找方向、投影」這三步,再讓 agent 寫程式。還沒學過特徵向量也可以先跟著做,下方會補上意義。

PCA 在做什麼?為什麼要降維?

每張手寫數字圖是 64 維向量。但這 64 個像素並不獨立。 左上角那個像素幾乎永遠是 0,中間幾個像素則高度相關(筆畫是連著的)。 換句話說,資料實際上只「住」在一個維度低得多的子空間附近。

PCA 要做的就是找出那個子空間:在 $\mathbb{R}^{64}$ 裡找 $K$ 個互相正交的方向, 讓資料投影上去之後保留最多的變異。

這裡會用到哪些線性代數概念

向量代表一筆資料,矩陣把許多筆資料排在一起。若還沒學到特徵向量,可以先把它理解為:矩陣作用後,只會縮放、不會改變所在直線的方向;縮放倍數就是特徵值。

PCA 用到的對應的線代概念
共變異數矩陣 $C$對稱矩陣
主成分$C$ 的特徵向量
各主成分的重要性對應的特徵值
主成分彼此垂直對稱矩陣的特徵向量可取為正交
投影與重建正交投影 $\mathbf{x}\mapsto W^\top W\mathbf{x}$

這些概念不必一次記熟。先掌握 PCA 要保留資料變化最大的方向,再練習「怎麼驗證別人寫的實作」。特徵分解的細節可以配合線性代數課程回來閱讀。

共變異數矩陣是什麼?為什麼要先中心化?

先把每一行(每個像素)減掉它自己的平均,得到中心化的 $X_c$。 然後 $C=\frac{X_c^\top X_c}{n-1}$ 是一個 $64\times 64$ 的矩陣,其中

$$C_{jk}=\frac{1}{n-1}\sum_{i=1}^{n}(x_{ij}-\bar x_j)(x_{ik}-\bar x_k)$$
  • 對角線 $C_{jj}$:第 $j$ 個像素自己的變異數,也就是它「變化得多劇烈」
  • 非對角線 $C_{jk}$:第 $j$ 與第 $k$ 個像素一起變動的程度。 正的代表「一個亮另一個也傾向亮」,負的代表相反,接近 0 代表線性共同變動較弱,仍可能有非線性關係

為什麼一定要先中心化

不減平均的話,$X^\top X$ 量到的是「離原點多遠」, 而不是「離資料中心多分散」。資料平均的位置也會影響求出的方向, 這時找出的就不一定是「相對資料中心,變異最大的方向」。這一步漏掉是手刻 PCA 最常見的錯誤之一。

「解釋變異比例」是什麼意思?

把 $C$ 的特徵值由大到小排成 $\lambda_1\ge\lambda_2\ge\cdots$。 每個 $\lambda_j$ 就是資料在第 $j$ 個主成分方向上的變異數。

對稱矩陣的跡等於特徵值總和,而這個跡剛好是所有像素的變異數總和,所以

$$\text{第 }j\text{ 個主成分的解釋變異比例}=\frac{\lambda_j}{\sum_{m}\lambda_m}$$

意思是「整體變異裡,有百分之幾是由這個方向貢獻的」。 在這份資料中,第一個主成分約占 14.89%,前十個加起來約 73%。 換句話說,用 10 個數字就能保留原本 64 個數字約七成三的變異。

注意這是個比例。待會會看到,正因為它是比例, 分母上的常數會被約掉。那個驗證盲點就是這麼來的。

會看到的 numpy 寫法
寫法意思
A @ B矩陣乘法(不是逐元素相乘)。A * B 才是逐元素
A.T轉置 $A^\top$
X - muX 是 (1797,64)、mu 是 (64,),numpy 會自動把 mu 對每一列廣播相減(broadcasting)
np.linalg.eigh(C)對稱矩陣專用的特徵分解。回傳的特徵值是由小到大,所以要自己反轉
np.argsort(v)[::-1]取得「由大到小」的索引順序
W[:K]取前 $K$ 列

用 eigh 而不是 eig 是有理由的:共變異數矩陣是對稱的, eigh 利用這個性質,數值更穩、而且保證回傳實數特徵值。 用 eig 可能會拿到帶極小虛部的複數,然後你會花半小時懷疑人生。

PCA 的定義:對中心化後的資料 $X_c$,計算共變異數矩陣

定義
$$C=\frac{X_c^\top X_c}{n-1},\qquad C=V\Lambda V^\top$$

取 $\Lambda$ 中最大的 $K$ 個特徵值對應的特徵向量,就是前 $K$ 個主成分。

一個「正確的 PCA 實作」至少要做對這幾件事:中心化對、分母對、特徵分解對、由大到小排序對、取前 $K$ 個對。 你的任務是設計檢查,盡可能抓出這些步驟中的錯誤,並留意每項檢查仍可能漏掉什麼。

動手

要求 agent 手刻

手刻一個 PCA,取前 10 個主成分,資料用 sklearn 內建的 digits。

要求:
- 只能用 numpy:中心化、算共變異數矩陣、np.linalg.eigh、排序、取前 10 個
- 不可以呼叫 sklearn.decomposition.PCA 來算(它只能當對照組)
- 寫成 lab2_pca.py,用 uv run python 執行

寫完之後,請你自己設計驗證,證明你的實作跟 sklearn 的 PCA 一致,
並且把每一項驗證的實際數值印出來。

注意最後一句:驗證方法是叫 agent 自己想的。 這是刻意的:先看看它會想出什麼。

第一個坑:它可能會直接相減

如果 agent 直接逐項比對 components_,可能得到下面的結果。你的模型回覆與末位數字不必完全相同:

opencode build · big-pickle
uv run python lab2_pca.py
手刻 components 與 sklearn 最大差 = 0.9411
驗證失敗:主成分不一致
看起來我的實作有問題,讓我檢查一下特徵分解的部分……
這裡不要讓它亂改

如果 agent 因此直接修改 PCA 程式,先停下來檢查比較方式。打斷它(按 Esc 或 Ctrl+C), 然後問它一個問題:
「$v$ 是 $C$ 的特徵向量,那 $-v$ 是不是?如果是,你的比較方法有什麼問題?」

關鍵在於 $Cv=\lambda v \Rightarrow C(-v)=\lambda(-v)$。 特徵向量的正負號是任意的,兩個實作各自挑了不同的號,逐項相減當然差很大。

換成正確的比法

兩邊的主成分都已正規化,所以比它們的夾角餘弦絕對值:

方向比對
$$\bigl|\cos\theta_i\bigr|=\bigl|\,\mathbf{w}^{\text{manual}}_i\cdot\mathbf{w}^{\text{sklearn}}_i\,\bigr|\;\overset{?}{\approx}\;1$$
剛才那個比較方法是錯的,因為特徵向量的正負號任意。

請改成下面四項驗證,每一項都印出實際數值與 PASS/FAIL:
1. 解釋變異比例:evals[:10]/evals.sum() vs explained_variance_ratio_
2. 主成分方向:|cos| 應該都接近 1,並列出哪幾個方向相反
3. 重建誤差:(Xc @ W.T) @ W + mu 的 MSE vs sklearn inverse_transform 的 MSE
4. 正交性:W @ W.T 應該接近單位矩陣
opencode build · big-pickle
uv run python lab2_pca.py
關卡 1:解釋變異比例 最大差異 = 8.327e-17 -> PASS
關卡 2:主成分方向 最大偏離 1 = 8.882e-16 -> PASS
    方向相反的主成分:PC[4, 7, 8, 9, 10](正常現象)
關卡 3:重建誤差 差異 = 0.000e+00 -> PASS
關卡 4:正交性 最大差 = 8.882e-16 -> PASS
四項驗證全部通過,手刻的 PCA 與 sklearn 一致。

四關全過。看起來可以收工了。

但是這組驗證有一個洞

現在做一件事:故意把程式寫錯,把共變異數的分母從 $n-1$ 改成 $n$,然後重跑那四關。

請做一個實驗:把共變異數矩陣的分母從 (n-1) 改成 n,
其他都不動,重新跑那四項驗證,把結果印出來。
分母寫成 n 的版本
關卡 1(比例)  PASS
關卡 2(方向)  PASS
關卡 3(重建)  PASS
關卡 4(正交)  PASS
程式是錯的,四關卻全部通過

這不是巧合,是數學上的必然。

設 $C'=cC$,其中 $c=\frac{n-1}{n}$ 是一個正常數。那麼:

為什麼四關都測不到
$$Cv=\lambda v\;\Longrightarrow\;C'v=(c\lambda)v$$
  • 特徵向量完全不變 → 關卡 2(方向)、關卡 4(正交)必然通過
  • 特徵值同乘 $c$,但比例 $\dfrac{c\lambda_i}{\sum_j c\lambda_j}=\dfrac{\lambda_i}{\sum_j\lambda_j}$ → 關卡 1 中 $c$ 被約掉
  • 重建 $X_cW^\top W+\mu$ 只用到特徵向量,跟特徵值大小無關 → 關卡 3 通過

四道關卡沒有任何一道會去看特徵值本身的數值, 而分母錯誤剛好只影響特徵值的尺度。所以這組驗證對這個錯誤完全免疫。

補上第五關

再加一個關卡 5:直接比對特徵值本身,
evals[:10] 應該等於 sklearn 的 pca.explained_variance_。
兩個版本(n-1 和 n)都跑一次。
加上關卡 5 之後
正確 n-1 關卡1=PASS 關卡2=PASS 關卡3=PASS 關卡4=PASS ‖ 關卡5=PASS
錯誤 n  關卡1=PASS 關卡2=PASS 關卡3=PASS 關卡4=PASS ‖ 關卡5=FAIL 差=0.0996
這一關真正要你帶走的

「驗證全部通過」只代表「這組驗證抓不到錯」,不代表程式正確。

設計驗證的時候,該問的不是「我檢查了幾項」, 而是「什麼樣的錯誤會從我的檢查縫隙中溜過去」。 以後檢查作業或自己的數學程式,也可以用同樣的方式思考。

兩個必須講清楚的但書

但書一:$|\cos|$ 不是永遠有效

$|\cos|$ 比對成立的前提是特徵值單純(沒有重根)且間隔夠大。 如果 $\lambda_i=\lambda_{i+1}$,那個二維特徵空間裡任何一組正交基底都是合法的特徵向量。 兩個都正確的實作,逐向量 $|\cos|$ 甚至可以是 0。

請印出前 11 個特徵值,並計算相鄰特徵值的最小間隔,
告訴我這份資料有沒有接近重根的問題。
特徵值間隔檢查
前 10 個特徵值:
179.0069 163.7177 141.7884 101.1004 69.5132
59.1085 51.8845 44.0151 40.3110 37.0118
λ₁₁ = 28.5190
相鄰最小間隔 = 3.2992  第 10/11 之間 = 8.4928
間隔遠大於 float64 的數值誤差,沒有接近重根的問題,|cos| 在這份資料上適用。

digits 剛好安全。但如果你之後換一份資料,務必先檢查這件事。 若要處理重複特徵值,應把對應的整個區塊一起比較,例如比較投影矩陣 $W^\top W$。還要確認保留的第 $K$ 與第 $K+1$ 個特徵值有間隔;若剛好從重根區塊中間切開,兩個都最佳的 $K$ 維子空間也可能不同,不能要求投影矩陣相等。

但書二:關卡 4 只是必要條件

$WW^\top\approx I$ 確實不需要對照組,但它只證明「這 $K$ 個向量彼此正交且長度為 1」。 隨便一組正交基底都會通過,包括「不小心取到最小的那 10 個特徵向量」這種錯誤。 要完整驗證還得檢查特徵方程殘差 $CW^\top-W^\top\Lambda$、特徵值大小與取用順序。

檢查清單

如果還有時間

  • 問 agent:pca._fit_svd_solver 是什麼?你會發現預設 auto 在這份資料上選的是 covariance_eigh,跟手刻是同一種演算法。所以這根本不算「兩種演算法交叉驗證」。要真的交叉驗證必須明寫 svd_solver='full'。
  • 設計一個「取錯順序」的錯誤(取最小的 10 個特徵值),看看五道關卡各自會不會抓到。
  • 把前 10 個主成分 reshape 回 8×8 畫出來,看看 PCA 學到了什麼樣的筆畫結構。