聚类算法是一种无监督学习,它把数据分成若干类,同一类中的数据的相似性应尽可能地大,不同类中的数据的差异性应尽可能地大。聚类算法可分为“软聚类”和“硬聚类”,对于“硬聚类”,样本中的每一个点都是 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
https://www.cnblogs.com/yixuan-xu/p/6272208.html
一些说明:虽然有很多启发式用于自动确定 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 算法可以看做高斯混合聚类在混合成分方差相等、且每个样本仅仅指派给一个混合成分时的特例。关于高斯混合聚类,参见另一篇博客:基于高斯混合分布的聚类