聚類演算法(一)—— k-means演算法以及其改進演算法
聚類演算法是一種無監督學習,它把資料分成若干類,同一類中的資料的相似性應儘可能地大,不同類中的資料的差異性應儘可能地大。聚類演算法可分為“軟聚類”和“硬聚類”,對於“硬聚類”,樣本中的每一個點都是 100%確定分到某一個類別;而“軟聚類”是指樣本點以一定的概率被分配到一個類別中。提到聚類演算法,很容易想到 K-means 演算法,即 K-均值。這種方法很好理解,也很好實現。本文以 k-means 為引子,不斷引出 K-means 的各種改進版本,如K-means ++ 、kernel K-means等。
K-means 的步驟可以分為:
(1)、隨機選取 K 個點,作為 K 類的聚類中心,用 Ki 表示
(2)、遍歷所有的資料點 Pj,通過計算距離,找到距離 Pj 最近的聚類中心點 Ki。此時可以說第 j 個數據屬於第 i 類
(3)、分別計算第 i 類的所有資料的中心點,作為該類的新的聚類中心點。
(4)、重複進行(2)(3)步驟。直到每一類的聚類中心不再發生變化
下面是 K -means 的程式碼:
#include<iostream> #include<sstream> #include<fstream> #include<string> #include<vector> #include<ctime> #include<cstdlib> //srand() 在裡面 #include<limits> using namespace std; typedef struct Point { float x; float y; int cluster; Point(){}; Point(float a,float b,int c) { x = a; y = b; cluster = c; } }point; float stringToFloat(string str) { stringstream sf; // 在<sstream>標頭檔案中,進行流的輸入輸出操作。傳入引數和目標物件的型別能夠被自動推匯出來 float f_num = 0; sf << str; // 將 str 輸入到流中 sf >> f_num; // 將字串 str 轉換為 int 型別的變數 return f_num; } vector<point> openFile(const char* dataset) // 開啟檔案 { fstream file; file.open(dataset,ios::in); vector<point> data; while (!file.eof()) { string temp; file >> temp; int split = temp.find(',', 0);// 從 0 開始找‘,’如果找到了就返回位置 point p(stringToFloat(temp.substr(0, split)), stringToFloat(temp.substr(split + 1, temp.length() - 1)), 0); data.push_back(p); } file.close(); return data; } float squarDistance(point a, point b) { return (a.x - b.x) *(a.x - b.x) + (a.y - b.y)*(a.y - b.y); } void k_means(vector<point> dataset, int k) { vector<point> centroid; int n = 1; //n 表示所屬的類別 int len = dataset.size(); srand((int) time(0)); // 隨機選擇 centroids while (n <= k) // 質心點的個數為 1 ~k.每一次迴圈選擇一個隨機點 cp 作為中心 { int cen = (float)rand() / (RAND_MAX + 1)*len; // RAND_MAX代表rand() 的最大值。那麼rand() / (RAND_MAX + 1)的範圍就是[0,1)。所以cen範圍為[0,len) point cp(dataset[cen].x, dataset[cen].y, n); centroid.push_back(cp); n++; } //for (int i = 0; i < k; i++) //{ // cout << "x:" << centroid[i].x << "\ty:" << centroid[i].y << "\tc: " << centroid[i].cluster << endl; // 一開始隨機選擇的每一類的聚類中心點 //} // cluster int time = 0; // 記錄迭代次數 int oSSE = INT_MAX; // 確保第一次能夠進入到迴圈中 int nSSE = 0; // nSSE 為每一個點距離其所屬聚類中心的距離平方和 的和 while (abs(oSSE - nSSE) >= 0.1) // 前後兩次的 nSSE 的差值,如果小於 0.1(這是要根據資料集適當調整的),停止迭代。也就是聚類中心不再發生變化 { time++; oSSE = nSSE; nSSE = 0; // 遍歷所有的點,更新每一個點所屬的聚類群.len 為點的個數 for (int i = 0; i < len; i++) { n = 1; float shortest = INT_MAX; int cur = dataset[i].cluster; // 當前的點所屬的類別 while (n <= k) // 判斷屬於 k 類中的哪一類 { float temp = squarDistance(dataset[i],centroid[n-1]); // 第 i 個點和聚類中心的距離平方和 if (temp < shortest) { shortest = temp; cur = n; //迴圈 k 次,找到當前的點與每個聚類中心的距離的最小值,,然後,將這個聚類中心作為該資料點的所屬聚類 } n++; } dataset[i].cluster = cur; } // 更新聚類中心(聚類點的聚類類別是 1~k,但是對應的下標都是 0~k - 1) int *point_num_percluster = new int[k]; // 記錄每類聚類點的個數 for (int i = 0; i < k; i++) point_num_percluster[i] = 0; for (int i = 0; i < k; i++) { centroid[i] = point(0, 0, i + 1); // 初始化聚類中心點。之前的第一次是隨機選的,此時需要重新計算了。 } for (int i = 0; i < len; i++) { centroid[dataset[i].cluster - 1].x += dataset[i].x; // 將每一類的聚類點的座標進行累加 centroid[dataset[i].cluster - 1].y += dataset[i].y; point_num_percluster[dataset[i].cluster - 1]++; // 這是記錄每類聚類點的個數 } for (int i = 0; i < k; i++) { centroid[i].x /= point_num_percluster[i]; centroid[i].y /= point_num_percluster[i]; } for (int i = 0; i < k; i++) // 輸出每一類的聚類中心點 { cout << "x : " << centroid[i].x << "\ty : " << centroid[i].y << "\tc: " << centroid[i].cluster << endl; } for (int i = 0; i < len; i++) { nSSE += squarDistance(centroid[dataset[i].cluster - 1],dataset[i]); // 計算聚類中心點與每一個點的距離的平方和的差 } } // while (abs(oSSE - nSSE) >= 1) 迴圈結束 fstream clustering; clustering.open("clustering.txt", ios::out); // 將聚類後的結果寫入clustering.txt for (int i = 0; i < len; i++) { clustering << dataset[i].x << "," << dataset[i].y << "," << dataset[i].cluster << "\n"; } clustering.close(); cout << "迭代次數: " << time << endl; } int main(int argc, char** argv) { cout << "start running...." << endl; vector<point> dataset = openFile("dataset3.txt"); cout << "read successfully!" << endl; k_means(dataset, 3); // 比如這 20 個數據,如果分成 2 類,只有10個數據被分類了。如果被分成 5 類,會有5個數據至少被分到兩個類別中了 return 0; }
程式中所用的資料集,是我自己設定的。資料集我選取了(0,0)到(3,3)內的正方形的點集和(8,8)到(10,10)內的正方形的點集。資料集即聚類結果如下:
K-means 存在以下幾個缺點:
(1)、對 K 值敏感。也就是說,K 的選擇會較大程度上影響分類效果。在聚類之前,我們需要預先設定 K 的大小,但是我們很難確定分成幾類是最佳的,比如上面的資料集中,顯然分為 2 類,即K = 2 最好,但是當資料量很大時,我們預先無法判斷。
(2)、對離群點和噪聲點敏感。如果在上述資料集中新增一個(100,100)這個點。很顯然,如果 K = 2,其餘20個點是一類,(100,100)自成一類。如果 K = 3,(100,100)也是自成一類,剩下的資料分成兩類。但是實際上,(100,100)自成一類沒有什麼必要,並且,如果資料量再加一點且分的類別比較多的話,(100,100)這個點會極大的影響其他點的分類。
(3)、初始聚類中心的選擇。K-means 是隨機選擇 K 個點作為初始的聚類中心。但是,我們可以對這個隨機性進行一點約束,使迭代速度更快。舉個例子,如果上面的資料集我隨機選擇了(0,0)和(1,0)兩個點,或者選擇了(1.5,1.5)和(9,9)兩個點,即可以加快迭代速度,也可以避免陷入區域性最優。
(4)、只能聚凸的資料集。所謂的凸資料集,是指集合內的每一對點,連線兩個點的直線段上的每個點也在該集合內。但是有研究表明,若採用 Bregman 距離,則可顯著增強此類演算法對更多型別簇結構的適用性。
K-means 的改進
針對(1):(1)中存在的問題主要是 K 的值必須認為預先設定,並且在整個演算法執行過程中無法更改。此時,可以利用 ISODATA 演算法:當屬於某個類別的樣本數過少,就將這個類別剔除;當屬於這個類別的樣本過多、分散程度很大的時候,就將這個類別分為兩個子類,此時 K 也就會 + 1了
可以參考:李芳. K-Means演算法的k值自適應優化方法研究[D]. 安徽大學, 2015
一些說明:雖然有很多啟發式用於自動確定 k 的值,但是實際應用中,最常用的仍然是基於不同的 K 值,多次執行取平均值(周志華 《機器學習》書 P218)
針對(2):針對離群點和噪聲點,我們可以使用一些演算法,比如 RANSAC 、LOF 等剔除離群點。此外,基於 K-means 的改進演算法有 k-medoids 和 k-medians
針對(3):K-means ++ 不再隨機選擇 K 個聚類中心:假設已經選取了 m 個聚類中心( 0 < m < K),m = 1時,隨機選擇一個聚類中心點;在選取第 m+1 個點的時候,距離當前 m 個聚類中心點的中心越遠的點,越會以更高的概率被選為第 m+1 個聚類中心。這種方法在一定程度上可以讓“隨機”選擇的聚類中心點的分佈更均勻。此外還有 canopy 演算法等。
針對(4):K-means 是使用歐式距離來測量,顯然,這種度量方式並不適合於所有的資料集。換句話說,K-means 比較適合聚那些球狀的簇。參照 SVM 中核函式的思想,將樣本對映到另外一個特徵空間,就可以改善聚類效果。代表演算法是;kernel K-means。
擴充套件:
1、核函式的選取也是一個很麻煩的工作,此時,可以考慮換方法!聚類不再以距離來度量,而是基於密度!參見另一篇部落格:基於密度的聚類方法
2、k-means 演算法可以看做高斯混合聚類在混合成分方差相等、且每個樣本僅僅指派給一個混合成分時的特例。關於高斯混合聚類,參見另一篇部落格:基於高斯混合分佈的聚類