本节用Python实现K-Means算法,对未标注的数据进行聚类。主要参考《机器学习实战》—— Peter Harrington著。
K-Means简介
这里参考了大三专业课老师的PPT,现在回过头来看,老师当初讲得特别透彻,可惜没好好听,老师dbq (*>﹏<*)。
k-means算法,也被称为k-平均或k-均值算法,是一种使用最广泛的聚类算法。根据个体到每个类中心的距离进行划分,而类中心用类中所有个体的均值来度量。
思路及步骤:
- 随机或按某种策略从n个对象中选择k个对象作为初始的类中心(Centriod,Mean Point);
- 计算每个对象与这k个类中心的距离;
- 将每个对象划分/分配到与其距离最近的类中心所在的类中;并重新计算每个类的类中心。
- 回到第2步,直到和前一次划分/分配结果无差异,停止。
代码实现
(一)数据集读入
先查看一下 testSet.txt
数据集的格式,每一行有两个数据,用空格间隔开。
我们首先要将每一行用空格 split , 用 map 函数进行数值类型转化(转为 float 型),再保存到一个名为 dataMat
的列表中。代码如下所示:
# 读入数据,保存到列表
def loadDataSet(fileName):
dataMat = []
with open(fileName, "r", encoding='utf-8') as fn:
for line in fn.readlines():
curLine = line.strip().split('\t')
fltLine = list(map(float, curLine)) # 数值类型转化,map()会根据提供的函数对指定序列做映射
dataMat.append(fltLine)
return dataMat
注:map ( ) 函数在 Python 2.x 返回列表,在 Python 3.x 返回迭代器。效果如下:
(二)距离计算
采用欧式距离进行计算,也可以使用其他计算方法。
# 计算距离
def distCal(vecA, vecB):
return np.sqrt(np.sum(np.power(vecA - vecB, 2)))
# vecA, vecB都为数组的形式,类似于[1 2]
# power(x1, x2)数组的元素分别求n次方。x2可以是数字,也可以是数组,但是x1和x2的列数要相同。
(三)构建随机质心
这一步用来随机生成质心。
- 质心的表示与原数据相同,原数据保存的格式类似为
[[1, 4], [-3, 3], [4, -1], ...]
,因此首先获取到每一行的数据有几个,用 shape [1] 来获取,得到 n 为2。 - 接下来生成 k * n 的矩阵,用来保存 k 个质心。
- 最后是表示质心的值,随机生成处于最大最小之间的值。获取第 j 列的最小值和幅度值,则质心的第 j 列的值 = 最小值 + (0-1随机数)* 幅度值。
# 为给定数据集构建一个随机质心矩阵
def randCent(dataSet, k):
n = np.shape(dataSet)[1] # shape函数的功能是读取矩阵的长度,比如shape[0]就是读取矩阵第一维度的长度。
# shape[0]返回的是有多少行,shape[1]返回的是每一行有几个数据
centroids = np.mat(np.zeros((k, n))) # 生成k*n的矩阵,用0初始化
for j in range(n):
minJ = min(dataSet[:, j])
rangeJ = float(max(dataSet[:, j]) - minJ)
centroids[:, j] = minJ + rangeJ * np.random.rand(k, 1) # random.rand(k, 1)生成k行1列的随机数组
return centroids
(四)数据聚类
- 首先找到
dataSet
一共有 m 行,构建 m * 2 的 0 矩阵,第一列存放的是该点所在簇的索引值,第二列存放该点与簇质心的距离。 - 接下来创建创建一个标志变量
clusterChanged
,若为真,则继续迭代。 - 对于每一行数据(视作一个点),分别计算与质心的距离,如果该距离小于之前的最小距离,说明该点离这个质心更近,同时更新簇的索引(或质心的索引)和距离质心的距离。判断该点簇的索引是否发生了变化。
- 在每一行计算结束后,更新质心的位置。首先要进行数组的过滤。例如,cent = 1时,抽取簇索引为1的行保存在
ptsInClust
中,对于每一列的值求平均,获得新的质心的位置。
def kMeans(dataSet, k):
m = np.shape(dataSet)[0] # 一共有m行数据
clusterAssment = np.mat(np.zeros((m, 2))) # 生成m*2的0矩阵,第一列存放该点所在簇的索引,第二列存放该点与簇质心的距离
centroids = randCent(dataSet, k) # 调用函数,构建质心矩阵
clusterChanged = True # 创建一个标志变量,若为真,则继续迭代
count = 0
while clusterChanged:
count += 1
clusterChanged = False
# 寻找最近的质心
for i in range(m): # 对于每一行的数据
minIndex = -1 # 在未迭代之前,将簇的索引设为-1
minDist = np.inf # 在未迭代之前,将最小距离设为的无穷大
for j in range(k): # 对于当前每一个质心
distJI = distCal(centroids[j, :], dataSet[i, :]) # 计算第i个数据与第j个质心之间的距离
if distJI < minDist:
minIndex = j # 若i与j之间距离小于目前的最小值,则更新当前点的索引为j
minDist = distJI # 若i与j之间距离小于目前的最小值,则更新当前最小值
if clusterAssment[i, 0] != minIndex: # 如果第i行的第一个值(簇的索引)不为当前的minIndex,说明簇发生了改变
clusterChanged = True # 继续迭代
clusterAssment[i, :] = int(minIndex), minDist**2
print(centroids)
# 更新质心的位置
for cent in range(k):
category = np.nonzero(clusterAssment[:, 0].A == cent) # 得到簇索引为cent的值的位置
ptsInClust = dataSet[category[0]]
centroids[cent, :] = np.mean(ptsInClust, axis=0) # axis=0表示沿着矩阵列的方向进行均值计算
print('*********************************************************************************')
print('经过%d次迭代,最终的质心坐标为' % count)
print(centroids)
print('*********************************************************************************')
print('打印所有点的索引及距离')
print(clusterAssment)
return centroids, clusterAssment
由于最后的更新质心的位置代码可能比较抽象,我做了一个小小的例子,帮助理解。
首先 clusterAssment = numpy.mat([[1, 0], [0, 1], [2, 0], [0, 3]])
得到这样一个矩阵:
执行以下代码(判断第一列的值是否为0):
print(clusterAssment[:, 0].A == 0) # 判断每一行第一列值是否为0
print('---------------')
print(nonzero(clusterAssment[:, 0].A == 0)) # 找到判断为True的索引
print('---------------')
print(nonzero(clusterAssment[:, 0].A == 0)[0]) # 返回第一行即它们所在的行数
效果如下:
第一个是判断,返回的是 True 和 False ,我们发现,第一列为0对应第2行(索引1)和第4行(索引3);第二个是使用nonzero
,找到显示为True的项,发现返回的有两行,[1, 3]
表示行数,[0, 0]
表示相对应的列,即[1, 0]
[3, 0]
位置的值为0;第三个直接返回第一行的值,即它们的行的索引。
因此,要想知道clusterAssment
矩阵第一列为0的行并且打印出来,我们可以这样做:
category = nonzero(clusterAssment[:, 0].A == 0)[0]
print(clusterAssment[category])
(五)完整代码
import numpy as np
# 读入数据,保存到列表
def loadDataSet(fileName):
dataMat = []
with open(fileName, "r", encoding='utf-8') as fn:
for line in fn.readlines():
curLine = line.strip().split('\t')
fltLine = list(map(float, curLine)) # 数值类型转化,map()会根据提供的函数对指定序列做映射
dataMat.append(fltLine)
return dataMat
# 计算距离
def distCal(vecA, vecB):
return np.sqrt(np.sum(np.power(vecA - vecB, 2)))
# vecA, vecB都为数组的形式,类似于[1 2]
# power(x1, x2)数组的元素分别求n次方。x2可以是数字,也可以是数组,但是x1和x2的列数要相同。
# 为给定数据集构建一个随机质心矩阵
def randCent(dataSet, k):
n = np.shape(dataSet)[1] # shape函数的功能是读取矩阵的长度,比如shape[0]就是读取矩阵第一维度的长度。
# shape[0]返回的是有多少行,shape[1]返回的是每一行有几个数据
centroids = np.mat(np.zeros((k, n))) # 生成k*n的矩阵,用0初始化
for j in range(n):
minJ = min(dataSet[:, j])
rangeJ = float(max(dataSet[:, j]) - minJ)
centroids[:, j] = minJ + rangeJ * np.random.rand(k, 1) # random.rand(k, 1)生成k行1列的随机数组
return centroids
# 数据聚类
def kMeans(dataSet, k):
m = np.shape(dataSet)[0] # 一共有m行数据
clusterAssment = np.mat(np.zeros((m, 2))) # 生成m*2的0矩阵,第一列存放该点所在簇的索引,第二列存放该点与簇质心的距离
centroids = randCent(dataSet, k) # 调用函数,构建质心矩阵
clusterChanged = True # 创建一个标志变量,若为真,则继续迭代
count = 0
while clusterChanged:
count += 1
clusterChanged = False
# 寻找最近的质心
for i in range(m): # 对于每一行的数据
minIndex = -1 # 在未迭代之前,将簇的索引设为-1
minDist = np.inf # 在未迭代之前,将最小距离设为的无穷大
for j in range(k): # 对于当前每一个质心
distJI = distCal(centroids[j, :], dataSet[i, :]) # 计算第i个数据与第j个质心之间的距离
if distJI < minDist:
minIndex = j # 若i与j之间距离小于目前的最小值,则更新当前点的索引为j
minDist = distJI # 若i与j之间距离小于目前的最小值,则更新当前最小值
if clusterAssment[i, 0] != minIndex: # 如果第i行的第一个值(簇的索引)不为minIndex,说明簇发生了改变
clusterChanged = True # 继续迭代
clusterAssment[i, :] = int(minIndex), minDist**2
print(centroids)
# 更新质心的位置
for cent in range(k):
category = np.nonzero(clusterAssment[:, 0].A == cent) # 得到簇索引为cent的值的位置
ptsInClust = dataSet[category[0]]
centroids[cent, :] = np.mean(ptsInClust, axis=0) # axis=0表示沿着矩阵列的方向进行均值计算
print('*********************************************************************************')
print('经过%d次迭代,最终的质心坐标为' % count)
print(centroids)
print('*********************************************************************************')
print('打印所有点的索引及距离')
print(clusterAssment)
return centroids, clusterAssment
if __name__ == '__main__':
data = np.mat(loadDataSet('testSet.txt'))
kMeans(data, 4)
经过了五次迭代,效果如图:
改进:采用二分法
(一)简介
由于参数 k 的值是用户预先给定的,因此会出现 K-means 只收敛于局部最优,还存在有最好的结果,因此采用二分法(bisecting K-means)。
首先将所有的点看作一个簇,然后将该簇一分为二。之后选择其中一个簇继续划分,选择哪一个簇基于是否可以最大程度降低SSE的值。上述过程不断重复。
SSE(Sum of Squared Error,误差平方和),对应上文中
clusterAssment
第2列(索引为1)的值,SSE越小代表数据点越接近于它们的质心。对距离取平方,会放大那些距离较远的值。
伪代码如下:
将所有点看作一个簇
当簇的数目小于k时
对于每一个簇
计算总误差
在给定的簇上面进行K-均值聚类(k = 2)
计算将该簇一分为二之后的总误差
选择使得将误差最小的那个簇进行划分操作
(二)代码
在之前代码基础上,新增如下代码:
# 二分聚类
def biKMeans(dataSet, k):
global bestCentToSplit, bestClustAss, bestNewCents
m = np.shape(dataSet)[0] # 一共有m行数据
clusterAssment = np.mat(np.zeros((m, 2))) # 生成m*2的0矩阵,第一列存放该点所在簇的索引,第二列存放该点与簇质心的距离
centroid0 = np.mean(dataSet, axis=0).tolist()[0] # 计算整个数据集的质心,并且将之转换为列表
centList = [centroid0] # 用来存放质心的列表
for j in range(m):
clusterAssment[j, 1] = distCal(np.mat(centroid0), dataSet[j, :])**2 # 计算每一行与当前质心的距离的平方
while len(centList) < k:
# 寻找一个最佳的簇,使得分完后SSE最小
lowestSSE = np.inf # 设置初始最小SSE为无穷大
for i in range(len(centList)): # 对于质心的列表中的每一个簇
ptsInCurrCluster = dataSet[np.nonzero(clusterAssment[:, 0].A == i)[0], :] # 找到簇索引为i的行,其值保存在ptsInCurrCluster
centroidMat, splitClustAss = kMeans(ptsInCurrCluster, 2) # 对这个簇进行k=2的聚类
sseSplit = np.sum(splitClustAss[:, 1]) # 这个簇二分后,新的SSE值
sseNotSplit = np.sum(clusterAssment[np.nonzero(clusterAssment[:, 0].A != i)[0], 1]) # 除了该簇的其他簇的SSE值
print("新簇二分后 sseSplit = " + str(sseSplit))
print("原先不含该簇 sseNotSplit = " + str(sseNotSplit))
if (sseSplit + sseNotSplit) < lowestSSE: # 如果二分后,SSE值小于目前的最小值
bestCentToSplit = i # 最应该被二分的簇的索引为i
bestNewCents = centroidMat # 新的质心
bestClustAss = splitClustAss.copy() # 新的点矩阵,第一列为索引,第二列为距离 [0, x] [1, y] ...
lowestSSE = sseSplit + sseNotSplit # 更新最小SSE
# 到目前为止,已有一个最佳的簇等待被分类,接下来开始实行分类
bestClustAss[np.nonzero(bestClustAss[:, 0].A == 0)[0], 0] = bestCentToSplit # 将索引值为0的改为当前被二分的簇的索引
bestClustAss[np.nonzero(bestClustAss[:, 0].A == 1)[0], 0] = len(centList) # 将索引值为1的改为centList的长度(实际就是新赋一个值)
print("最佳二分的簇索引为:%d" % bestCentToSplit)
print("该簇共有%d行" % len(bestClustAss))
print('*****************************************************************')
centList[bestCentToSplit] = bestNewCents[0, :] # 将索引值为i的质心加入列表
centList.append(bestNewCents[1, :]) # 将另一个质心加入列表
clusterAssment[np.nonzero(clusterAssment[:, 0].A == bestCentToSplit)[0], :] = bestClustAss # 更新点矩阵
print(centList)
return centList, clusterAssment
运行 main 函数:
if __name__ == '__main__':
data = np.mat(loadDataSet('testSet2.txt'))
biKMeans(data, 3)
结果如下图所示:
用该代码跑第一次的数据集 testSet.txt
,发现结果与之前相同:
最后
- 书上引进包的形式为
from numpy import *
,而我使用的是import numpy as np
,从而使得所有numpy的函数都需要加一个np.
,这样做一来是自己希望能够熟悉 numpy 的运算函数,另一方面,也看到博文说不建议使用前者,因为会与内置的函数混淆,例如numpy.sum
和sum
使用会有小小差别。 - 最后二分法我的运算结果与书上不太一致,差了一点点。我下载下了作者提供的源代码,运算结果和我目前的一样,不太清楚作者是否更改了数据集,还是我自身代码有误,还望各位指点。