K-均值聚类(K-Means Clustering)是最基础和最常用的聚类算法。它的基本思想是,通过迭代方式寻找 K 个簇(Cluster)的一种划分方案,使得聚类结果对应的代价函数最小。特别地,代价函数可以定义为各个样本距离所属簇中心点的平方误差和。
给定样本集 D = { x 1 , x 2 , ⋯   , x m } D = \{x_1, x_2, \cdots, x_m\} D={x1,x2,⋯,xm},K-均值算法针对聚类所得的簇划分 C = { C 1 , C 2 , ⋯   , C k } C = \{C_1, C_2, \cdots, C_k\} C={C1,C2,⋯,Ck} 进行最小化平方误差和。
- 先求出每个簇的均值向量
μ
i
\mu_i
μi,
∣
C
i
∣
|C_i|
∣Ci∣ 表示簇内的元素个数。
μ i = 1 ∣ C i ∣ ∑ x ∈ C i x \mu_i = \frac{1}{|C_i|}\sum_{x \in C_i}x μi=∣Ci∣1x∈Ci∑x - 假设簇 C1 有三个向量 x1、x2 和 x3,分别为 (1, 2)、(2, 2) 以及 (2, 3),则簇 C1 的均值向量
μ
1
\mu_1
μ1 为:
μ 1 = 1 3 ∑ x i ∈ C 1 x i = 1 3 ( ( 1 , 2 ) + ( 2 , 2 ) + ( 2 , 3 ) ) = ( 5 3 , 7 3 ) \mu_1 = \frac{1}{3}\sum_{x_i \in C_1}x_i = \frac{1}{3}((1, 2) + (2, 2) + (2, 3)) = (\frac{5}{3}, \frac{7}{3}) μ1=31xi∈C1∑xi=31((1,2)+(2,2)+(2,3))=(35,37) - 接着,求每个簇内的元素到簇均值向量的距离平方和。
E = ∑ i = 1 k ∑ x ∈ C i ∣ ∣ x − μ i ∣ ∣ 2 2 ( 1 ) E = \sum_{i=1}^k\sum_{x \in C_i}||x - \mu_i||_2^2 \quad (1) E=i=1∑kx∈Ci∑∣∣x−μi∣∣22(1)
E 刻画了簇内样本围绕簇均值向量的紧密程度,E 值越小则簇内样本相似度越高。
【难点】:最小化平方误差并不容易,找到最优解需考察样本集 D 所有可能的簇划分,这是一个 NP 难问题。见下图,左图为初始化后的三个簇中心,右图是迭代结束后的三个簇划分。初始簇的选择会极大影响最终的簇划分,很显然下图初始簇的选择使得优化过程陷入局部最优解,而非全局最优解。
关于初始值的选择放到后面再讲,先介绍 K-Means 算法的描述与实现过程。
【算法】:K-Means 算法采用了贪心策略,通过迭代优化来近似求解式子(1)。
- 输入:样本集 D = { x 1 , x 2 , ⋯   , x n } D = \{x_1, x_2, \cdots, x_n\} D={x1,x2,⋯,xn};聚类簇数 k。
- 输出:簇划分 C = { C 1 , C 2 , ⋯   , C k } C = \{C_1, C_2, \cdots, C_k\} C={C1,C2,⋯,Ck}。
- 过程:
- 数据预处理,如归一化、离群点处理等。
- 从样本集 D 中随机挑选 K 个样本作为初始簇均值向量;
- 计算每个样本与各簇均值向量的距离,并根据距离确定当前样本所属的簇;
- 依据划分后的簇重新计算簇均值向量,并判断簇均值向量是否发生变化,若发生变化则更新,否则保持不变;
- 重复(2)、(3)步骤,直到所有簇均值向量均未更新。
代码实现
【实现准备】:
- 所需包:numpy
【随机挑选初始均值向量函数】:随机确定起始位置,然后根据样本集长度和挑选数量计算出步长。这么做可以避免挑选出重复值,以及尽可能地从样本集各个区域挑选数据,而不是聚集在某一区域。
def random_select(dataset, count):
# 获取样本集长度
length = dataset.shape[0]
# 计算步长和初始位置
step, start = length // count, np.random.randint(length)
data_select = []
# 按照起始位置和步长挑选数据
for i in range(count):
data_select.append(dataset[start])
start += step
start = start % length
return data_select
【实现 K-means 函数】:
- 函数定义
def k_mean(dataset, k):
- 获取样本集长度和初始化所需变量
length = dataset.shape[0]
dataset_cluster_info = np.array(np.zeros((length, 2)))
flag = True
- flag:用作循环判断的标志;
- dataset_cluster_info:创建一个二维数组,行数等于样本集长度,对应样本集数据。列数为二,第一列为当前样本所属的簇,第二列为当前样本距离所属簇的均值向量的值。
- 随机从样本集中挑选 k 个样本向量作为簇的均值向量
cluster_vectors = random_select(dataset, k)
- 进入循环,每次清空簇信息并将 flag 变量设置为 False
while flag:
cluster = []
flag = False
# ...
return cluster
- 循环每一个样本,将其划分到相应的簇
for i in range(length):
min_dist = np.inf
cluster_index = -1
for index in range(k):
dist = np.linalg.norm(dataset[i] - cluster_vectors[index])
if dist < min_dist:
min_dist = dist
cluster_index = index
dataset_cluster_info[i] = (cluster_index, min_dist)
- 更新每个簇的均值向量
for i in range(k):
cluster_data = dataset[dataset_cluster_info[:, 0] == i]
cluster.append(cluster_data)
cluster_new = np.mean(cluster_data, axis=0)
if (cluster_new != cluster_vectors[i]).all():
flag = True
cluster_vectors[i] = cluster_new
【完整代码】:
def k_mean(dataset, k):
# 初始化
length = dataset.shape[0]
# 随机从样本集中挑选 k 个样本向量作为簇的均值向量
cluster_vectors = random_select(dataset, k)
dataset_cluster_info = np.array(np.zeros((length, 2)))
flag = True
while flag:
cluster = []
flag = False
# 循环每一个样本,将其划分到相应的簇内
for i in range(length):
min_dist = np.inf
cluster_index = -1
for index in range(k):
dist = np.linalg.norm(dataset[i] - cluster_vectors[index])
if dist < min_dist:
min_dist = dist
cluster_index = index
dataset_cluster_info[i] = (cluster_index, min_dist)
# 更新每个簇的均值向量
for i in range(k):
cluster_data = dataset[dataset_cluster_info[:, 0] == i]
cluster.append(cluster_data)
cluster_new = np.mean(cluster_data, axis=0)
if (cluster_new != cluster_vectors[i]).all():
flag = True
cluster_vectors[i] = cluster_new
return cluster
上述代码的终止条件是当每个簇的均值向量都不发生变化。但随着数据量的增加以及数据复杂性的提高,要达到终止条件或许不是一件容易的事情。为避免运行时间过长,通常要设置一个最大运行轮数或最小调整幅度阈值。若达到最大轮数或调整幅度小于阈值,则停止运行。
我们再对代码进行改进,允许使用者自动选择代码的终止条件。
【改进代码】:
class KMeans:
def __init__(self, k, stop_criterion=True, max_iter=500, min_threshold=1):
self.k = k
self.stop_criterion = stop_criterion
self.max_iter = max_iter
self.iter = 0
self.min_threshold = min_threshold
self.data_cluster_info = None
def _random_select(self, data, k):
length = data.shape[0]
step, start = length // k, numpy.random.randint(length)
data_select = []
for i in range(k):
data_select.append(data[start])
start += step
start = start % length
return data_select
def fit(self, data):
# 初始化
length = data.shape[0]
# 随机从样本集中挑选 k 个样本向量作为簇的均值向量
cluster_vectors = self._random_select(data, self.k)
self.data_cluster_info = numpy.array(numpy.zeros((length, 2)))
flag = True
self.iter = 0
while self._stop_criterion():
cluster = []
flag = False
# 循环每一个样本,将其划分到相应的簇内
for i in range(length):
min_dist = numpy.inf
cluster_index = -1
for index in range(self.k):
dist = numpy.linalg.norm(data[i] - cluster_vectors[index], 2)**2
if dist < min_dist:
min_dist = dist
cluster_index = index
self.data_cluster_info[i] = (cluster_index, min_dist)
# 更新每个簇的均值向量
for i in range(self.k):
cluster_data = data[self.data_cluster_info[:, 0] == i]
cluster.append(cluster_data)
cluster_new = numpy.mean(cluster_data, axis=0)
if (cluster_new != cluster_vectors[i]).all():
flag = True
cluster_vectors[i] = cluster_new
self.iter += 1
if not flag:
break
return cluster
def _stop_criterion(self):
# 最大运行轮数
if self.stop_criterion:
return True if self.iter <= self.max_iter else False
# 最小调整幅度阈值
else:
if self.iter == 0:
error = numpy.infty
else:
error = numpy.sum(self.data_cluster_info[1], axis=0)
print(error)
return True if error > self.min_threshold else False
优缺点
【优点】:对于大数据集,K-Means 算法相对是可伸缩和高效的,它的计算复杂度是 O(NKt) 接近于线性,其中 N 是样本的数目,K 是聚类的簇数,t 是迭代的轮数。尽管算法经常以局部最优解结束,但一般情况下达到的局部最优已经可以满足聚类的需求。
【缺点】:
- 受初始簇和离群点的影响,每次的结果都不稳定(可能会不相同)、结果通常不是全局最优而是局部最优解;
- 无法很好地解决数据簇分布差别较大的情况,例如某一类样本是另一类样本数量的 100 倍。
- 不太适用于离散分类。
- K 值难以确定。
- 很难发现任意形状的簇。
算法优化
K-Means 算法的优化主要可分为初始点(簇均值向量)的选择以及 K 值的选择。
初始点的选择
初始点的选择对最后的聚类结果和运行时间都有很大的影响,如果仅仅是随机的选择,有可能导致算法收敛很慢。
【解决方案】:K-Means++。
- 从输入的数据集中随机选择一个点作为第一个聚类的簇均值向量。
- 对于数据集中的每一个点
x
i
x_i
xi,计算该点与已选择的簇均值向量中最近的距离。
D ( x i ) = a r g m i n ∣ ∣ x i − μ r ∣ ∣ 2 2 r = 1 , 2 , ⋯   , k s e l e c t e d D(x_i) = argmin||x_i - \mu_r||_2^2 \quad r = 1,2,\cdots,k_{selected} D(xi)=argmin∣∣xi−μr∣∣22r=1,2,⋯,kselected - 选择 D(x) 最大的数据点作为新的簇均值向量。
- 重复(2)和(3)步,直到选择出 K 个簇均值向量。
K 值的选择
K 值选择的方法主要有以下几种:
- 按需选择
- 观察法
- 拍脑袋法
- 肘部法则
- 其余方法后续更新
按需选择
按照业务的需求和目的来选择聚类的个数(K 的值)。一般来说,聚类的结果通常会用于下游业务,因此聚类簇个数的设置会根据下游任务的效果进行评估。
【例如】:一个游戏公司想把所有玩家做聚类分析,分成顶级、高级、中级、菜鸟四类,那么 K 可以设置为 4;一家房地产公司想把当地的商品房分成高、中、低三档,那么 K 可以设置为 3。
【缺点】:按需选择虽然合理,但是未必能保证在做 K-Means 时能够得到清晰的分界线,从而将数据集很好地划分为 K 个簇。
观察法
通过数据可视化的方式呈现数据,然后用肉眼去观察数据集的分布情况,从而决定 K 值。
【缺点】:适用于低维度的数据集,当数据集的维数大于 3 维时,无法使用数据可视化的方式呈现数据,也就无法用肉眼去观察。当然也可以先进行降维操作,将维度降低到二维或者三维,然后再进行观察。但能不能降到二维和三维也是一个问题。
拍脑袋法
K 近似于样本数除以 2 后开方。
K
≈
N
2
K \approx \sqrt{\frac{N}{2}}
K≈2N
例如,现在有 100 个样本,我们可以将 K 设置为:
K
≈
100
2
≈
7
K \approx \sqrt{\frac{100}{2}} \approx 7
K≈2100≈7
肘部法则(Elbow Method)
肘部法则适用于 K 值相对较小的情况。随着 K 的增大,样本划分会更加精细,每个簇的聚合程度会逐渐提高,那么误差平方和自然会逐渐变小。
- 当选择的 K 值小于真正的 K 值时,K 每增加 1,误差平方和的下降幅度会很大;
- 当选择的 K 值大于真正的 K 值时,K 每增加 1,误差平方和的下降幅度会骤减而趋于平缓。
基于上述考量,正确的 K 值就会在这个转折点。观察下图可以很明显地看出,曲线的形状类似人的肘部,故而取名为肘部法则。
但并不是所有的问题都可以通过画肘部图(K 与 cost function 的关系曲线图)来解决。肘点位置不明显,这时就无法确定 K 值了。
【Python 代码实现】:
from sklearn.cluster import KMeans
from scipy.spatial.distance import cdist
x1 = np.array([3, 1, 1, 2, 1, 6, 6, 6, 5, 6, 7, 8, 9, 8, 9, 9, 8])
x2 = np.array([5, 4, 5, 6, 5, 8, 6, 7, 6, 7, 1, 2, 1, 2, 3, 2, 3])
plt.plot()
plt.xlim([0, 10])
plt.ylim([0, 10])
plt.title('Dataset')
plt.scatter(x1, x2)
plt.show()
# create new plot and data
plt.plot()
X = np.array(list(zip(x1, x2))).reshape(len(x1), 2)
colors = ['b', 'g', 'r']
markers = ['o', 'v', 's']
# k means determine k
distortions = []
K = range(1, 10)
for k in K:
kmeanModel = KMeans(n_clusters=k).fit(X)
kmeanModel.fit(X)
distortions.append(sum(np.min(cdist(X, kmeanModel.cluster_centers_, 'euclidean'), axis=1)) / X.shape[0])
# Plot the elbow
plt.plot(K, distortions, 'bx-')
plt.xlabel('k')
plt.ylabel('Distortion')
plt.title('The Elbow Method showing the optimal k')
plt.show()
【语法】:scipy.spatial.distance.cdist
cdist(XA, XB, metric='euclidean')
- XA:ndarray,参与距离计算的向量 A;
- XB:ndarray,参与距离计算的向量 B;
- metric:str or callable,可选。距离度量方法,默认为欧氏距离。
- 返回值:ndarray。
【总结】:Elbow Method 是可以可以尝试的一种方法,但不适用于所有问题。
参考
- 《机器学习》周志华
- 《机器学习实战》
- K-Means算法之K值的选择:https://www.codercto.com/a/26692.html