1. 程式人生 > >聚類演算法(一)—— k-means演算法以及其改進演算法

聚類演算法(一)—— 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 演算法可以看做高斯混合聚類在混合成分方差相等、且每個樣本僅僅指派給一個混合成分時的特例。關於高斯混合聚類,參見另一篇部落格:基於高斯混合分佈的聚類