Machine Learning in Action 读书笔记
第10章 利用k-均值聚类算法对未标注数据分组
文章目录
一、聚类
前面学到的监督学习可以大致分为两类:分类和回归。本章开始学习无监督学习,所谓的无监督学习是指事先不知道要寻找的内容,即没有目标变量。
聚类(Clustering)是一种无监督的学习方法,聚类将数据点归到多个簇中,其中相似数据点处于同一簇,而不相似数据点处于不同簇中。本章要学习的一种称为k-均值(K-means)聚类的算法。k-均值聚类算法可以发现k个不同的簇,且每个簇的中心采用簇中所含值的均值计算而成。
簇识别(cluster identification) 可以给出聚类结果的含义。
聚类和分类的最大不同在于,分类的目标事先已知,而聚类则未知,因此,聚类有时也被称为无监督分类(unsupervised classification)
二、K-均值聚类算法
- 优点:容易实现
- 缺点:可能收敛到局部最小值,在大规模数据集上收敛较慢
- 适用数据类型:数值型数据
k-均值是发现给定数据集的k个簇的算法,k由用户给定,每一个簇通过其质心(centroid),即簇中所有点的中心来描述。
1.K-均值算法的工作流程
- 随机确定k个初始点作为质心
- 将数据集中的每个点分配到一个簇中,即为每个点找距其最近的质心,并将其分配该质心对应的簇
- 每个簇的质心更新为该簇所有点的平均值
2.K-均值聚类的一般流程
- 收集数据:适用任意方法
- 准备数据:需要数值型数据来计算距离,也可以将标称型数据映射为二值型数据再用于距离计算
- 分析数据:使用任意方法
- 训练数据:不适应于无监督学习,即无监督学习没有训练过程
- 测试数据:应用聚类算法、观察结果。可以使用量化的误差指标如误差平方和来评价算法的结果
- 使用算法:可以用于所希望的任何应用。通常情况下,簇质心可以代表整个簇的数据来做出决策。
'''k-均值聚类算法的支持(辅助)函数'''
def loadDataSet(fileName):
dataMat = []
fr = open(fileName)
for line in fr.readlines():
curLine = line.strip().split('\t')
fltLine = list(map(float, curLine))
dataMat.append(fltLine)
return dataMat
# distEclud函数用于计算两个向量的欧式距离
def distEclud(vecA, vecB):
return sqrt(sum(power(vecA - vecB, 2)))
# randCent为给定数据集构建一个包含k个随机质心的集合(随机质心必须要在整个数据集的边界之内)
def randCent(dataSet, k): # k为设置质心的数目
n = shape(dataSet)[1] # 2
centroids = mat(zeros((k, n)))# 构建一个质心矩阵 4*2
for j in range(n):#创建随机聚类质心
minJ = min(dataSet[:,j])
rangeJ = float(max(dataSet[:,j]) - minJ)
centroids[:,j] = mat(minJ + rangeJ * random.rand(k,1)) # random.rand(k,1)创建一个k行1列的矩阵,值在0到1之间
return centroids
'''k-均值聚类算法'''
# 计算质心-分配-重新计算质心(迭代执行)
def kMeans(dataSet, k, distMeas=distEclud, createCent=randCent): # k表示簇的数目; 四个参数中数据集和k是必选参数,用来计算距离和创建初始质心 的函数是可选的
m = shape(dataSet)[0] # 输出数据集中数据点的总数
clusterAssment = mat(zeros((m,2)))#创建一个矩阵,用于存储每个点的簇分配结果,包含两列:第一列记录簇索引值,第二列存储误差(误差是指当前点到簇质心的距离,后边使用该误差来评价聚类的效果)
centroids = createCent(dataSet, k)
clusterChanged = True # 该值为true,就继续迭代,该值为false表示所有数据点的簇分配结果不再改变,停止迭代
while clusterChanged:
clusterChanged = False
for i in range(m):#为每个数据点分配最近质心
minDist = inf; minIndex = -1
for j in range(k): # 寻找最近的质心:对每个点遍历所有质心,后计算点到每个质心的距离
distJI = distMeas(centroids[j,:],dataSet[i,:]) # 计算距离,默认距离函数是distEclud()
if distJI < minDist:
minDist = distJI; minIndex = j
if clusterAssment[i,0] != minIndex: clusterChanged = True # 如果任一点的簇分配结果发生改变,则更新clusterChanged标志
clusterAssment[i,:] = minIndex,minDist**2
# print(centroids)
for cent in range(k):# 遍历所有质心并更新它们的取值
ptsInClust = dataSet[nonzero(clusterAssment[:,0].A==cent)[0]]#通过数组过滤来获得给定簇的所有点
centroids[cent,:] = mean(ptsInClust, axis=0) #计算所有点的均值,axis=0表示沿矩阵的列方向进行均值计算
return centroids, clusterAssment # 返回所有的类质心与点分配结果
三、使用后处理来提高聚类性能
在上面k-均值聚类算法中,返回值包含两部分,质心和簇的分配结果,其中簇的分配结果有两列,第一列是该数据点所属的簇,第二列为该数据点到质心的距离平方值,即每个点的误差。利用该误差可以来评价聚类质量,即k值选择的是否合适或者簇分的是否好。
K-均值算法收敛但聚类效果较差的原因是:K-均值算法收敛到了局部最小值,而非全局最小值(局部最小值指结果还可以但并非最好结果,全局最小值是可能的最好结果)。
一种用于度量聚类效果的指标是SSE(sum of squared error,误差平方和)。SSE值越小表示数据点越接近于它们的质心,聚类效果也越好。
一种肯定可以降低SSE值的方法是增加簇的个数,但这违背了聚类的目标。聚类的目标是在保持簇数目不变的情况下提高簇的质量。
可以通过对生成的簇进行后处理来改进,后处理的方法有:
- 将具有最大SSE值的簇划分成两个簇。
遇到高维数据,有两种量化方法:
- 合并最近质心
- 合并两个使得SSE增幅最小的质心
四、二分K-均值算法
二分K-均值算法可以克服K-均值算法收敛于局部最小值的问题。
伪代码如下:
将所有点看成一个簇
当簇数目小于k时
对于每个簇
计算总误差
在给定的簇上面进行K-均值聚类(k=2)
计算将该簇一分为二之后的总误差
选择使得误差最小的那个簇进行划分操作
'''二分k-均值聚类算法'''
def biKmeans(dataSet, k, distMeas=distEclud): # 给定的数据集、所期望的簇数目、距离计算方法
m = shape(dataSet)[0] # 数据点的数目
clusterAssment = mat(zeros((m,2))) # 使用矩阵存储数据集中每个点的簇分配结果及平方误差
centroid0 = mean(dataSet, axis=0).tolist()[0]# 计算整个数据集的质心
centList =[centroid0] #使用列表来保存所有质心
for j in range(m):#计算每个点到质心的误差值
clusterAssment[j,1] = distMeas(mat(centroid0), dataSet[j,:])**2
while (len(centList) < k): # while循环会不停的对簇进行划分,直到得到想要的簇数目为止
lowestSSE = inf # SSE,sum of squared error,误差平方和,用于度量聚类效果的指标
for i in range(len(centList)):
ptsInCurrCluster = dataSet[nonzero(clusterAssment[:,0].A==i)[0],:]#获得聚类中的当前数据点
centroidMat, splitClustAss = kMeans(ptsInCurrCluster, 2, distMeas) # 对当前数据点进行二分类
sseSplit = sum(splitClustAss[:,1])#计算总误差
sseNotSplit = sum(clusterAssment[nonzero(clusterAssment[:,0].A!=i)[0],1]) # 计算剩余数据集的误差
print("sseSplit, and notSplit: ",sseSplit,sseNotSplit)
if (sseSplit + sseNotSplit) < lowestSSE: # 使用分类后的误差和剩余数据集的误差和作为本次划分的误差,与最小误差进行比较
bestCentToSplit = i
bestNewCents = centroidMat
bestClustAss = splitClustAss.copy()
lowestSSE = sseSplit + sseNotSplit # 使用分类后的误差和剩余数据集的误差和作为本次划分的误差
bestClustAss[nonzero(bestClustAss[:,0].A == 1)[0],0] = len(centList) #通过两个数组过滤器将这些簇编号需改为划分簇及新加簇的编号
bestClustAss[nonzero(bestClustAss[:,0].A == 0)[0],0] = bestCentToSplit
print('the bestCentToSplit is: ',bestCentToSplit)
print('the len of bestClustAss is: ', len(bestClustAss))
centList[bestCentToSplit] = bestNewCents[0,:].tolist()[0]#用两个最好的质心代替当前质心
centList.append(bestNewCents[1,:].tolist()[0])
clusterAssment[nonzero(clusterAssment[:,0].A == bestCentToSplit)[0],:]= bestClustAss#重新分配新的簇和误差
return mat(centList), clusterAssment
五、示例:对地图上的点进行聚类
获得数据places.txt:
'''示例:对地图上的点进行聚类'''
import urllib
import urllib.request
import json
def geoGrab(stAddress, city): # 街道地址和城市
apiStem = 'http://where.yahooapis.com/geocode?' #create a dict and constants for the goecoder
params = {}
params['flags'] = 'J'#JSON return type
params['appid'] = 'aaa0VN6k'
params['location'] = '%s %s' % (stAddress, city)
url_params = urllib.parse.urlencode(params) #urlencode函数将创建的字典转换为可以通过URL进行传递的字符串格式
yahooApi = apiStem + url_params #print url_params
# print(yahooApi) #http://where.yahooapis.com/geocode?flags=J&appid=aaa0VN6k&location=1+VA+Center+Augusta%2C+ME
c=urllib.request.urlopen(yahooApi)
return json.loads(c.read()) # 到这里也就意味着成功的对一个地址进行了地理编码
from time import sleep
def massPlaceFind(fileName): # 为了生成文件places.txt
fw = open('places.txt', 'w')
for line in open(fileName).readlines():
line = line.strip()
lineArr = line.split('\t')
retDict = geoGrab(lineArr[1], lineArr[2])
if retDict['ResultSet']['Error'] == 0:
lat = float(retDict['ResultSet']['Results'][0]['latitude'])
lng = float(retDict['ResultSet']['Results'][0]['longitude'])
print("%s\t%f\t%f" % (lineArr[0], lat, lng))
fw.write('%s\t%f\t%f\n' % (line, lat, lng))
else: print("error fetching")
sleep(1) # 为了确保在短时间内过于频繁地调用API
fw.close()
对地理坐标进行聚类,并绘图:
'''球面距离计算'''
def distSLC(vecA, vecB):#返回地球表面两点之间的距离,单位是英里
a = sin(vecA[0,1]*pi/180) * sin(vecB[0,1]*pi/180)
b = cos(vecA[0,1]*pi/180) * cos(vecB[0,1]*pi/180) * \
cos(pi * (vecB[0,0]-vecA[0,0]) /180)
return arccos(a + b)*6371.0 # 球面余弦定理
'''簇绘图函数'''
import matplotlib
import matplotlib.pyplot as plt
def clusterClubs(numClust=5): # 将文本文件中的俱乐部进行聚类并画出结果,参数为所希望得到的簇数目
datList = []
for line in open('places.txt').readlines():
lineArr = line.split('\t')
datList.append([float(lineArr[4]), float(lineArr[3])])
datMat = mat(datList)
myCentroids, clustAssing = biKmeans(datMat, numClust, distMeas=distSLC) # 聚类
fig = plt.figure()
rect=[0.1,0.1,0.8,0.8]
scatterMarkers=['s', 'o', '^', '8', 'p', \
'd', 'v', 'h', '>', '<'] # 构建一个标记形状的列表,用于绘制散点图
axprops = dict(xticks=[], yticks=[])
ax0=fig.add_axes(rect, label='ax0', **axprops)
imgP = plt.imread('Portland.png') # 使用imread()函数基于一副图像来创建矩阵
ax0.imshow(imgP) # 使用imshow()函数绘制上面矩阵
ax1=fig.add_axes(rect, label='ax1', frameon=False) # 在同一幅图上绘制一张新的图,允许使用两套坐标系统并且不做任何缩放或偏移
for i in range(numClust): # 遍历每一个簇,并将它们一一画出来
ptsInCurrCluster = datMat[nonzero(clustAssing[:,0].A==i)[0],:]
markerStyle = scatterMarkers[i % len(scatterMarkers)] # 标记类型从上面创建的标记列表中获得,使用索引i % len(scatterMarkers)来选择标记形状
ax1.scatter(ptsInCurrCluster[:,0].flatten().A[0], ptsInCurrCluster[:,1].flatten().A[0], marker=markerStyle, s=90)
ax1.scatter(myCentroids[:,0].flatten().A[0], myCentroids[:,1].flatten().A[0], marker='+', s=300) # 使用十字标记来表示簇中心并在图中显示
plt.show()
当k = 4:
当k = 5:
当k = 6:
可以看出k=5时效果会好一些。
六、全部代码
from numpy import *
'''k-均值聚类算法的支持(辅助)函数'''
def loadDataSet(fileName):
dataMat = []
fr = open(fileName)
for line in fr.readlines():
curLine = line.strip().split('\t')
fltLine = list(map(float, curLine))
dataMat.append(fltLine)
return dataMat
# distEclud函数用于计算两个向量的欧式距离
def distEclud(vecA, vecB):
return sqrt(sum(power(vecA - vecB, 2)))
# randCent为给定数据集构建一个包含k个随机质心的集合(随机质心必须要在整个数据集的边界之内)
def randCent(dataSet, k): # k为设置质心的数目
n = shape(dataSet)[1] # 2
centroids = mat(zeros((k, n)))# 构建一个质心矩阵 4*2
for j in range(n):#创建随机聚类质心
minJ = min(dataSet[:,j])
rangeJ = float(max(dataSet[:,j]) - minJ)
centroids[:,j] = mat(minJ + rangeJ * random.rand(k,1)) # random.rand(k,1)创建一个k行1列的矩阵,值在0到1之间
return centroids
'''k-均值聚类算法'''
# 计算质心-分配-重新计算质心(迭代执行)
def kMeans(dataSet, k, distMeas=distEclud, createCent=randCent): # k表示簇的数目; 四个参数中数据集和k是必选参数,用来计算距离和创建初始质心 的函数是可选的
m = shape(dataSet)[0] # 输出数据集中数据点的总数
clusterAssment = mat(zeros((m,2)))#创建一个矩阵,用于存储每个点的簇分配结果,包含两列:第一列记录簇索引值,第二列存储误差(误差是指当前点到簇质心的距离,后边使用该误差来评价聚类的效果)
centroids = createCent(dataSet, k)
clusterChanged = True # 该值为true,就继续迭代,该值为false表示所有数据点的簇分配结果不再改变,停止迭代
while clusterChanged:
clusterChanged = False
for i in range(m):#为每个数据点分配最近质心
minDist = inf; minIndex = -1
for j in range(k): # 寻找最近的质心:对每个点遍历所有质心,后计算点到每个质心的距离
distJI = distMeas(centroids[j,:],dataSet[i,:]) # 计算距离,默认距离函数是distEclud()
if distJI < minDist:
minDist = distJI; minIndex = j
if clusterAssment[i,0] != minIndex: clusterChanged = True # 如果任一点的簇分配结果发生改变,则更新clusterChanged标志
clusterAssment[i,:] = minIndex,minDist**2
# print(centroids)
for cent in range(k):# 遍历所有质心并更新它们的取值
ptsInClust = dataSet[nonzero(clusterAssment[:,0].A==cent)[0]]#通过数组过滤来获得给定簇的所有点
centroids[cent,:] = mean(ptsInClust, axis=0) #计算所有点的均值,axis=0表示沿矩阵的列方向进行均值计算
return centroids, clusterAssment # 返回所有的类质心与点分配结果
'''二分k-均值聚类算法'''
def biKmeans(dataSet, k, distMeas=distEclud): # 给定的数据集、所期望的簇数目、距离计算方法
m = shape(dataSet)[0] # 数据点的数目
clusterAssment = mat(zeros((m,2))) # 使用矩阵存储数据集中每个点的簇分配结果及平方误差
centroid0 = mean(dataSet, axis=0).tolist()[0]# 计算整个数据集的质心
centList =[centroid0] #使用列表来保存所有质心
for j in range(m):#计算每个点到质心的误差值
clusterAssment[j,1] = distMeas(mat(centroid0), dataSet[j,:])**2
while (len(centList) < k): # while循环会不停的对簇进行划分,直到得到想要的簇数目为止
lowestSSE = inf # SSE,sum of squared error,误差平方和,用于度量聚类效果的指标
for i in range(len(centList)):
ptsInCurrCluster = dataSet[nonzero(clusterAssment[:,0].A==i)[0],:]#获得聚类中的当前数据点
centroidMat, splitClustAss = kMeans(ptsInCurrCluster, 2, distMeas) # 对当前数据点进行二分类
sseSplit = sum(splitClustAss[:,1])#计算总误差
sseNotSplit = sum(clusterAssment[nonzero(clusterAssment[:,0].A!=i)[0],1]) # 计算剩余数据集的误差
print("sseSplit, and notSplit: ",sseSplit,sseNotSplit)
if (sseSplit + sseNotSplit) < lowestSSE: # 使用分类后的误差和剩余数据集的误差和作为本次划分的误差,与最小误差进行比较
bestCentToSplit = i
bestNewCents = centroidMat
bestClustAss = splitClustAss.copy()
lowestSSE = sseSplit + sseNotSplit # 使用分类后的误差和剩余数据集的误差和作为本次划分的误差
bestClustAss[nonzero(bestClustAss[:,0].A == 1)[0],0] = len(centList) #通过两个数组过滤器将这些簇编号需改为划分簇及新加簇的编号
bestClustAss[nonzero(bestClustAss[:,0].A == 0)[0],0] = bestCentToSplit
print('the bestCentToSplit is: ',bestCentToSplit)
print('the len of bestClustAss is: ', len(bestClustAss))
centList[bestCentToSplit] = bestNewCents[0,:].tolist()[0]#用两个最好的质心代替当前质心
centList.append(bestNewCents[1,:].tolist()[0])
clusterAssment[nonzero(clusterAssment[:,0].A == bestCentToSplit)[0],:]= bestClustAss#重新分配新的簇和误差
return mat(centList), clusterAssment
'''示例:对地图上的点进行聚类'''
import urllib
import urllib.request
import json
def geoGrab(stAddress, city): # 街道地址和城市
apiStem = 'http://where.yahooapis.com/geocode?' #create a dict and constants for the goecoder
params = {}
params['flags'] = 'J'#JSON return type
params['appid'] = 'aaa0VN6k'
params['location'] = '%s %s' % (stAddress, city)
url_params = urllib.parse.urlencode(params) #urlencode函数将创建的字典转换为可以通过URL进行传递的字符串格式
yahooApi = apiStem + url_params #print url_params
# print(yahooApi) #http://where.yahooapis.com/geocode?flags=J&appid=aaa0VN6k&location=1+VA+Center+Augusta%2C+ME
c=urllib.request.urlopen(yahooApi)
return json.loads(c.read()) # 到这里也就意味着成功的对一个地址进行了地理编码
from time import sleep
def massPlaceFind(fileName): # 为了生成文件places.txt
fw = open('places.txt', 'w')
for line in open(fileName).readlines():
line = line.strip()
lineArr = line.split('\t')
retDict = geoGrab(lineArr[1], lineArr[2])
if retDict['ResultSet']['Error'] == 0:
lat = float(retDict['ResultSet']['Results'][0]['latitude'])
lng = float(retDict['ResultSet']['Results'][0]['longitude'])
print("%s\t%f\t%f" % (lineArr[0], lat, lng))
fw.write('%s\t%f\t%f\n' % (line, lat, lng))
else: print("error fetching")
sleep(1) # 为了确保在短时间内过于频繁地调用API
fw.close()
'''球面距离计算'''
def distSLC(vecA, vecB):#返回地球表面两点之间的距离,单位是英里
a = sin(vecA[0,1]*pi/180) * sin(vecB[0,1]*pi/180)
b = cos(vecA[0,1]*pi/180) * cos(vecB[0,1]*pi/180) * \
cos(pi * (vecB[0,0]-vecA[0,0]) /180)
return arccos(a + b)*6371.0 # 球面余弦定理
'''簇绘图函数'''
import matplotlib
import matplotlib.pyplot as plt
def clusterClubs(numClust=5): # 将文本文件中的俱乐部进行聚类并画出结果,参数为所希望得到的簇数目
datList = []
for line in open('places.txt').readlines():
lineArr = line.split('\t')
datList.append([float(lineArr[4]), float(lineArr[3])])
datMat = mat(datList)
myCentroids, clustAssing = biKmeans(datMat, numClust, distMeas=distSLC) # 聚类
fig = plt.figure()
rect=[0.1,0.1,0.8,0.8]
scatterMarkers=['s', 'o', '^', '8', 'p', \
'd', 'v', 'h', '>', '<'] # 构建一个标记形状的列表,用于绘制散点图
axprops = dict(xticks=[], yticks=[])
ax0=fig.add_axes(rect, label='ax0', **axprops)
imgP = plt.imread('Portland.png') # 使用imread()函数基于一副图像来创建矩阵
ax0.imshow(imgP) # 使用imshow()函数绘制上面矩阵
ax1=fig.add_axes(rect, label='ax1', frameon=False) # 在同一幅图上绘制一张新的图,允许使用两套坐标系统并且不做任何缩放或偏移
for i in range(numClust): # 遍历每一个簇,并将它们一一画出来
ptsInCurrCluster = datMat[nonzero(clustAssing[:,0].A==i)[0],:]
markerStyle = scatterMarkers[i % len(scatterMarkers)] # 标记类型从上面创建的标记列表中获得,使用索引i % len(scatterMarkers)来选择标记形状
ax1.scatter(ptsInCurrCluster[:,0].flatten().A[0], ptsInCurrCluster[:,1].flatten().A[0], marker=markerStyle, s=90)
ax1.scatter(myCentroids[:,0].flatten().A[0], myCentroids[:,1].flatten().A[0], marker='+', s=300) # 使用十字标记来表示簇中心并在图中显示
plt.show()
if __name__ == '__main__':
datMat = mat(loadDataSet('testSet.txt'))
# print(datMat)
# print(min(datMat[:, 0])) #[[-5.379713]]
# print(min(datMat[:, 1])) #[[-4.232586]]
# print(max(datMat[:, 0])) #[[4.838138]]
# print(max(datMat[:, 1])) #[[5.1904]]
randNum = randCent(datMat, 2)
# print(randNum) #[[-1.9276751 1.5013302 ]
#[ 3.67862177 -3.75580585]]
dis = distEclud(datMat[0], datMat[1])
# print(dis) #5.184632816681332
myCentroids, clustAssing = kMeans(datMat, 4)
# print(myCentroids) # 所有的质心
# print('==============')
# print(clustAssing) # 点分配结果,第一列为簇索引值(0-3),第二列为点到质心的误差
datMat3 = mat(loadDataSet('testSet2.txt'))
# centList, myNewAssments = biKmeans(datMat3, 3)
# print(centList)
# geoResults = geoGrab('1 VA Center', 'Augusta, ME')
# print(geoResults)
clusterClubs(6)