中級 S2・114-02・程式題逐行解剖
Question 41 · 演算法辨識 / K-means 分群

選中心、分群、搬到平均、重複到不動:
這段虛擬碼是哪一種演算法?

這題又是一段虛擬碼:給它一堆資料點和「要分成幾群」X,它先隨機挑 X 個點當中心,再反覆做兩件事:把每個點分給最近的中心、把每個中心搬到它那一群的平均位置,直到中心不再移動。答案是 A:K-means,名字裡的 means 就是「平均」。這頁用「12 間店要蓋 3 個物流中心」把每一步演出來,把虛擬碼逐行翻成 Python,和 scikit-learn 的 KMeans 對答案;最後把四個選項放在同一張地圖上,看它們分群的想法差在哪。

Python 3.13.7scikit-learn 1.8.0 pseudocodeK-meansrandom.samplemath.dist while TrueKMeansGMM階層式DBSCAN零基礎可讀

讀完這頁,你會知道

  1. 什麼是分群:資料沒有標準答案,演算法依「誰和誰比較近」自己分組。這是非監督式學習。
  2. 虛擬碼每一步在做什麼:隨機選 X 個中心 → 每個點分給最近的中心 → 中心改成那一群的平均 → 中心不動就停。這就是 K-means,X 就是 K。本頁的 12 間店跑 4 輪就停了。
  3. 怎麼翻成 Python:random.sample、math.dist、distances.index(min(distances))、zip、while True 加 break;和 scikit-learn 用同樣的起點,也是 4 輪、同樣的答案。
  4. 虛擬碼沒說的事:起點會影響結果(100 個亂數種子有 9 個卡在壞答案),某一群可能一個點都沒有,離很遠的點會把中心拉走;scikit-learn 用 k-means++ 挑起點來避免卡住。
  5. 四個選項的差別:GMM 給每個點「屬於各群的機率」;階層式從最近的兩個開始一路合併;DBSCAN 看密度、不用先說要幾群,還會把落單的點標成雜訊。
§0

題目

第 41 題請參考附圖,下列虛擬程式碼(pseudocode)最可能是在描述何種演算法?
第 41 題原卷附圖:Input 有 data_points(N 筆資料,每筆資料有 D 個特徵)、X(要分成的群數);Output 有 clusters(每筆資料所屬的群編號)、centroids(每個群的中心點);Algorithm:1. 隨機選擇 X 個資料點作為初始中心;2. 重複以下步驟直到收斂:a. 分群:對每個資料點,計算它到每個中心的距離,將資料點指派給距離最近的中心;b. 更新中心:對每個群,計算該群中所有資料點的平均值,將群中心更新為這個平均值;3. 當群中心不再變動時,停止;回傳每筆資料的群編號 clusters,以及最後的群中心 centroids
原卷附圖(文字已在下面完整重打一份)

先憑直覺選一個,讀完再回來對照:

Input: - data_points:N 筆資料,每筆資料有 D 個特徵 - X:要分成的群數 Output: - clusters:每筆資料所屬的群編號 - centroids:每個群的中心點 Algorithm: 1. 隨機選擇 X 個資料點作為初始中心 2. 重複以下步驟直到收斂: a. 分群: 對每個資料點,計算它到每個中心的距離 將資料點指派給距離最近的中心 b. 更新中心: 對每個群: 計算該群中所有資料點的平均值 將群中心更新為這個平均值 3. 當群中心不再變動時,停止 回傳每筆資料的群編號 clusters,以及最後的群中心 centroids
先抓虛擬碼的五個線索

① Input 只有資料和群數,沒有標籤:這是非監督式的分群。② 群數 X 要先給:演算法不會自己決定分幾群。③ 隨機選 X 個點當初始中心:每一群用一個「中心點」代表。④ 指派給最近的中心:每個點只屬於一群,非此即彼。⑤ 中心更新為平均值、重複到不再變動:K 個中心、每個都是平均(mean),就是 K-means 名字的由來。

關於本頁的資料與版本

考卷只有虛擬碼,沒有資料。本頁自己設計了一份教學資料:一張 10 × 10 公里的地圖上有 12 間店,要蓋 3 個物流中心(X=3)。每間店由最近的物流中心送貨;物流中心想蓋在它負責的店的正中央,送貨距離最短。西南、東邊、北邊各有 4 間店。

本頁所有數字、輸出都是在 Python 3.13.7、scikit-learn 1.8.0(NumPy 2.4.3、SciPy 1.16.2)上實際執行的結果。

§1

先補基本功:讀懂這段虛擬碼需要的 5 個觀念

這題一樣不考語法,考的是看懂流程。下面先講什麼是分群、中心和平均,中間兩支動畫:一支在地圖上把 4 輪演一次,一支把虛擬碼翻成 Python、打開記憶體一行一行跑。

① 分群觀念 1
→
② 距離・中心・平均觀念 2
→
③ 兩步驟反覆觀念 3、動畫 1
→
④ 找最近・收集同群觀念 4
→
⑤ 收斂與起點觀念 5、動畫 2

觀念 1|分群(clustering):沒有標準答案,依「誰和誰近」分組

第 40 題的資料有正確答案(考了幾分),模型學的是「怎麼預測答案」。分群不一樣:資料沒有標籤,演算法只看資料彼此之間的距離,把靠得近的放成一群。這叫非監督式學習。

本頁的例子:12 間店只有位置 (x, y),沒有人告訴你哪間屬於哪一區。要蓋 3 個物流中心,就要先把 12 間店分成 3 群,再決定每個物流中心蓋在哪裡。

區域4 間店的位置(公里)
西南(1.5, 2) (2, 1) (2.5, 3) (1, 3.5)
東邊(7, 2.5) (8, 1.5) (8.5, 3) (7.5, 4)
北邊(4, 8) (5, 9) (5.5, 7.5) (3.5, 7)

表格是給讀者看的;演算法拿到的只是 12 組數字,不知道「西南、東邊、北邊」。

觀念 2|距離、中心、平均

距離用直線距離:math.dist((1.5, 2), (1, 3.5)) 實測是 1.5811388300841898(√(0.5² + 1.5²))。中心(centroid)是代表一群的那一個點。一群點的平均位置,就是 x 取平均、y 取平均:西南 4 間店的平均是 ((1.5+2+2.5+1) ÷ 4, (2+1+3+3.5) ÷ 4) = (1.75, 2.375)。

為什麼物流中心要蓋在平均位置?因為在所有可能的位置裡,平均位置到這群店的「距離平方」總和最小。K-means 每一輪做的事,就是讓這個總和(scikit-learn 叫 inertia_)一路變小。本頁最後的答案:總和 14.125。

觀念 3|兩個步驟反覆做:分給最近的、搬到正中央

K-means 只有兩個動作輪流做。a. 分群:物流中心位置先不動,每間店找最近的中心報到。b. 更新中心:店的分組先不動,每個中心搬到它那群店的平均位置。搬家之後,有些店會發現「隔壁那個中心比較近」,下一輪就換組;換組之後中心又要再搬……直到中心不再移動。

輪這一輪開始時的 3 個中心(實測,random.seed(3))分群結果
1(1, 3.5) (5, 9) (4, 8):隨機挑的 3 間店,有 2 間在北邊西南 4 間+東邊 2 間擠到第 0 群
2(3.67, 2.25) (5.25, 8.25) (5.88, 5.5)東邊的 (7, 2.5) 換到第 2 群
3(3.0, 2.2) (4.5, 7.88) (7.67, 3.17)東邊 4 間都到第 2 群了
4(1.75, 2.38) (4.5, 7.88) (7.75, 2.75)沒人換組,算出來的新中心和舊的一樣 → 停止
看動畫 1 之前:這支要回答什麼

觀念 1~3 說到:分群沒有標籤、中心是一群的平均、K-means 只有兩個步驟輪流做。動畫 1 直接在地圖上演:隨機挑 3 間店當物流中心的起點,每間店找最近的中心報到,中心搬到那群店的平均位置,一輪一輪,直到中心不再移動;最後再看一個不好的起點。

帶著三個問題看:① 第 1 輪為什麼東邊的店被分到西南的中心?② 跑了幾輪才停?③ random.seed(7) 的起點為什麼停在壞答案?

動畫 1物流中心一輪一輪搬家:K-means 在地圖上演一次2 分 18 秒・有旁白,也有字幕

12 間店是本頁自己設計的教學資料(考卷沒有資料)。每一輪的中心、分群和總和,都是 Python 3.13.7 照 §3 的程式實際執行的結果(random.seed(3) 和 random.seed(7)),和 scikit-learn 1.8.0 的 KMeans 一致。

看完動畫 1,你應該能回答:第 1 輪為什麼東邊的店被分到西南的中心?
隨機挑的 3 個起點 (1, 3.5)、(5, 9)、(4, 8) 有 2 個在北邊,東邊附近沒有中心;東邊的 (7, 2.5)、(8, 1.5) 量了 3 個距離,最近的剛好是西南的 (1, 3.5)。
跑了幾輪才停?最後的總和是多少?
4 輪。第 4 輪沒有店換組,新中心和舊的一模一樣就停;距離平方總和 14.125。
random.seed(7) 的起點為什麼停在壞答案?
起點 (8, 1.5)、(2.5, 3)、(8.5, 3) 有兩個在東邊,東邊被切成兩群,西南和北邊被湊成一群;第 2 輪中心就不動了,總和 87.25。中心不動只代表停下來,不代表最好。

觀念 4|Python:找最近的那一個、收集同一群的點

虛擬碼Python第 1 間店 (1.5, 2) 在第 1 輪(實測)
計算它到每個中心的距離[math.dist(p, c) for c in centroids][1.58…, 7.83…, 6.5]
指派給距離最近的中心distances.index(min(distances))min 是 1.58…,它在第 0 號 → 第 0 群
該群中所有的資料點[p for p, c in zip(data_points, clusters) if c == k]zip 把「店」和「它的群號」一對一配起來,只留群號是 k 的
計算平均值sum(x for x, y in members) / len(members)第 0 群有 6 間店,x 的平均 3.6666666666666665

群編號從 0 開始:X=3 時,群號是 0、1、2,正好是 centroids 清單裡中心的位置。

觀念 5|「直到收斂」:while True 加 break;起點會影響終點

虛擬碼說「重複直到收斂」「中心不再變動時停止」,但事先不知道要幾輪。Python 寫成 while True:(一直重複),在迴圈裡檢查 if new_centroids == centroids: break,一樣就跳出。

不過,停下來不代表找到最好的分法。起點是隨機挑的:random.seed(7) 挑到 (8, 1.5)、(2.5, 3)、(8.5, 3),兩個中心都在東邊,第 2 輪就停了,答案卻是「東邊切兩半、西南和北邊硬湊一群」,距離平方總和 87.25(好的分法是 14.125)。實測換 100 個種子,91 個找到好的分法、9 個卡在壞的分法。這叫局部最佳。

把觀念合起來:kmeans 在記憶體裡怎麼跑

看動畫 2 之前:這支要回答什麼

觀念 4~5 講了找最近的那一個、用 zip 收集同一群、while True 加 break。動畫 2 左邊是 §3 的 kmeans,執行到哪一行就亮哪一行;右邊打開記憶體:stores 這個清單、random.sample 做出來的 centroids、每一輪新做的 clusters 和 new_centroids。

帶著三個問題看:① random.sample 有沒有複製那 3 間店?② distances.index(min(distances)) 交回什麼?③ 什麼時候 break?

動畫 2打開記憶體:一輪一輪跑 kmeans2 分 22 秒・有旁白,也有字幕
🔒這支動畫在完整版試看版每頁開放第 1 支動畫

畫面上的位址(0x…)、距離、群號和中心,都取自同一次實際執行(Python 3.13.7,random.seed(3))。位址每次執行都不同。

看完動畫 2,你應該能回答:random.sample 有沒有複製那 3 間店?
沒有。它做出一個新的清單 centroids,但裡面的 3 個 tuple 就是 stores 裡那 3 間店的同一個物件(實測 is 都是 True)。
distances.index(min(distances)) 交回什麼?
最小的距離在清單的第幾號,也就是最近的中心是第幾個,當作群號。第 1 間店的 distances 是 [1.58, 7.83, 6.5],交回 0。
什麼時候 break?
算出來的 new_centroids 和這一輪開始時的 centroids 逐一比較,全部一樣(True)時。本頁第 4 輪才是 True。
§2

四個選項是四種完全不同的分群想法

四個選項都是分群演算法,最後也都會給每個點一個群號;差別在怎麼決定誰和誰一群。下表的最後一列,是 scikit-learn 1.8.0 在這 12 間店上實際分出來的結果。

A|K-meansB|GMMC|階層式D|DBSCAN
群數要先給嗎要(X)要(幾個常態分佈)可以最後再決定(切樹狀圖)不用,改給距離 eps 和最少點數
每一群用什麼代表一個中心點(平均)一個鐘形分佈:平均+形狀(共變異數)沒有中心;看合併的過程沒有中心;看哪裡夠密
一個點屬於幾群只屬於最近的一群屬於每一群都有一個機率一群一群,或是雜訊(−1)
怎麼算出來分給最近中心 ↔ 中心搬到平均,反覆到不動算機率 ↔ 更新平均與形狀,反覆(EM 演算法)一開始每點自成一群,每次合併最接近的兩群從密集的點往外擴張,連得到的就是一群
12 間店的結果西南、東邊、北邊各 4 間一樣一樣一樣
多一間偏遠的店 (9.5, 9.5)硬塞進北邊那群,中心被拉到 (5.5, 8.2)也塞進北邊那群也併進北邊那群標成雜訊 −1

資料分得很開的時候,四種方法的結果可能一模一樣,所以不能只看結果認演算法,要看做法。虛擬碼寫的「指派給最近的中心」「中心更新為平均值」「中心不再變動時停止」,只有 K-means 是這樣做的。GMM 也是兩個步驟反覆,但每個點分給每一群的是機率(軟分群),而且每群除了平均還要學形狀;新店 (5, 4) 在 GMM 裡屬於東邊那群的機率是 0.9986,不是「直接指派」。

scikit-learn 的 K-means:KMeans(實測 1.8.0)

KMeans(n_clusters=8, *, init='k-means++', n_init='auto', max_iter=300, tol=0.0001,
       verbose=0, random_state=None, copy_x=True, algorithm='lloyd')
參數/屬性預設值或意思對應虛擬碼
n_clusters8(一定要自己改)X(要分成的群數)
init'k-means++':第一個中心隨機挑,之後離已選中心越遠的點越容易被挑中;設 'random' 就是虛擬碼的「隨機選 X 個點」步驟 1
n_init'auto':init 是 k-means++ 時跑 1 次,是 'random' 時跑 10 次,留下總和最小的虛擬碼只跑 1 次
max_iter、tol最多 300 輪;中心移動量小於門檻就算收斂步驟 3「不再變動」
algorithm='lloyd'就是「分群 ↔ 更新中心」輪流做的經典版本步驟 2a、2b
.labels_、.cluster_centers_每個點的群號、每群的中心Output 的 clusters、centroids
.inertia_、.n_iter_距離平方總和、跑了幾輪(虛擬碼沒有輸出)

實測:給它和本頁 Python 一樣的起點 (1, 3.5)、(5, 9)、(4, 8),n_init=1,結果 labels_、cluster_centers_ 完全相同,n_iter_ 是 4、inertia_ 是 14.125。用預設的 k-means++,只跑 2 輪就到同樣的 14.125。

虛擬碼沒有規定的事

起點

隨機挑的起點可能卡在壞答案(100 個種子有 9 個,總和 84.7~87.75)。實務上用 k-means++,或跑好幾次留最好的(n_init)。

空群

某一群在分群後一個點都沒有,平均就要「除以 0」。§4 的 160 組起點裡有 5 組會這樣,§3 的程式會出錯;scikit-learn 會把空的中心改放到別的點上。

X 要自己選

K-means 不會告訴你該分幾群。X 越大總和一定越小,常見做法是畫出「X 對總和」的曲線,找轉折點(手肘法)。

§3

逐行解剖:把虛擬碼翻成 Python

考卷只有虛擬碼,左邊是本頁照著它逐步翻譯的 Python(第 5~16 行右邊的註解標著對應的步驟)。只用 Python 內建的 math 和 random,不用安裝任何套件;完整可以執行的版本在 §6 程式 1。點任一行可以跳到解說;右邊往下捲時,左邊會自動標出目前講到哪一行。

q41.py26 行

兩個都是 Python 內建的標準函式庫,不用另外安裝。

import math
模組要用裡面的 math.dist。
import random
模組要用裡面的 random.sample(隨機挑)和 random.seed(固定亂數)。

定義一台叫 kmeans 的機器,對應虛擬碼的 Input。Output 有兩樣(clusters、centroids),由最後的 return 一起交回。

data_points
參數12 間店的位置,每間是一組 (x, y):N=12、D=2。
X
參數要分成幾群,就是 K-means 的 K。

從 12 間店裡隨機挑 3 間(不重複),它們的位置就是 3 個中心的起點。

random.sample(data_points, X)
函式從清單裡隨機挑 X 個不同的元素,交回一個新清單(抽出不放回)。
random.seed(3) 之後(實測): centroids → [(1, 3.5), (5, 9), (4, 8)] ← 第 4、10、9 間店
  • 每次執行挑到的都不一樣,所以第 23 行先 random.seed(3) 固定亂數,結果才能重現。換成 random.seed(7) 會挑到 (8, 1.5)、(2.5, 3)、(8.5, 3),最後卡在壞答案(觀念 5)。

不知道要幾輪,就先「一直重複」,等第 16~17 行發現中心不動了,再用 break 跳出。

while True:
while 迴圈條件永遠成立,會一直跑,直到遇到 break。

每一輪先清出一個空的 clusters,再對每間店:量它到 3 個中心的距離,找出最小的那個在第幾號,就是它的群號。

[math.dist(p, c) for c in centroids]
串列推導式對每個中心量一次距離,做成 3 個數字的清單。
min(distances)
內建函式最小的距離。
distances.index(…)
方法這個值在清單的第幾號(從 0 數);一樣近時取第一個。
第 1 輪(實測): (1.5, 2) → [1.58, 7.83, 6.5] → 0 (7, 2.5) → [6.08, 6.80, 6.26] → 0 ← 東邊的店,卻離西南的中心最近 (7.5, 4) → [6.52, 5.59, 5.32] → 2 clusters → [0, 0, 0, 0, 0, 0, 2, 2, 2, 1, 1, 2]

對每一群:用 zip 把「店」和「它的群號」配成一對對,留下群號是 k 的店,x、y 各自取平均,就是新的中心。

zip(data_points, clusters)
內建函式兩個清單一格一格配對:((1.5, 2), 0)、((2, 1), 0)…
sum(x for x, y in members) / len(members)
平均把每間店的 x 加起來,除以店數。
第 1 輪的第 0 群(6 間店): members → [(1.5, 2), (2, 1), (2.5, 3), (1, 3.5), (7, 2.5), (8, 1.5)] 新中心 → (3.6666666666666665, 2.25) new_centroids → [(3.67, 2.25), (5.25, 8.25), (5.88, 5.5)](四捨五入)
  • 如果某一群一個點都沒有,len(members) 是 0,這一行會出錯 ZeroDivisionError。虛擬碼沒說這時怎麼辦(§2「空群」)。

新算出來的中心和這一輪開始時一模一樣,就代表沒有店換組、中心也不必再搬:跳出 while 迴圈。

new_centroids == centroids
比較兩個清單逐一比較每個中心的 x、y,全部相等才是 True。(1, 3.5) == (1.0, 3.5) 也是 True。
break
關鍵字立刻跳出最近的那個迴圈。
實測:第 1、2、3 輪都是 False,第 4 輪是 True → 跑了 4 輪

centroids 這個名字改指向新的清單,回到第 6 行再做一次分群和更新。

一次交回兩樣東西(Python 會把它們包成一個 tuple),對應虛擬碼的兩個 Output。

準備資料,固定亂數種子,呼叫 kmeans;交回的兩樣東西用兩個名字接住。

random.seed(3)
函式固定亂數的起點,之後的 random.sample 每次都挑到同樣的 3 間。
clusters, centroids = …
拆包左邊兩個名字各接一樣。
印出(實測): [0, 0, 0, 0, 2, 2, 2, 2, 1, 1, 1, 1] [(1.75, 2.375), (4.5, 7.875), (7.75, 2.75)]

西南 4 間是第 0 群、北邊 4 間是第 1 群、東邊 4 間是第 2 群。群號只是編號,換一個起點,同樣的分法可能叫做不同的號碼(scikit-learn 預設就把東邊叫第 0 群)。

§4

互動實驗:自己按「分群」「更新中心」,再看四種方法怎麼分

實驗 1 用 JavaScript 照 §3 的 Python 即時計算(距離用和 Python 的 math.dist 同樣的算法,算到最後一位都一樣)。起點是事先用 Python 的 random.sample 抽好的:X=2~5、種子 0~19、有沒有偏遠的店,共 160 組。本頁用 Node.js 跑同一段計算程式,從這 160 組起點一路跑到收斂,和 Python 3.13.7 實際執行的每一輪中心、分群、總和比對了 1,066 個結果,全部一致。實驗 2 的每一種分法,是事先用 scikit-learn 1.8.0 實際算出來的。

實驗 1|K-means 實驗室:一步一步搬物流中心
圓點是店,方塊是物流中心,顏色是群。按「a. 分群」讓每間店找最近的中心(細線連到它的中心);按「b. 更新中心」讓每個中心搬到它那群的平均位置(虛線是搬家的路線)。
每一輪的紀錄
    對應的程式
    可以試試看

    ① 按「考卷情境」,交替按 a、b:第 1 輪東邊有 2 間店被分到西南的中心,中心一路往東拉,第 3 輪東邊 4 間才回到同一群,第 4 輪中心不動,停止。② 按「種子 7」:兩個中心都在東邊,按兩輪就停了,總和 87.25,比 14.125 大很多:停下來不代表最好。③ 按「加偏遠的店」:(9.5, 9.5) 被分進北邊那群,北邊的中心被拉到 (5.5, 8.2)。④ 把 X 拉到 4、5,總和繼續變小,但多出來的群只是把原本的區域再切開。⑤ 有幾組起點會出現「某一群一個點都沒有」,這時 §3 的程式會在平均那一行除以 0。

    實驗 2|四個選項:同一張地圖,四種分法
    選一種方法,地圖上的顏色是 scikit-learn 1.8.0 實際分出來的群;灰色叉叉是 DBSCAN 標成雜訊(−1)的點。下面說明這種方法怎麼做。
    看完實驗 2 要記住的一句話

    12 間店分得很開,四種方法的答案都一樣;所以考題給的是做法,不是結果。K-means 用「最近的中心+平均」;GMM 用「機率+鐘形分佈」(橢圓是每群的形狀);階層式用「一路合併」(看下面的合併順序);DBSCAN 用「夠不夠密」,偏遠的店因為附近 1.6 公里內不到 3 間店,被標成雜訊,其他三種方法都把它硬塞進某一群。

    看動畫 3 之前:這支要回答什麼

    §2 的表格比較了四種分群法,實驗 2 讓你切換著看。動畫 3 在同一張地圖上,把四個選項一個一個演出來:K-means 的中心和 k-means++、GMM 的橢圓和機率、階層式的合併順序、DBSCAN 的範圍圈,最後加一間偏遠的店,看誰把它當成雜訊。

    帶著三個問題看:① GMM 和 K-means 最大的差別是什麼?② 階層式為什麼切在 3 群?③ 偏遠的店在 K-means 和 DBSCAN 裡各怎樣?

    動畫 3四個選項,四種分法:同一張地圖比一比2 分 16 秒・有旁白,也有字幕
    🔒這支動畫在完整版試看版每頁開放第 1 支動畫

    每一種分法、GMM 的橢圓與機率、階層式的合併距離、DBSCAN 的核心點,都是 scikit-learn 1.8.0(SciPy 1.16.2)在這 12 間店上實際算出來的。顏色照區域上(西南藍、北邊橘、東邊綠),因為各方法的群號編法不同。

    看完動畫 3,你應該能回答:GMM 和 K-means 最大的差別是什麼?
    K-means 把每個點直接指派給最近的中心(只屬一群);GMM 算每個點屬於每一群的機率(新店 (5, 4) 屬於東邊 0.9986),而且每群除了平均還有形狀。
    階層式為什麼切在 3 群?
    前 9 次合併的距離都在 2.5 以內(同一區的店互相合併),最後 2 次是 12.02、12.28(把不同區合起來),距離一下變大,切在這之前就是 3 群。
    偏遠的店在 K-means 和 DBSCAN 裡各怎樣?
    K-means 一定要把它分進某一群,塞進北邊,北邊的中心從 (4.5, 7.88) 被拉到 (5.5, 8.2);DBSCAN 因為 1.6 公里內沒有其他店,把它標成雜訊 −1。
    §5

    回到題目:逐項對答案

    先把虛擬碼的每一步,拿去問四種方法「你有沒有做這件事」:

    虛擬碼的步驟A K-meansB GMMC 階層式D DBSCAN
    先給群數 X有有可以不用不用
    隨機選 X 個點當初始中心有平均+形狀沒有中心沒有中心
    每個點指派給最近的中心(只屬一群)有給機率合併看密度
    中心更新為群的平均值,重複到不再變動有還要更新形狀、權重不反覆不反覆
    (A) K-means 分群 正解

    虛擬碼的每一步都是 K-means 的定義:隨機選 X 個中心(步驟 1)、每個點分給最近的中心(2a)、中心改成群的平均(2b)、重複到中心不動(3)。X 就是 K,「平均」就是 means。照它翻成的 Python 和 scikit-learn 的 KMeans(init=同樣的起點, n_init=1) 在 12 間店上結果完全相同,都是 4 輪、總和 14.125。

    (B) 高斯混合模型(GMM) 錯

    GMM 假設每一群是一個鐘形(常態)分佈,有自己的平均和形狀(共變異數)。它也是兩步驟反覆(EM 演算法),但第一步算的是「每個點屬於每一群的機率」,不是直接指派給最近的中心;第二步除了平均,還要更新形狀和每群的比重。實測新店 (5, 4) 屬於東邊那群的機率是 0.9986,其他兩群合起來 0.0014。虛擬碼是「非此即彼」的指派,所以不是它。

    (C) 階層式分群 錯

    階層式(scikit-learn 的 AgglomerativeClustering,預設 linkage='ward')一開始每個點自成一群,每一步把「合併後增加的變異最少」的兩群合在一起,一路合到剩下要的群數,過程可以畫成樹狀圖。它沒有初始中心、沒有隨機、也不用「重複到收斂」。

    (D) DBSCAN 分群 錯

    DBSCAN 不用先說要幾群,改給兩個數:多近算鄰居(eps,本頁 1.6 公里)、至少幾個鄰居才算「密集」(min_samples,本頁 3)。從密集的點往外擴張,連得到的就是一群;連不到的點標成雜訊 −1。實測加一間偏遠的店 (9.5, 9.5),DBSCAN 把它標成 −1,K-means 卻把它塞進北邊那群。DBSCAN 沒有中心、沒有平均。

    考場上的辨識口訣:看到這些詞,就是這個演算法

    虛擬碼或題目出現的關鍵動作演算法
    選 K 個中心 → 每點指派給最近的中心 → 中心改成平均 → 重複到不再變動K-means
    每點屬於每群的機率、常態分佈、平均與共變異數、EM(E 步驟、M 步驟)GMM(高斯混合模型)
    每點自成一群 → 每次合併最近的兩群 → 樹狀圖(dendrogram)階層式分群
    eps、min_samples、核心點、密度可達、雜訊(noise)DBSCAN

    常見的錯誤想法

    錯誤想法實際上
    K-means 的 K 和 KNN 的 K 一樣K-means 的 K 是「分幾群」(非監督式);KNN 的 K 是「問幾個鄰居」(監督式分類,第 40 題 115 年那一題)。
    K-means 停下來就是最好的分法只代表「中心不再動」。起點不好會卡在壞答案:種子 7 停在 87.25,好的分法是 14.125。
    結果一樣,演算法就一樣這 12 間店四種方法分得一模一樣;要看做法:中心+平均+最近才是 K-means。
    K-means 會自動排除離群點不會。每個點都一定被分進某一群,偏遠的店還會把中心拉走;能標出雜訊的是 DBSCAN。
    群號 0、1、2 有固定意義群號只是編號。§3 的結果西南是第 0 群;scikit-learn 預設的結果,西南是第 2 群。
    §6

    動手跑跑看:三支程式

    程式 1 只用 Python 本身,裝好 Python 就能跑。程式 2、3 用到 scikit-learn 和 NumPy,要先安裝:pip install scikit-learn(Google Colab 已經內建,直接貼上就能跑)。

    程式 1:把虛擬碼翻成 Python,一輪一輪印出來

    import math
    import random
    
    def kmeans(data_points, X):
        centroids = random.sample(data_points, X)                  # 1
        rnd = 0
        while True:                                                # 2
            clusters = []                                          # 2a 分群
            for p in data_points:
                distances = [math.dist(p, c) for c in centroids]
                clusters.append(distances.index(min(distances)))
            new_centroids = []                                     # 2b 更新中心
            for k in range(X):
                members = [p for p, c in zip(data_points, clusters) if c == k]
                new_centroids.append((sum(x for x, y in members) / len(members),
                                      sum(y for x, y in members) / len(members)))
            rnd += 1
            print(f'第 {rnd} 輪:中心 {[(round(x, 2), round(y, 2)) for x, y in centroids]} → 分群 {clusters}')
            if new_centroids == centroids:                         # 3
                break
            centroids = new_centroids
        return clusters, centroids
    
    # 12 間店在地圖上的位置(公里)
    stores = [(1.5, 2), (2, 1), (2.5, 3), (1, 3.5), (7, 2.5), (8, 1.5),
              (8.5, 3), (7.5, 4), (4, 8), (5, 9), (5.5, 7.5), (3.5, 7)]
    random.seed(3)
    clusters, centroids = kmeans(stores, 3)
    print('clusters =', clusters)
    print('centroids =', centroids)
    實際跑出來的結果(Python 3.13.7)
    第 1 輪:中心 [(1, 3.5), (5, 9), (4, 8)] → 分群 [0, 0, 0, 0, 0, 0, 2, 2, 2, 1, 1, 2]
    第 2 輪:中心 [(3.67, 2.25), (5.25, 8.25), (5.88, 5.5)] → 分群 [0, 0, 0, 0, 2, 0, 2, 2, 1, 1, 1, 1]
    第 3 輪:中心 [(3.0, 2.2), (4.5, 7.88), (7.67, 3.17)] → 分群 [0, 0, 0, 0, 2, 2, 2, 2, 1, 1, 1, 1]
    第 4 輪:中心 [(1.75, 2.38), (4.5, 7.88), (7.75, 2.75)] → 分群 [0, 0, 0, 0, 2, 2, 2, 2, 1, 1, 1, 1]
    clusters = [0, 0, 0, 0, 2, 2, 2, 2, 1, 1, 1, 1]
    centroids = [(1.75, 2.375), (4.5, 7.875), (7.75, 2.75)]

    和 §3 的程式相比,只多了數輪數的 rnd(第 6、17 行)、每一輪的 print(第 18 行)、店家位置的註解(第 24 行),最後印出時加了名字。印出的中心四捨五入到小數 2 位。

    程式 2:和 scikit-learn 的 KMeans 對答案

    import numpy as np
    from sklearn.cluster import KMeans
    
    stores = np.array([(1.5, 2), (2, 1), (2.5, 3), (1, 3.5), (7, 2.5), (8, 1.5),
                       (8.5, 3), (7.5, 4), (4, 8), (5, 9), (5.5, 7.5), (3.5, 7)])
    
    # 和程式 1 一樣的起點:(1, 3.5)、(5, 9)、(4, 8),只跑 1 次
    same = KMeans(n_clusters=3, init=np.array([(1, 3.5), (5, 9), (4, 8)]), n_init=1).fit(stores)
    print('同樣的起點:labels_ =', same.labels_.tolist())
    print('            cluster_centers_ =', same.cluster_centers_.tolist())
    print('            n_iter_ =', same.n_iter_, '|inertia_ =', same.inertia_)
    
    # 壞的起點:(8, 1.5)、(2.5, 3)、(8.5, 3)
    bad = KMeans(n_clusters=3, init=np.array([(8, 1.5), (2.5, 3), (8.5, 3)]), n_init=1).fit(stores)
    print('壞的起點:labels_ =', bad.labels_.tolist(), '|inertia_ =', bad.inertia_)
    
    # 預設:init='k-means++'
    model = KMeans(n_clusters=3, random_state=0).fit(stores)
    print('預設 k-means++:labels_ =', model.labels_.tolist(), '|n_iter_ =', model.n_iter_, '|inertia_ =', model.inertia_)
    print('新店 (5, 4) 屬於群', model.predict([(5, 4)])[0])
    實際跑出來的結果(scikit-learn 1.8.0)
    同樣的起點:labels_ = [0, 0, 0, 0, 2, 2, 2, 2, 1, 1, 1, 1]
                cluster_centers_ = [[1.75, 2.375], [4.5, 7.875], [7.75, 2.75]]
                n_iter_ = 4 |inertia_ = 14.125
    壞的起點:labels_ = [1, 1, 1, 1, 0, 0, 2, 2, 1, 1, 1, 1] |inertia_ = 87.25
    預設 k-means++:labels_ = [2, 2, 2, 2, 0, 0, 0, 0, 1, 1, 1, 1] |n_iter_ = 2 |inertia_ = 14.125
    新店 (5, 4) 屬於群 0

    同樣的起點:群號、中心和程式 1 完全一樣,n_iter_ 也是 4。壞的起點:東邊切成兩群(群號 0、2),西南和北邊被湊成第 1 群,總和 87.25。預設的 k-means++ 只跑 2 輪就到 14.125,群號的編法不同(東邊是第 0 群);新店 (5, 4) 離東邊的中心 3.02 公里,最近,所以是第 0 群。

    程式 3:四個選項各跑一次,再加一間偏遠的店

    import numpy as np
    from sklearn.cluster import KMeans, AgglomerativeClustering, DBSCAN
    from sklearn.mixture import GaussianMixture
    
    stores = np.array([(1.5, 2), (2, 1), (2.5, 3), (1, 3.5), (7, 2.5), (8, 1.5),
                       (8.5, 3), (7.5, 4), (4, 8), (5, 9), (5.5, 7.5), (3.5, 7)])
    
    print('A K-means:', KMeans(n_clusters=3, random_state=0).fit(stores).labels_.tolist())
    gmm = GaussianMixture(n_components=3, random_state=0).fit(stores)
    print('B GMM:    ', gmm.predict(stores).tolist())
    print('   新店 (5, 4) 屬於各群的機率:', np.round(gmm.predict_proba([(5, 4)])[0], 4).tolist())
    print('C 階層式:  ', AgglomerativeClustering(n_clusters=3).fit(stores).labels_.tolist())
    print('D DBSCAN:  ', DBSCAN(eps=1.6, min_samples=3).fit(stores).labels_.tolist())
    
    # 多一間偏遠的店 (9.5, 9.5)
    more = np.vstack([stores, [(9.5, 9.5)]])
    km = KMeans(n_clusters=3, random_state=0).fit(more)
    print('加一間偏遠的店:')
    print('  K-means:', km.labels_.tolist(), '中心', np.round(km.cluster_centers_, 2).tolist())
    print('  DBSCAN: ', DBSCAN(eps=1.6, min_samples=3).fit(more).labels_.tolist())
    實際跑出來的結果
    A K-means: [2, 2, 2, 2, 0, 0, 0, 0, 1, 1, 1, 1]
    B GMM:     [2, 2, 2, 2, 0, 0, 0, 0, 1, 1, 1, 1]
       新店 (5, 4) 屬於各群的機率: [0.9986, 0.0014, 0.0]
    C 階層式:   [0, 0, 0, 0, 2, 2, 2, 2, 1, 1, 1, 1]
    D DBSCAN:   [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2]
    加一間偏遠的店:
      K-means: [2, 2, 2, 2, 0, 0, 0, 0, 1, 1, 1, 1, 1] 中心 [[7.75, 2.75], [5.5, 8.2], [1.75, 2.38]]
      DBSCAN:  [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, -1]

    四種方法分出來的三群完全一樣,只是群號的編法不同。GMM 交回的是機率。加了偏遠的店之後,K-means 把它分進北邊那群(第 1 群),北邊的中心從 (4.5, 7.88) 被拉到 (5.5, 8.2);DBSCAN 把它標成 −1(雜訊)。

    新出現的語法是什麼白話
    random.seed(3)、random.sample(清單, X)函式固定亂數;隨機挑 X 個不重複的元素
    while True: … break迴圈一直重複,直到 break 跳出
    清單.index(min(清單))方法+內建函式最小值在第幾號
    zip(a, b)內建函式兩個清單一格一格配成對
    KMeans(n_clusters=3, init=…, n_init=1)scikit-learn 類別K-means;.labels_、.cluster_centers_、.inertia_、.n_iter_
    GaussianMixture(n_components=3)scikit-learn 類別GMM;.predict_proba() 交回屬於每一群的機率
    AgglomerativeClustering(n_clusters=3)scikit-learn 類別階層式分群(預設 ward 合併)
    DBSCAN(eps=1.6, min_samples=3)scikit-learn 類別密度分群;雜訊的群號是 −1
    np.vstack([a, b])NumPy 函式把列疊起來(多加一間店)
    §7

    一頁總結+自我檢測

    這題要帶走的 6 件事
    1. 分群沒有標籤,依「誰和誰近」自己分組,是非監督式學習。
    2. 「隨機選 X 個中心 → 分給最近的中心 → 中心改成平均 → 重複到不再變動」就是 K-means,X 就是 K。第 41 題答案 A。
    3. 翻成 Python:random.sample 挑起點、math.dist 量距離、index(min(...)) 找最近、zip 收集同群、while True + break 重複到收斂。
    4. 起點會影響結果:100 個種子有 9 個卡在壞答案;scikit-learn 預設用 k-means++ 挑起點。
    5. K-means 每個點只屬一群、每群用平均代表;離群的點也會被硬塞進某一群,把中心拉走。
    6. GMM 給機率、階層式一路合併、DBSCAN 看密度並標出雜訊;結果可能一樣,做法不一樣。
    K-means 的名字是怎麼來的?
    K 個群,每一群用「平均」(mean)當中心。虛擬碼的「將群中心更新為這個平均值」就是 means。
    為什麼不是 GMM?
    虛擬碼把每個點「指派給距離最近的中心」,只屬一群;GMM 算的是每個點屬於每一群的機率,而且每群還有形狀(共變異數)要更新。
    虛擬碼的「重複直到收斂」,Python 怎麼寫?
    while True: 一直重複,在迴圈裡 if new_centroids == centroids: break,中心不變就跳出。
    random.seed(3) 時第 1 輪的 3 個中心是哪裡?跑了幾輪?
    (1, 3.5)、(5, 9)、(4, 8);跑了 4 輪,最後的中心是 (1.75, 2.375)、(4.5, 7.875)、(7.75, 2.75),總和 14.125。
    K-means 停下來了,就一定是最好的分法嗎?
    不一定。random.seed(7) 的起點讓它停在總和 87.25 的壞答案。實務上用 k-means++ 或跑好幾次(n_init)留最好的。
    哪一個選項不用先決定群數,還能標出雜訊?
    DBSCAN:給 eps 和 min_samples,雜訊的群號是 −1。
    某一群一個點都沒有時,§3 的程式會怎樣?
    算平均時 len(members) 是 0,出錯 ZeroDivisionError。虛擬碼沒有規定這種情況。

    這是考古題程式奈米級拆解中的 1 頁

    每一頁都把考古題的程式一行一行拆開:記憶體裡發生什麼、每個選項各算出什麼,配旁白動畫。完整版還有:

    💳 信用卡直接購買🛒 前往蝦皮賣場 📖 看完整介紹 🗺️ 回學習順序總覽