opencode×AI-Math
Lab 1·現場 30 分·agent 當加速器

分群:請 agent 做實驗,再判讀結果

這個實作要讓你體會 agent 最直觀的價值:把重複寫程式、跑參數與畫圖的工作交給 agent,自己專心比較結果。 不過這一關的重點在最後。agent 會很有自信地報告一個數字,而你要有能力知道它錯了。

這一關的資料

分群是先不看答案,依資料的相似程度把樣本分組。本關的每張圖都是一個樣本,像素值是描述它的特徵;數字標籤留到後面檢查分群結果時才使用。

三個實作共用同一份資料:scikit-learn 內建的 digits, 1797 張 8×8 的手寫數字灰階圖,攤平成 64 維向量,真實類別有 10 類(0 到 9)。

資料到底長什麼樣?「64 維」是什麼意思?

每張圖是 8×8 = 64 個像素,每個像素是 0 到 16 的灰階值。 把這 64 個數字依序排成一列,一張圖就變成一個 64 維的向量 $\mathbf{x}\in\mathbb{R}^{64}$。1797 張圖疊起來,就是一個 $1797\times 64$ 的矩陣 $X$。

「64 維」不是什麼玄學,就是線代課上那個 $\mathbb{R}^n$, 只是 $n=64$ 而已。你在線代學的內積、範數、正交、投影, 在這裡全部原封不動可以用,Lab 2 就會派上用場。

兩個向量「像不像」怎麼量

用你已經會的歐氏距離:

$$\|\mathbf{x}-\mathbf{y}\|_2=\sqrt{\sum_{j=1}^{64}(x_j-y_j)^2}$$

兩張圖如果每個像素都差不多,距離就小。整個 k-means 就建立在這一個量上。

會看到的 numpy 寫法

寫法意思
X.shape回傳 (1797, 64),也就是「1797 列、64 行」
X[5]第 6 張圖(從 0 數起)的那個 64 維向量
X.mean(axis=0)沿著「列」的方向壓扁,得到 64 個平均值=每個像素的平均亮度
X[y == 3]只取出標籤是 3 的那些列(布林遮罩)

axis=0 常讓人搞混。記法:axis 指的是「要被吃掉的那個維度」。 X 是 (1797, 64),axis=0 吃掉 1797 那一維,剩下 64 個數。

什麼是「分群」?跟「分類」差在哪?

這是機器學習裡最基本的一組區分,值得花三十秒搞清楚:

分類(classification)分群(clustering)
你有什麼資料 + 正確答案只有資料
在做什麼學一個規則,看到新資料能說出它屬於哪一類看資料自己長成幾坨,把靠近的歸在一起
術語監督式(supervised)非監督式(unsupervised)
本工作坊Lab 3Lab 1(現在這關)

關鍵在於:分群演算法看不到答案。 digits 雖然附了正確標籤,但我們不會把標籤餵給 k-means。 它只看得到那 1797 個 64 維向量,得自己決定誰跟誰是一夥的。

這就是為什麼待會會出現「指標偏好 9 群、但資料有 10 種數字標籤」這種事。 它本來就不知道答案是 10。

k-means 到底在做什麼?(三行講完)

給定你想分幾群(那個 $k$),演算法只做兩件事,反覆做到不變為止:

  1. 隨機挑 $k$ 個點當「中心」$\boldsymbol{\mu}_1,\dots,\boldsymbol{\mu}_k$
  2. 指派:每個資料點歸到離它最近的那個中心
  3. 更新:每個中心移動到自己那群所有點的平均位置

然後回到第 2 步,直到沒有點再換群為止。

它在最小化什麼

整個過程其實是在最小化這個量,sklearn 把它叫做 inertia_:

$$J=\sum_{i=1}^{n}\bigl\|\mathbf{x}_i-\boldsymbol{\mu}_{c(i)}\bigr\|^2$$

($c(i)$ 是第 $i$ 個點被分到哪一群。)白話:每個點到自己那群中心的距離平方,全部加起來。

第 3 步「取平均」不是隨便定的。它是微積分算出來的: 固定分群之後,對 $\boldsymbol{\mu}$ 微分並令其為零, $\frac{\partial J}{\partial\boldsymbol{\mu}_j}=-2\sum_{i\in C_j}(\mathbf{x}_i-\boldsymbol{\mu}_j)=0$, 解出來就是 $\boldsymbol{\mu}_j=\frac{1}{|C_j|}\sum_{i\in C_j}\mathbf{x}_i$,也就是群內平均。 這就是你在微積分課上做的「令導數為零求極值」。

順帶一提:每一步不會讓 $J$ 變大;實作會在分群不再改變、變化小於容差,或達到最大迭代次數時停止。 但停下來的地方不保證是全域最小。所以才要設 n_init=10(跑 10 次不同起點取最好的),以及為什麼要固定 random_state 才能重現結果。

為什麼用這份

不用下載(套件裡就有)、夠小(幾秒跑完)、維度夠高(64 維,PCA 才有意義)、 而且它附有 10 種數字的標籤。這點待會會變成整堂課最重要的伏筆。

動手

啟動 agent

在你課前準備好的專案資料夾裡打開終端機:

opencode

看到 TUI 介面,左下角顯示 build · big-pickle 就對了。

下第一個指令

把下面這段整個貼進去。注意它的結構:做什麼、用什麼、存到哪、印出什麼。 再補上「如何判斷完成」,就成為可反覆使用的任務格式。完整練習見需求與續作。

用 sklearn 內建的 digits 資料集做 k-means 分群。

請掃過 k = 2 到 12,每個 k 都記錄 inertia 與 silhouette score,
把逐項數值印出來,並畫成兩張並排的圖存到 figs/lab1_k_selection.png。

要求:
- random_state 固定為 0,n_init=10
- 寫成 lab1_cluster.py,用 uv run python 執行
- 最後告訴我 silhouette 最高的 k 是多少

看著它工作

觀察 agent 是否讀取規則、寫出檔案並實際執行。下面是一份範例輸出:

opencode build · big-pickle
Read AGENTS.md
Write lab1_cluster.py
uv run python lab1_cluster.py
資料形狀 X=(1797, 64), 真實類別數=10
k= 2 inertia= 1914619.6 silhouette=0.1183
k= 3 inertia= 1730182.3 silhouette=0.1265
k= 4 inertia= 1612274.9 silhouette=0.1229
k= 5 inertia= 1497722.5 silhouette=0.1382
k= 6 inertia= 1404975.3 silhouette=0.1515
k= 7 inertia= 1336540.0 silhouette=0.1623
k= 8 inertia= 1265053.1 silhouette=0.1784
k= 9 inertia= 1202300.1 silhouette=0.1893
k=10 inertia= 1165188.9 silhouette=0.1825
k=11 inertia= 1131795.0 silhouette=0.1830
k=12 inertia= 1112363.4 silhouette=0.1848
完成。silhouette 分數最高的是 k = 9(0.1893),建議用 9 群。 圖已存到 figs/lab1_k_selection.png。
停在這裡,先不要往下

agent 說最佳 k 是 9。但你知道這份資料有 10 個數字。 在往下看之前,先自己想三十秒:誰錯了?

誰錯了

這組計算中,0.1893 確實是最高的 silhouette。若目標就是最大化這個指標,選 9 群有依據;但這還不能回答「如何辨識十種數字」。閱讀建議前,先確認它回答的是哪個問題。

更精確地說:10 是這份資料標註的類別數,它不保證原始像素空間裡 恰好存在 10 個符合 k-means 偏好的緊密、近似球形群組結構的群。 所以這裡沒有「誰算錯了」,只有「兩個不同的問題被當成同一個」。

silhouette 在量什麼

對每個點 $i$,設 $a(i)$ 是它到同群其他點的平均距離, $b(i)$ 是它到最近的其他群所有點的平均距離,則

silhouette
$$s(i)=\frac{b(i)-a(i)}{\max\{a(i),\,b(i)\}},\qquad s(i)\in[-1,1]$$

整體分數就是所有 $s(i)$ 的平均。它衡量的是「群內夠不夠緊、群間夠不夠開」, 一個純粹的幾何性質,完全沒有碰到真實標籤。

所以 silhouette 回答的問題是「哪個 $k$ 讓分群幾何上最漂亮」, 而不是「哪個 $k$ 是正確的類別數」。這是兩個不同的問題。

為什麼 digits 會是 9

在這份資料、本次初始化與搜尋範圍中,$k=9$ 的 silhouette 最高。這是已觀察到的結果;單靠這個分數,還不能知道哪些數字混在一起,或為什麼這組分群較好。合併兩群也不保證讓群內距離下降或 silhouette 上升。

換句話說:「幾何上最漂亮的分法」跟「語意上正確的分法」不一樣。 如果想解釋原因,可以請 agent 畫群中心,或列出每群包含各種數字的筆數,再對照標籤判讀。

這一關真正要你帶走的

agent 給你的每個數字都是對的,但它擅自替你做了一個你才有資格做的判斷。 它沒有先確認你要優化的是幾何分群品質,還是辨識數字類別。 往後每次 agent 說「所以建議……」,停在那個「所以」上面檢查一下。

追問

把這句話丟回給 agent,看它怎麼回應:

這份資料我知道真實類別有 10 類,但你算出 silhouette 最高是 k=9。

請解釋這個落差。並且:
- 用 sklearn 的 adjusted_rand_score 與真實標籤比對 k=9 和 k=10 的結果
- 告訴我在「不知道真實答案」的情境下,還有什麼方法可以判斷 k

這裡引入了 ARI(adjusted Rand index),這是一個需要真實標籤的外部指標。 對照 silhouette 這個內部指標,你會很清楚看到兩者的差別。

ARI 是什麼?為什麼要「調整」?

分群的編號是任意的:把「第 0 群」跟「第 3 群」的編號對調,分群結果完全沒變。 所以不能直接比編號。要比的是「哪些點被放在一起」這個關係。

先看 Rand index

把所有資料點兩兩配對,總共 $\binom{n}{2}$ 對。對每一對問一個是非題: 這兩個點在真實標籤裡是同一類嗎?在分群結果裡是同一群嗎? 兩邊答案一致就算「同意」:

$$\text{RI}=\frac{\text{兩邊都說同群}+\text{兩邊都說不同群}}{\binom{n}{2}}$$

這完全繞開了編號問題,只看配對關係。

為什麼要「調整」

RI 有個毛病:就算亂分,也會拿到不低的分數。 群數一多,隨便兩個點「剛好都不同群」的機率就很高,分母白白被灌水。 所以會把隨機情況下的期望值扣掉、再正規化:

$$\text{ARI}=\frac{\text{RI}-\mathbb{E}[\text{RI}]}{\max(\text{RI})-\mathbb{E}[\text{RI}]}$$

調整之後:完全正確是 1,隨機亂分約等於 0(可以是小負數)。 接近 0 表示與這個隨機分群基準相近,不能據此斷言資料中完全沒有資訊。 你可以叫 agent 用隨機標籤實際跑一次驗證這點,五行程式就能做完。

順便驗證一件事

ARI 是「調整過的」Rand index,意思是它扣掉了隨機分群的期望值, 所以隨機亂分的 ARI 約等於 0,而不是某個正數。可以叫 agent 用隨機標籤實際跑一次驗證這點。

檢查清單

如果還有時間

  • 叫 agent 把 k=10 的十個群中心 reshape 回 8×8 畫出來。觀察哪些群中心比較模糊,再列出各群的數字標籤組成;不能只憑模糊就判定合併了哪些類別。
  • silhouette 的差距(0.1893 vs 0.1825)其實很小。用不同 random_state 重跑,比較兩個 k 的分數與差值,觀察排序對初始化是否穩定。這是初始化敏感性分析,不是對樣本母體做顯著性檢定。
  • 問它:「inertia 為什麼隨 k 遞減?用數學證明給我看。」(提示:$k+1$ 群的最佳解一定不差於 $k$ 群,因為可以把多的那群留空。但 k-means 找到的是局部解,所以實務上偶爾會出現不單調的情形。這也是 elbow 法只能「看轉折」、不能「取最小值」的原因。)
  • 換一個距離度量(例如先做 L2 正規化再分群),看 silhouette 的最佳 k 會不會變。