目录
参考:
概述
本博文通过学习《全面解析Kmeans聚类(Python) · Issue #42 · aialgorithm/Blog · GitHub》中的Kmeans算法以及其中的一个程序,详细描述了Kmean聚类算法的实现细节。通过这个学习,可以更好地理解Kmeans算法。在学习过程中,也大量参考了《Kmeans++聚类算法原理与实现 - 知乎 (zhihu.com)》这篇文章
Kmeans算法原理简介
Kmeans算法主要用于无监督学习中的聚类,即将一堆数据按照其数据特征分类。通过分类,我们有可能发现这些数据中隐含的更深层次的意义,对于分析大量的实际数据是十分有益的。
Kmeans算法的主要思想是:每一个数据都由若干特征组成,这些特征之间存在差异,我们将这些差异定义为一种“距离”。于是,同一类的数据之间,必然距离较近,而不同类的数据之间,其距离较远。通过不断地迭代,不断地将类别划开。
Kmeans算法主要步骤为:
- 随机选择 k 个样本作为初始簇类中心(k为超参,代表簇类的个数。可以凭先验知识、验证法确定取值);
- 针对数据集中每个样本 计算它到 k 个簇类中心的距离,并将其归属到距离最小的簇类中心所对应的类中;
- 针对每个簇类,重新计算它的簇类中心位置;
- 重复迭代上面 2 、3 两步操作,直到达到某个终止条件(如迭代次数,簇类中心位置不变等)。
Kmeans算法的缺点在于,起始的k个族类中心点是随机选取,其初始情况可能并不理想。为了解决这一问题,引入了Kmeans++算法。
Kmeans++算法的思想为:在选取初始族类中心点时,只有第一个点是随机选择,其后的点计算剩余样本点和前面已选的中心点之间的距离,找出距离最远的点作为下一个初始族类中心点。初始中心点选取之后,剩下的步骤和Kmeans算法一样。
第二篇参考文章(《Kmeans++聚类算法原理与实现 - 知乎 (zhihu.com)》)中给出了一个计算示例,我觉得非常通俗易懂,如果不理解Kmeans++算法的,建议在这里暂停一下,先看看这篇参考文章中的计算示例。
实战问题
本文剩下的篇幅,将通过第一篇参考文章(《全面解析Kmeans聚类(Python) · Issue #42 · aialgorithm/Blog · GitHub》)中的一个“对不同种类的狗进行分类”的具体问题,来逐步地详细地讲解Kmeans算法。
个人觉得原文中的Python程序不是很好理解,因此我基于原文中Python程序,重新写了一份Python程序。
有如下一份不同种类的狗的一些特征数据,存放于文件“dogs.csv.txt”中,其内容如下:
breed,height (inches),weight (pounds)
Border Collie,20,45
Boston Terrier,16,20
Brittany Spaniel,18,35
Bullmastiff,27,120
Chihuahua,8,8
German Shepherd,25,78
Golden Retriever,23,70
Great Dane,32,160
Portuguese Water Dog,21,50
Standard Poodle,19,65
Yorkshire Terrier,6,7
文件中:
- 第1行是标题行,其余是数据内容
- 数据第1列是狗的品种名称,第2列是这个品种的狗的平均身高,第3列是这个品种的狗的平均体重
目标是通过对狗的身高,体重数据,将这些不同品种的狗分类。
问题分析
首先,容易想到的是,这个问题可以分为读取数据(load data)和算法分类这两个阶段。
其次,注意到数据中有3列,但第1列(breed)数据其实并不是Kmeans算法中涉及的“特征”数据,即在计算数据之间的距离时,我们不需要这一列数据。同时,这一列数据显然也不是Kmeans算法中需要标记的种类。这一列数据可以看作是某个具体样本的名称或者代号。因此,在Kmeans算法中,不建议输入这列数据。
但是,当Kmeans分类结束后,在某一类中,我们希望在这个类中出现的就是这一列数据(比如在某一类中,其内容为:'Bullmastiff', 'Great Dane')。这么一看,第1列(breed)数据似乎应该出现在Kmeans算法中。对于这一矛盾,我的理解是,这一列数据不输入给Kmeans算法,即Kmeans算法只负责将具体的一个个特征数据(height, weight)放入某一个分类中,而不用关心这个数据是叫'Bullmastiff',还是叫'Great Dane',以此保持Kmeans算法的通用性;而在算法结束时,在算法外围,根据算法的结果最终处理聚类和第1列(breed)数据之间的关系。我认为这样的处理更加合理。
因此,程序划分为3个阶段:
- 读取数据
- Kmean算法分类
- 根据Kmean算法的分类结果,整理问题的最终结果,并输出
读取数据
定义数据结构
个人喜欢用类或者结构体来表示数据,而不喜欢看起来比较抽象的词典数据。尤其是词典中嵌套词典,这种数据结构写出来的程序很难读懂。因此,我首先定义了以下纯数据类
class Dog:
def __init__(self, breed, height, weight):
self.breed = breed
self.height = height
self.weight = weight
class Dogs:
def __init__(self, num, dogs_data):
self.num = num
self.dogs_data = dogs_data
说明:
- dogs_data是一个以Dog对象为元素的列表
读取原始文件
先将文件中的数据,读取到一个“表格”中,这里的表格就是一个二维的数据结构,第一维按行划分,第二维按照','划分。
def read_csv(filename, row_offset = 1):
"""
read csv files, return strings sequences,
each line is splitted by ','
"""
out = []
with open(filename, 'r') as f:
data = f.readlines()
for i in range(row_offset, len(data)):
line = data[i].replace('\n', '').split(',')
out.append(line)
print("{} read, out = {}".format(filename, out))
return out
说明:
- 从文件中读取的每一行字符串是包含换行符'\n'的,因此data[i]需要先去掉换行符,即"replace('\n', '')",表示将读取到的换行符替换为空字符串。
- 对于去掉换行符后的字符串,按','分隔为字串,即"split(',')"。
- 用c语言的表述,输出的数据是一个字符串类型的二维数组
组织数据
将文件中读取的原始数据(一个字符串类型的二维数组),按照前面定义的数据结构,组织为后面算法需要的数据
def load_dogs(filename):
"""
read dogs data from csv file
"""
raw = read_csv(filename)
num = len(raw)
dogs_data = []
for item in raw:
dog = Dog(item[0], int(item[1]), int(item[2]))
dogs_data.append(dog)
dogs = Dogs(num, dogs_data)
return dogs
说明:
- 对于第2列和第3列数据,原始数据中是字符串,因此,这里使用了int()将其转化为整数类型。Python的内置类型int, str, float,都可以使用后跟()的方式作类型转换
Kmeans算法分类
Kmeans算法的实现和参考1一样,使用了Python的类。关于Python类的使用,在Python教程学习 (6)中有较为详细的介绍。在使用Python类的时候,需要注意以下几点:
- 类的成员变量一般在__init__()函数中显示定义并初始化
- 在类的成员(包括函数和变量)中,如果使用了任何类的其它成员(包括函数和变量),都需要加上‘self.’,否则会报错。这点对经常使用C++类的人来说,一开始可能会有点不习惯。
- 所有成员函数的第一个参数都是self,并且必须显示地写出来
按照前面Kmeans算法原理的描述,这段程序可以大致分为以下几个步骤:
- 初始化,选取k个中心点。这里应该支持传统的Kmeans方式,即‘random’:随机选取,也应该支持Kmeans++的方式,即'kmeans++'。并且默认为'kmeans++'(这里参考了sklearn的KMeans的实现方式,详见sklearn.cluster.KMeans — scikit-learn 1.3.2 documentation)。
- 根据当前中心点,基于每个样本数据和中心点的距离,对每个样本数据进行分类。这里的距离,我们采用了和参考1一样的方式,即曼哈顿距离。关于曼哈顿距离的定义,详见参考1。
- 根据当前分类结果,重新计算每个类的新的中心点。
- 重复步骤2,3,直到新的中心点和上一次的中心点一致为止
注意:本节中所有代码都属于类MyKmeans中。
类的数据定义及初始化
首先需要定义类的成员变量,以及初始化函数。实际上,在程序实现过程中,一开始只能从类的输入参数来考虑类的成员,无法考虑到中间参数的成员,输出参数可能也只能考虑到一个最基本的输出。随着程序的实现,类的成员是可以调整的。
下面代码是最终的实现。
class MyKmeans:
def __init__(self, features, n_clusters, *, init='kmeans++', max_iter=10, tol=0.0001):
self.data = features
self.dim = len(features[0])
self.k = n_clusters
self.init = init
self.iters = max_iter
self.tol = tol
self.centers = self.init_centers(self.k)
self.targets = [-1 for i in range(len(self.data))] # sequence corresponding to each item of self.data, with value in {0,1,...,k-1}
self.distances = [0 for i in range(len(self.data))]
其实,在程序设计的开始,只包括了以下参数:
- data(需要分类的数据),k(分类个数),init(算法类型,传统kmeans或者kmeans++),iters(最大迭代次数),tol(迭代差异的接受值)。这些是从输入参数的角度考虑的。其中,tol用于结束迭代、实际上没有用到,这个参数是参考的sklearn的KMeans实现设计的。
- centers(中心点)。既是中间参数,也是输出结果之一
- targets(每个样本数据的分类结果)。输出结果之一
在开发过程中,增加了以下参数:
- dim(样本参数的维度)。在本例中即height, weight两个维度。用于中间参数。
- distances(每个样本距离中心的距离)。输出结果之一。
个人感觉,其实还可以增加一个中间参数:
- data_num(样本参数的个数)。用于中间参数。接下来的实现中,会看到程序中经常使用len(self.data)。
计算初始中心点
前面提到,支持两种方式获取初始中心点:
- 'random':从样本数据中随机选择k个、作为中心点
- 'kmeans++':从样本数据中随机选择1个作为第一个中心点,剩余(k-1)个中心点、选择和前面中心点距离最远的点。
下面看具体的程序:
def init_centers(self, k):
# set a interger for seed to make sure each run get the same samples
random.seed(4)
# if 'random', choose k samples from data randomly as the centers
if self.init == 'random':
centers = [x for x in random.sample(self.data, k)]
# if 'kmeans++', choose centers based on the distance
else:
# 1. choose first center randomly
centers = []
centers.append(random.choice(self.data))
# 2.choose other centers based on the distance between the already chosen centers
for i in range(1, self.k):
# for each point in data, get the minimum distance between it and the already chosen centers as the weight
weights = [self.get_min_dist(x, centers) for x in self.data]
# choose the data with the max weight, as the next center
tmp = zip(weights, self.data)
tmp = sorted(tmp)
next_center = tmp[len(tmp)-1][1] # the last one is with the max weight
centers.append(next_center)
print("At initialization, centers ={}".format(centers))
return centers
说明:
- random.seed(4)设置了随机数种子。这样做的目的是为了每次运行程序,得到相同的随机序列,从而输出稳定的结果,便于程序开发和调试
- 'centers = [x for x in random.sample(self.data, k)]' 这里使用了python的列表推导式,可以简化循环的形式,迅速获取一个新的列表。'random.sample(self.data, k)'表示从self.data的样本数据集中随机选择k个样本。由于前面设置了随机数种子,因此每次运行程序时,随机选择的样本集合是一样的。
- 在kmeans++分支中,'weights = [self.get_min_dist(x, centers) for x in self.data]' 这里定义了一个函数get_min_dist()用于获取某个样本数据到已有中心点列表centers中的最小距离,这个最小距离会作为选择下一个中心点的权重:找出这个权重中的最大者,对应的点即为下一个中心点,因为这个最大者就是距离已有中心中最远的点(如果不理解这个,可以先看看参考2中的计算示例)。
- ‘tmp = zip(weights, self.data)’ 这行是绑定了权重和样本数据,便于后面对权重排序时,获取这个权重对应的样本数据。注意权重weights要放在前面,这使得调用sorted()函数时,排序的关键字是权重weights。如果不理解zip()函数的用法,可以参考python手册:内置函数 — Python 3.12.1 文档
下面看看get_min_dist()函数的定义:
#get the minimum distance between current point and the already chosen centers
def get_min_dist(self, x, centers):
min = 999999
for i, center in enumerate(centers):
distance = 0
for j in range(self.dim):
distance += abs(x[j] - center[j]) # Manhattan Distance
if distance < min:
min = distance
return min
说明:
- 在for循环中,如果同时想获取循环下标i和每一个循环数据,可以使用类似“for i, center in enumerate(centers):”的形式,十分方便
- 曼哈顿距离就是每个维度之间的差的绝对值,然后再对所有维度求和
分类
分类的思想很简单,在确定了k个中心点之后,对每一个样本数据,查看其距离哪个中心点最近,那么就认为这个样本属于、由这个中心点确立的一个类。
回看一下前面的get_min_dist()函数,我们发现,这个函数实际上正是在实现上述功能,只是缺少了返回中心点的信息。因此,我们将get_min_dist()函数稍作改造,得到新的函数get_nearest_dist_and_center()
# for current point x, get the distance between it and the nearest center, and also get the index of the nearest center
def get_nearest_dist_and_center(self, x, centers):
min = 999999
center_id = -1
for i, center in enumerate(centers):
distance = 0
for j in range(self.dim):
distance += abs(x[j] - center[j]) # Manhattan Distance
if distance < min:
min = distance
center_id = i
return min, center_id
分类函数调用get_nearest_dist_and_center()函数
# clustering current data based on current centers based on distances
def clustering_based_on_distance(self):
for i, data in enumerate(self.data):
self.distances[i], self.targets[i] = self.get_nearest_dist_and_center(data, self.centers)
return None
说明:
- get_nearest_dist_and_center()返回了多个参数,彼此可以用逗号隔开,python会将其当成一个元组结构
- 调用返回元组的函数时,可以用多个参数分别对应元组的每个元素,正如clustering_based_on_distance()函数中的方式;也可以用一个元组参数x接收,然后使用x[0], x[1]的方式访问元组的每个元素
- self.distances[i]表示每个样本距离其最近中心的距离,self.targets[i]是每个样本对应的分类索引
计算新的中心
每个样本,如果以其维度为坐标系,以其各个维度的值为坐标,则可以表示为一个多维空间中的一个点。对于当前分类,每个类中都有一些属于自己类的样本。将这些样本在多维空间中对应的点求几何中心,即每个维度的坐标分别求算数平均,就可以得到一个新的中心点。
例如,我们对数据中的“Bullmastiff,27,120”和“Great Dane,32,160”这两个点求几何中心,为:x1 = (27+32)/2 = 29.5, x2 = (120+160)/2 = 140。于是其几何中心坐标为[29.5, 140]。
self.data[i]中存储了每个样本的坐标,self.targets[i] 中存储了当前分类中每个样本所属的类,利用这两个参数可以求得新的中心。
# calculate the new centers
def locate_new_centers(self):
new_centers = [[0.0 for j in range(self.dim)] for i in range(self.k)]
for cluster_id in range(self.k):
# get data belong to current center
data_in_cluster = [self.data[i] for i in range(len(self.data)) if self.targets[i] == cluster_id]
# get the average of all data that belong to current center, as the new center
for j in range(self.dim):
for data in data_in_cluster:
new_centers[cluster_id][j] += data[j]
new_centers[cluster_id][j] /= len(data_in_cluster)
print("new_centers[{}]={}".format(cluster_id, new_centers[cluster_id]))
return new_centers
说明:
- ‘[[0.0 for j in range(self.dim)] for i in range(self.k)]’这里使用了列表推导式的嵌套,相当于一个两层循环。其中,内层括号代表内层循环,外层括号代表外层循环。如果对这个知识点不熟悉的话,可以参考我的博文Python教程学习 (3)中"嵌套的列表推导式"这一节。
- “[self.data[i] for i in range(len(self.data)) if self.targets[i] == cluster_id]”使用了带"if"条件的列表推导式,相当于在for循环里面加了这样一个“if”条件。这一句的作用是将属于某一个类的所有样本数据组成一个新的列表data_in_cluster
- 在得到了某一个类的所有样本数据列表data_in_cluster之后,对其中所有样本的每个维度取算术平均,即得到了新中心点new_centers[cluster_id][j] 。对应代码应该不难理解。
KMeans迭代
在拥有了分类clustering_based_on_distance()和计算新中心locate_new_centers()之后,我们可以很容易地写出迭代函数
def kcluster(self):
#最大迭代次数iters
for i in range(self.iters):
self.clustering_based_on_distance()
new_centers = self.locate_new_centers()
if sorted(self.centers) == sorted(new_centers):
break
else:
self.centers = new_centers
print("----------------Results for iter %d----------------"%(i+1))
print("centers: {}".format(self.centers))
print("targets: {}".format(self.targets))
说明:
- 容易发现,其中一个结束迭代的条件就是新的中心集合和当前中心集合一致,即sorted(self.centers) == sorted(new_centers)
结果输出
为了使用方便,单独定义一个函数clustering_and_results()用于调用,从读取数据、到Kmeans算法分类这一整个过程的所有类和函数。
def clustering_and_results(cluster_num=3):
# load data
dogs = load_dogs(r'ai\dogs.csv.txt')
#data = [[dogs.dogs_data[i].height, dogs.dogs_data[i].weight] for i in range(len(dogs.dogs_data))]
data = [[dog.height, dog.weight] for dog in (dogs.dogs_data)]
# clustering
model = MyKmeans(data, cluster_num)
#model = MyKmeans(data, 3, init='random')
model.kcluster()
# print results
for i in range(cluster_num):
dogs_in_cluster = [dog.breed for dog_id, dog in enumerate(dogs.dogs_data) if model.targets[dog_id] == i]
print("Breeds in Cluster {}: {}".format(i, dogs_in_cluster))
for i in range(cluster_num):
distances_in_cluster = [model.distances[dog_id] for dog_id in range(len(dogs.dogs_data)) if model.targets[dog_id] == i]
print("Distances in Cluster {}: {}".format(i, distances_in_cluster))
return None
说明:
- ‘data = [[dog.height, dog.weight] for dog in (dogs.dogs_data)]’这句中,我们将每一个dog的身高和体重提取出来,组成一个[身高,体重]的列表,所有dog的[身高,体重]数据,组成一个新的列表数据,这个数据就是KMeans算法输入的特征数据。注意到这个写法比前面的“data = [[dogs.dogs_data[i].height, dogs.dogs_data[i].weight] for i in range(len(dogs.dogs_data))]”更加简洁。
- 在获取输出结果时,我们同样使用了列表推导式“[dog.breed for dog_id, dog in enumerate(dogs.dogs_data) if model.targets[dog_id] == i]”。如果你认真看了前面的内容,那么这个列表推导式应该不难理解。
前面所有代码均定义在python文件my_kmeans.py中,我们打开一个python对话框,使用如下方式调用程序
>>> import ai.my_kmeans
>>> ai.my_kmeans.clustering_and_results()
在设置kmeans++算法时,得到如下打印:
ai\dogs.csv.txt read, out = [['Border Collie', '20', '45'], ['Boston Terrier', '16', '20'], ['Brittany Spaniel', '18', '35'], ['Bullmastiff', '27', '120'], ['Chihuahua', '8', '8'], ['German Shepherd', '25', '78'], ['Golden Retriever', '23', '70'], ['Great Dane', '32', '160'], ['Portuguese Water Dog', '21', '50'], ['Standard Poodle', '19', '65'], ['Yorkshire Terrier', '6', '7']]
At initialization, centers =[[27, 120], [6, 7], [19, 65]]
new_centers[0]=[29.5, 140.0]
new_centers[1]=[10.0, 11.666666666666666]
new_centers[2]=[21.0, 57.166666666666664]
----------------Results for iter 1----------------
centers: [[29.5, 140.0], [10.0, 11.666666666666666], [21.0, 57.166666666666664]]
targets: [2, 1, 2, 0, 1, 2, 2, 0, 2, 2, 1]
new_centers[0]=[29.5, 140.0]
new_centers[1]=[10.0, 11.666666666666666]
new_centers[2]=[21.0, 57.166666666666664]
Breeds in Cluster 0: ['Bullmastiff', 'Great Dane']
Breeds in Cluster 1: ['Boston Terrier', 'Chihuahua', 'Yorkshire Terrier']
Breeds in Cluster 2: ['Border Collie', 'Brittany Spaniel', 'German Shepherd', 'Golden Retriever', 'Portuguese Water Dog', 'Standard Poodle']
Distances in Cluster 0: [22.5, 22.5]
Distances in Cluster 1: [14.333333333333334, 5.666666666666666, 8.666666666666666]
Distances in Cluster 2: [13.166666666666664, 25.166666666666664, 24.833333333333336, 14.833333333333336, 7.166666666666664, 9.833333333333336]
可以看到,算法只迭代了一次,得到分类为:
Breeds in Cluster 0: ['Bullmastiff', 'Great Dane']
Breeds in Cluster 1: ['Boston Terrier', 'Chihuahua', 'Yorkshire Terrier']
Breeds in Cluster 2: ['Border Collie', 'Brittany Spaniel', 'German Shepherd', 'Golden Retriever', 'Portuguese Water Dog', 'Standard Poodle']
如果使用'random'方式,修改函数clustering_and_results(),使用"model = MyKmeans(data, 3, init='random')",得到如下输出
ai\dogs.csv.txt read, out = [['Border Collie', '20', '45'], ['Boston Terrier', '16', '20'], ['Brittany Spaniel', '18', '35'], ['Bullmastiff', '27', '120'], ['Chihuahua', '8', '8'], ['German Shepherd', '25', '78'], ['Golden Retriever', '23', '70'], ['Great Dane', '32', '160'], ['Portuguese Water Dog', '21', '50'], ['Standard Poodle', '19', '65'], ['Yorkshire Terrier', '6', '7']]
At initialization, centers =[[27, 120], [8, 8], [16, 20]]
new_centers[0]=[26.75, 107.0]
new_centers[1]=[7.0, 7.5]
new_centers[2]=[18.8, 43.0]
----------------Results for iter 1----------------
centers: [[26.75, 107.0], [7.0, 7.5], [18.8, 43.0]]
targets: [2, 2, 2, 0, 1, 0, 0, 0, 2, 2, 1]
new_centers[0]=[28.0, 119.33333333333333]
new_centers[1]=[10.0, 11.666666666666666]
new_centers[2]=[20.2, 53.0]
----------------Results for iter 2----------------
centers: [[28.0, 119.33333333333333], [10.0, 11.666666666666666], [20.2, 53.0]]
targets: [2, 1, 2, 0, 1, 0, 2, 0, 2, 2, 1]
new_centers[0]=[29.5, 140.0]
new_centers[1]=[10.0, 11.666666666666666]
new_centers[2]=[21.0, 57.166666666666664]
----------------Results for iter 3----------------
centers: [[29.5, 140.0], [10.0, 11.666666666666666], [21.0, 57.166666666666664]]
targets: [2, 1, 2, 0, 1, 2, 2, 0, 2, 2, 1]
new_centers[0]=[29.5, 140.0]
new_centers[1]=[10.0, 11.666666666666666]
new_centers[2]=[21.0, 57.166666666666664]
Breeds in Cluster 0: ['Bullmastiff', 'Great Dane']
Breeds in Cluster 1: ['Boston Terrier', 'Chihuahua', 'Yorkshire Terrier']
Breeds in Cluster 2: ['Border Collie', 'Brittany Spaniel', 'German Shepherd', 'Golden Retriever', 'Portuguese Water Dog', 'Standard Poodle']
Distances in Cluster 0: [22.5, 22.5]
Distances in Cluster 1: [14.333333333333334, 5.666666666666666, 8.666666666666666]
Distances in Cluster 2: [13.166666666666664, 25.166666666666664, 24.833333333333336, 14.833333333333336, 7.166666666666664, 9.833333333333336]
可以看到,算法迭代了3次,效率比Kmeans++要低一些。但最终分类结果时一样的。