『ML』利用K-Means聚类算法对未标注数据分组——《机器学习实战》学习笔记(Ch10)

本节用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 * 20 矩阵,第一列存放的是该点所在簇的索引值,第二列存放该点与簇质心的距离。
  • 接下来创建创建一个标志变量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])  # 返回第一行即它们所在的行数

效果如下:
在这里插入图片描述
第一个是判断,返回的是 TrueFalse ,我们发现,第一列为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.sumsum使用会有小小差别。
  • 最后二分法我的运算结果与书上不太一致,差了一点点。我下载下了作者提供的源代码,运算结果和我目前的一样,不太清楚作者是否更改了数据集,还是我自身代码有误,还望各位指点。
发布了8 篇原创文章 · 获赞 0 · 访问量 617

猜你喜欢

转载自blog.csdn.net/qq_42491242/article/details/105215679
今日推荐