从本节开始,我们将介绍无监督学习领域内最重要的一类算法——聚类算法。
# 科学计算模块
import numpy as np
import pandas as pd
# 绘图模块
import matplotlib as mpl
import matplotlib.pyplot as plt
# 自定义模块
from ML_basic_function import *
在此前的学习中,无论是回归问题还是分类问题,本质上其实都属于有监督学习范畴:即算法的学习是在标签的监督下进行有选择规律学习,也就是学习那些能够对标签分类或者数值预测起作用的规律。而无监督学习,则是在没有标签的数据集中进行规律挖掘,既然没有标签,自然也就没有了规律是否对预测结果有效一说,也就失去了对挖掘规律的“监督”过程,这也就是无监督算法的由来。
而如果一个数据集没有标签,我们就只能围绕特征矩阵进行规律挖掘,更具体的来说,面对没有标签的数据集,我们只能去尽可能的探索特征矩阵中的数值分布规律,当然这些规律肯定是需要符合一定的业务场景、拥有一定的现实意义。
而在所有的无监督学习算法中,最著名的两类算法就是聚类算法和关联规则算法。其中聚类算法是去探索特征矩阵中那些样本更加相似、更有可能是同一类(注意不是更加接近),并据此对数据集中的样本进行分类(当然有时我们也会针对数据集的列进聚类),著名的如RFM用户价值划分,就是通过三个维度的评估对不同类型用户进行价值划分的聚类过程。
而关联规则算法则更加聚焦于一个具体的业务场景——即针对一个购物篮数据进行频繁项的挖掘,并据此进一步探索不同数据之间是否存在一定的“关联性”,也就是所谓的关联规则,典型的如啤酒和尿布(尽管已经被证伪),就是在进行关联性的挖掘。当然,相比之下,聚类算法的使用场景会更多。
当然我们也可以换个角度来理解,那就是如果数据没有标签,那么我们就只能从数据内部结构入手,探索数据的分布规律、并对其进行类别的划分。
总的来说,我们可以将聚类算法的使用场景划分成两类,其一是独立解决一个无监督问题,如上对客户价值进行划分,或者,有时我们也会利用聚类算法来辅助有监督学习的过程,通常来说是辅助进行特征工程方面的工作,如进行样本的合并、进行特征的合并等。此外,在极少情况下,我们会利用聚类算法去解决有监督学习问题。
当然,围绕样本进行分群的聚类算法并不是一个算法,而是一类算法。所谓无监督学习最核心的算法类,当前流行的聚类算法也有数十种之多,而不同的聚类算法在进行分群的过程中实际效果也各不相同,在sklearn中就有一个著名的、不同聚类算法的效果比较图:
其中就列举了10种不同的聚类针对不同分布形态的数据最终的聚类效果。能够看出,尽管聚类是尝试对数据进行分群,但聚类算法(计算流程)不同,分群效果也是截然不同的。
不过尽管聚类算法看起来数量不少,但实际上对聚类算法的掌握难度其实要远远低于有监督学习算法,由于没有规律的选择环节,因此无监督学习算法并不存在类似模型泛化能力评估以及模型调参的过程;另外,在实际解决问题的过程中,机器学习类算法的主流应用还是在于预测,因此聚类算法的实际使用场景也要远少于有监督类算法。正是因为上述种种,本节将挑选最为常用三类聚类算法来进行集中的讨论,即K-Means快速聚类、小批量快速聚类(Mini Batch K-Means)以及BDSCAN基于密度的聚类,并据此深入探讨聚类算法的算法共性与使用共性。
首先是K-Means快速聚类,这是一种能够对数据集进行指定类别数量分群的聚类算法,同时也是目前最常用的一类聚类算法。我们此前说到,聚类算法其实就是对数据进行分群,而不同聚类算法流程不同、对应的分群规则也有所不同,对聚类算法流程的掌握,实际上也就是对数据分群规则的掌握。而对于K-Means快速聚类来说,我们可以通过如下实例来介绍该算法的聚类流程。
首先进行数据准备,我们借助此前定义的arrayGenCla函数创建一组数据,注意,无监督算法的执行流程不需要标签,也不会从标签中提取任何信息,因此其实我们核心是需要arrayGenCla函数所创建的特征矩阵。而为了更好的展示聚类算法对特征矩阵的分群功能,我们创建一组包含两个特征的数据,在二维特征空间内对其进行聚类:
np.random.seed(23)
X, y = arrayGenCla(num_examples = 20, num_inputs = 2, num_class = 2, deg_dispersion = [2, 0.5])
plt.scatter(X[:, 0],X[:, 1],c=y)
接下来,围绕已经生成X,我们尝试对其进行聚类。对于K-Means来说,首先需要确定的是需要将分成几个群,尽管我们从样本的分布来看分成两个群更加合适,但实际上K-Means聚类的类别数量根本上由实际业务来决定,并且由于没有标签的引导,例如上述在围绕用户价值进行分群时,将用户分为高价值、低价值两类还是分成高、中、低价值三类,实际上是由业务端来决定。从算法原理层面来说,由于聚类算法缺少了标签的指引,所以分成几类其实也没有非常严谨的数值指标进行引导。此处我们假定需要对上述数据聚成两类,然后执行后续的操作。
此处有两个点需要进行拓展讨论:
首先,尽管一般来说没有非常严谨的指标来指导K-Means应该聚成几类,但却有很多用于评估聚类结果的指标,有的时候,我们也可以从这些指标中反推应该聚成几类更加合适,如R语言中就有算法包能够对K-Means的聚类结果从几十个角度进行评估;
其次,并非所有的聚类算法都需要在聚类开始前设置聚类的类别数量,如后续将要介绍的DBSCAN。
需要知道的是,聚成几类,实际上就是K-Means中的K。
在确定了聚类的类别数量(也就是两类)之后,接下来,我们需要在特征空间中随机生成两个点,作为初始中心点。
这里需要注意,在K-Measn快速聚类过程中,这类中心点其实起到了至关重要的作用,中心点会随着迭代逐步发生变化,而每个点应该属于哪一类,其实也都是由这些中心点决定的。这里的相关概念我们可以类比此前的内容进行理解,中心点可以类比于此前逻辑回归/线性回归中的参数,刚开始给予一组初始随机值,并且K-Means的计算过程实际上也是一轮一轮进行迭代的,并且每一轮迭代的过程都会修改中心点的位置,这就类似于梯度下降的计算过程中,通过一轮一轮的迭代来不断的修改参数。
无论如何,我们先在特征空间中创建两个点作为初始中心点,相关过程如下:
np.random.seed(23)
center = np.random.randn(2, 2)
center
#array([[ 0.66698806, 0.02581308],
# [-0.77761941, 0.94863382]])
plt.scatter(X[:, 0],X[:, 1])
plt.plot(center[0, 0], center[0, 1], 'o', c='red') # 令第一个点为红色
plt.plot(center[1, 0], center[1, 1], 'o', c='cyan') # 令第二个点为蓝色
首先给出聚类算法分群结束后分得的每个群的定义,为了区分分类算法分类别这一概念,我们成聚类算法分出来的“群”为一个簇。
在给出两个中心点之后,我们就能够依据这两个中心点将数据分成两个簇,划分的过程也非常简单:计算每个点到两个中心点的距离,如果距离红色中心点更近,则该点属于红色点代表的簇(以下简称红色点簇),而如果该点距离蓝色中心点更近,则应该属于蓝色点代表的簇(以下简称蓝色点簇)。
当然,关于距离的计算其实有很多种,我们在Lesson 4.1中曾介绍了多种距离的计算方法,此处我们以欧式距离为例,来进行距离计算,相关计算过程可以由如下代码实现:
center
#array([[ 0.66698806, 0.02581308],
# [-0.77761941, 0.94863382]])
# 计算每条样本距离红色中心点距离
np.power((X - center[0]), 2).sum(1)
#array([2.80418598, 4.53045247, 4.13895531, 5.96788755, 5.43938343,
# 3.865367 , 3.25889336, 7.13376456, 2.30412925, 8.92312473,
# 3.37863704, 2.33823889, 4.64766873, 3.16445859, 5.17815242,
# 1.55545174, 1.69836706, 5.6316741 , 9.43394061, 2.93573695,
# 2.1952354 , 1.68759248, 0.99370862, 1.5880287 , 0.50250126,
# 1.21754799, 1.4285248 , 0.052919 , 0.64128333, 2.52979672,
# 0.53203112, 1.17477822, 2.55720201, 7.04514128, 1.0019926 ,
# 2.98793165, 3.17855917, 0.55893052, 1.00322863, 0.55888205])
# 对比距离中心点远近情况
res_bool = np.power((X - center[0]), 2).sum(1) < np.power((X - center[1]), 2).sum(1)
res_bool
#array([ True, False, True, True, False, False, True, False, False,
# False, False, True, True, False, True, True, False, False,
# False, True, True, True, True, True, True, True, True,
# True, True, True, True, True, True, True, True, True,
# True, True, True, True])
res = res_bool*1
res
#array([1, 0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1,
# 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
根据代码可知,1代表该样本距离红色中心点更近,应该属于红色点簇,而0则代表该样本距离蓝色中心点更近,应该属于蓝色点簇。我们可以通过可视化的方式进行呈现,令红色点簇中的点都着上红色,令蓝色点簇中的点都着上蓝色:
# 选取红色点簇
X_red = X[(res_bool)]
X_red
# array([[-0.66650597, -0.98709346],
# [-0.6491641 , -1.52554078],
# [-1.18377406, -1.56872985],
# [-0.83886424, -0.96982853],
# [-0.73091902, -0.59394067],
# [-0.87944685, -1.47625477],
# [-0.91318318, -1.61162739],
# [-0.29234001, -0.77114451],
# [-0.69690225, -1.01126945],
# [ 1.00671113, 1.46797245],
# [ 1.21031133, 1.20580982],
# [ 0.96433804, 0.97728121],
# [ 1.52044299, 0.95298263],
# [ 0.78957802, 0.72400572],
# [ 0.93945123, 1.09507068],
# [ 1.2560687 , 1.06576923],
# [ 0.83419144, 0.18380686],
# [ 1.30955704, 0.50371311],
# [ 0.91932681, 1.59620217],
# [ 1.12536828, 0.59319188],
# [ 1.35181182, 0.86592893],
# [ 0.75872026, 1.62230524],
# [ 1.33843006, 2.59375134],
# [ 0.45971762, 1.00511465],
# [ 1.21891484, 1.66389381],
# [ 0.87442748, 1.79655531],
# [ 1.08540885, 0.64537305],
# [ 0.9334877 , 0.99132153],
# [ 0.94945354, 0.71798019]])
# 选取蓝色点簇
X_blue = X[(~res_bool)]
X_blue
#array([[-1.38880971, -0.52568309],
# [-1.66107376, -0.11387086],
# [-1.1737295 , -0.66492992],
# [-1.521725 , -1.50497094],
# [-0.77913181, -0.43556157],
# [-1.91903388, -1.46938432],
# [-1.10092026, -0.47731436],
# [-1.06813338, -0.3663759 ],
# [-0.63556208, -0.01578263],
# [-1.273894 , -1.33970914],
# [-2.25311516, -0.92651975]])
plt.plot(X_red[:, 0],X_red[:, 1], 'o', c='lightcoral')
plt.plot(center[0, 0], center[0, 1], 'o', c='red')
plt.plot(X_blue[:, 0],X_blue[:, 1], 'o', c='c')
plt.plot(center[1, 0], center[1, 1], 'o', c='cyan')
至此,我们就根据两个中心点,对上述数据集进行了两个簇的划分。由此我们也可得知,只要给出一组中心点,就能够对数据集进行一次分群。
尽管上述过程确实将所有的点分成了两个簇,但划分结果却并不理想。对于K-Means来说,其聚类的核心目的是将类似的划分成一类,而上面的划分结果很明显无法满足要求。比如对于左下方的一些点来说,明明彼此距离更近,却有些点和右上方的点属于同一类,这并不符合挨得越近越有可能属于同一类的初衷。因此我们还需要进行进一步的计算,也就是换中心点,再进行簇的划分。
而新的中心点应该如何计算?对于K-Means来说,我们会根据上述划分结果,通过计算不同簇的质心来重新计算中心点。此时我们将红色点簇的中心点改为红色点簇的质心,而蓝色点簇的中心点改为蓝色点簇的质心。注意,在上述表述中,中心点表示K-Means的建模含义,即据此划分数据集,而质心其实表示的中心点的计算方法。对于利用欧式距离进行计算的K-Means快速聚类来说,质心是采用均值来进行计算的: x c e n = x 1 + x 2 + . . . + x n n x_{cen} = \frac{x_1+x_2+...+x_n}{n} xcen=nx1+x2+...+xn y c e n = y 1 + y 2 + . . . + y n n y_{cen} = \frac{y_1+y_2+...+y_n}{n} ycen=ny1+y2+...+yn
为什么说利用欧式距离进行K-Means的聚类算法才是用均值计算质心,稍后在解释K-Means的数学原理时会详细探讨。
而对于上述点簇,我们可以通过如下方法计算各簇的质心:
X_red.mean(0)
#array([0.48257302, 0.40526208])
X_blue.mean(0)
#array([-1.3431935 , -0.71273659])
而这两个点,就构成了新的中心点,其中第一个点就是红色点簇新的中心点、第二个点就是蓝色点簇新的中心点。接下来围绕这两个点再来进行下一轮划分:
center = np.array([X_red.mean(0), X_blue.mean(0)])
center
#array([[ 0.48257302, 0.40526208],
# [-1.3431935 , -0.71273659]])
plt.plot(X_red[:, 0],X_red[:, 1], 'o', c='lightcoral')
plt.plot(center[0, 0], center[0, 1], 'o', c='red')
plt.plot(X_blue[:, 0],X_blue[:, 1], 'o', c='c')
plt.plot(center[1, 0], center[1, 1], 'o', c='cyan')
接下来,重复上述依据中心点划分数据集的方法,根据新的中心点对数据集进行再一次的划分:
# 距离计算结果
res_bool = np.power((X - center[0]), 2).sum(1) < np.power((X - center[1]), 2).sum(1)
res = res_bool*1
# 新的簇的划分
X_red = X[(res_bool)]
X_blue = X[(~res_bool)]
# 展示划分结果
plt.plot(X_red[:, 0],X_red[:, 1], 'o', c='lightcoral')
plt.plot(center[0, 0], center[0, 1], 'o', c='red')
plt.plot(X_blue[:, 0],X_blue[:, 1], 'o', c='c')
plt.plot(center[1, 0], center[1, 1], 'o', c='cyan')
在本轮计算结束后,我们发现聚类的结果已经更贴近于K-Means所要求的距离更近更有可能属于同一个簇的目标。当然,前面所有的计算我们可以将其视为两轮运算,每一轮计算中都包含以下三步:
1)确定中心点,第一轮是随机生成的,其他情况都是通过质心计算得到;
2)根据中心点,计算每个点到中心点的距离;
3)根据距离计算结果,对数据集进行划分。
并且后一轮的中心点的位置,实际上是由前一轮计算结果(也就是数据集的划分结果)决定的,也就是当前计算条件其实是上一轮的计算结果(所决定),因此上述过程本质上也是在迭代,即整个K-Means的计算过程实际上是迭代计算过程。
那何时迭代停止呢?一般来说有两个等价的条件:
1)相邻两次迭代过程中质心位置不发生变化;
2)相邻两次迭代过程中各点所属类别不发生变化;
注意,这两个条件是等价的,如果质心位置不变,则数据集划分情况就不会发生变化,而如果数据集划分情况不发生变化,则质心也就不变。当然,迭代停止也就等价于模型收敛了,因此对于K-Means来说,和梯度下降求解参数一样,都存在模型收敛这一说法。
接下来,我们就尝试继续进行计算,看下数据集划分情况或者中心点是否发生变化。首先继续计算心的质心:
center = np.array([X_red.mean(0), X_blue.mean(0)])
plt.plot(X_red[:, 0],X_red[:, 1], 'o', c='lightcoral')
plt.plot(center[0, 0], center[0, 1], 'o', c='red')
plt.plot(X_blue[:, 0],X_blue[:, 1], 'o', c='c')
plt.plot(center[1, 0], center[1, 1], 'o', c='cyan')
# 距离计算结果
res_bool = np.power((X - center[0]), 2).sum(1) < np.power((X - center[1]), 2).sum(1)
res = res_bool*1
# 新的簇的划分
X_red = X[(res_bool)]
X_blue = X[(~res_bool)]
# 展示划分结果
plt.plot(X_red[:, 0],X_red[:, 1], 'o', c='lightcoral')
plt.plot(center[0, 0], center[0, 1], 'o', c='red')
plt.plot(X_blue[:, 0],X_blue[:, 1], 'o', c='c')
plt.plot(center[1, 0], center[1, 1], 'o', c='cyan')
我们发现数据集划分方式并为发生变化,说明已经达到K-Means模型收敛条件,模型将不再进行迭代,而上述过程就是一整个K-Means快速聚类的过程。当然,如果将上述代码封装为一个完整函数,该函数就是K-Means快速聚类的手动实现方法。
不难看出,相比很多有监督学习算法,K-Means的计算过程相对简单,并且稍后在进行sklearn的实现过程中,我们也会发现K-Means评估器的参数也不算多。不过尽管如此,我们还是需要稍微深入点儿来讨论K-Means快速聚类过程背后的数学意义,在这个过程中,我们会发现K-Means作为一个无监督学习算法,其背后有非常多的原理都和我们此前介绍的一系列算法的原理是相通的。
尽管我们此前一直说,K-Means快速聚类的目标是让更接近的点划分为同一个簇,以达到“物以类聚、人以群分”的效果,但实际上这一目标背后其实有更加严谨的数学表示,那就是在给定K(簇的个数)的情况下,找到一种最优的划分情况,使得组内误差平方和尽可能的小。这里所谓的组内误差平方和,指的是每个点到当前簇的中心点的距离的平方和,我们可以通过如下数学计算符号来进行表示:
则组内误差为:
∑ i = 1 K ∑ x ∈ C i ( c i − x ) 2 \sum^K_{i=1}\sum_{x\in C_i}(c_i-x)^2 i=1∑Kx∈Ci∑(ci−x)2
这点其实不难理解,如果每个点都距离各自中心点更近,肯定聚类效果会更好。而由此,则可进一步衍生出原型和质心计算公式的数学意义这两个至关重要的点。
在K-Means快速聚类中,中心点、质心其实还有另一个叫法:原型。即当前簇中所有点的原型。换而言之,很多时候我们在进行聚类分析时,最后实际上是要利用这个中心点、质心或者原型来代表一个簇的数据的,例如此前介绍的RFM客户价值划分模型中,如果我们将客户划分成高、中、低价值三类,最终我们还是需要从这三类中找到具有代表性的“典型”,才能为后续的诸如产品设计环节提供数据支持。也就是说,我们其实是希望通过原型来“代表”一个簇中的点,也就是说,我们希望通过原型来预测这个簇中数据的表现。
那既然是预测,就肯定会有误差,而这里用原型预测一个簇中其他所有点的误差,就是上述的组内平方和误差,也就是簇内所有点到这个原型之间距离的平方和。而在线性回归中我们曾介绍,预测值和真实值之间的距离,被称为SSE,因此对于K-Means快速聚类来说,其组内误差平方和也就是SSE:
S S E = ∑ i = 1 K ∑ x ∈ C i ( c i − x ) 2 SSE = \sum^K_{i=1}\sum_{x\in C_i}(c_i-x)^2 SSE=i=1∑Kx∈Ci∑(ci−x)2
当然,上述SSE其实就是对当前聚类状况的一个评估,SSE越大,则说明当前聚类效果较差,而SSE较小,则说明当前聚类效果较好。回顾上述聚类过程,能够明显看到SSE下降趋势:
# 第一轮SSE
# 确定中心点
np.random.seed(23)
center = np.random.randn(2, 2)
# 计算距离
res_bool = np.power((X - center[0]), 2).sum(1) < np.power((X - center[1]), 2).sum(1)
res = res_bool*1
# 划分数据集
X_red = X[(res_bool)]
X_blue = X[(~res_bool)]
# 计算SSE
np.power((X_red - center[0]), 2).sum()
#66.26098649489376
# 第二轮SSE
# 确定中心点
center = np.array([X_red.mean(0), X_blue.mean(0)])
# 计算距离
res_bool = np.power((X - center[0]), 2).sum(1) < np.power((X - center[1]), 2).sum(1)
res = res_bool*1
# 划分数据集
X_red = X[(res_bool)]
X_blue = X[(~res_bool)]
# 计算SSE
np.power((X_red - center[0]), 2).sum()
#23.256673490142994
而更进一步,可以借助数学证明,选取质心作为中心点,实际上是有利于让SSE下降速度最快的迭代方法。在梯度下降中我们曾提到,令损失函数导函数取值为0的方向,就是损失函数值下降最快的方向,此处也类似,由于原型概念的引入,使得我们可以将K-Means视作预测模型,而上述SSE就是其损失函数,并且该损失函数中变量为 c k c_k ck,也就是质心,对其求导可得:
∂ ∂ c k S S E = ∑ i = 1 K ∑ x ∈ C i ∂ ∂ c i ( c i − x ) 2 = ∑ i = 1 K ∑ x ∈ C i 2 ( c i − x ) = 0 \begin{aligned} \frac{\partial}{\partial c_{k}} S S E &=\sum_{i=1}^{K} \sum_{x \in C_{i}} \frac{\partial}{\partial c_{i}}\left(c_{i}-x\right)^{2} \\ &=\sum_{i=1}^{K} \sum_{x \in C_{i}} 2\left(c_{i}-x\right)=0 \end{aligned} ∂ck∂SSE=i=1∑Kx∈Ci∑∂ci∂(ci−x)2=i=1∑Kx∈Ci∑2(ci−x)=0
因此,对于给定的i,上式的必要条件是: ∑ x ∈ C i ( c i − x ) = 0 \sum_{x\in C_i}(c_i-x) = 0 x∈Ci∑(ci−x)=0
由该式可以进一步推导得出: ∑ x ∈ C i x = m i c i \sum_{x \in C_i}x = m_ic_i x∈Ci∑x=mici
即 c i = 1 m i ∑ x ∈ C i x c_i = \frac{1}{m_i}\sum_{x \in C_i}x ci=mi1x∈Ci∑x
即中心是由质心计算得出。换而言之,中心点采用每个点各维度均值的计算方式,能够让SSE下降速度最快。当然如果样本距离的计算方式发生变化,则质心对应的计算方式也必须发生变化,例如,如果采用曼哈顿距离进行距离计算,则需要采用中位数作为质心的计算方法。
接下来,我们尝试在sklearn中进行K-Means快速聚类,并尝补充讲解K-Means聚类算法在使用过程中的注意事项,同时补充介绍关于Mini Batch K-Means的相关内容。
首先,作为聚类的评估器,K-Means在sklearn.cluster模块下,通过如下方式进行导入,并查看K-Means的超参数。
from sklearn.cluster import KMeans
KMeans?
#Init signature:
#KMeans(
# n_clusters=8,
# *,
# init='k-means++',
# n_init=10,
# max_iter=300,
# tol=0.0001,
# verbose=0,
# random_state=None,
# copy_x=True,
# algorithm='auto',
)
除了通用的verbose、random_state和copy_x外,我们重点介绍其他各参数:
围绕上述参数,需要重点解释的是关于K-Means迭代不平稳的问题。
尽管此前例子中K-Means的迭代过程快速高效,但实际上,当面对复杂数据集时,K-Measn很有可能陷入“局部最小值陷进”或者“震荡收敛”。所谓落入局部最小值陷进,指的是尽管可能有更好的划分数据集的方法(SSE取值更小),但根据K-Means的收敛条件却无法达到,算法会在另外一种划分情况时停止迭代;而所谓“震荡收敛”,指的是算法会在两种不同的划分方法中来回震荡(尽管SSE取值可能有差别)。前种情况非常类似于参数进行梯度下降求解过程中,如果采用BGD,并且参数在一个局部最小值点附近,则最终参数会收敛到局部最小值点类似,而后面一种情况则非常类似于学习率过大导致无法收敛、一直处于震荡状态。
而出现这种问题的根本原因,其实在于初始中心点的随机选取。因此sklearn中其实集成了两种技术手段来避免上述两种问题的出现。其一是采用k-means++算法来计算初始中心点,经过这种算法生成的中心点,能够大概率在后续的迭代过程中让模型保持平稳,相关说明可参考论文:“k-means++: The advantages of careful seeding” 。而无论k-means++是否生效,为了保险起见,sklearn中都采用了多次初始化中心点、多次训练模型、然后找到最优数据集划分的方法,这就是n_init参数的意义。在这双重保证下,sklearn的K-means快速聚类能够整体保持非常平稳的状态。
接下来,尝试调用sklearn中快速聚类方法对数据集进行聚类:
km = KMeans(n_clusters=2)
km.fit(X)
#KMeans(n_clusters=2)
注意,对于无监督学习算法,只需要带入特征矩阵进行计算即可。在训练完成后,我们即可调用评估器的相关属性来查看聚类结果:
# 查看中心点
km.cluster_centers_
#array([[-1.08131141, -0.91777659],
# [ 1.04228586, 1.11340149]])
# 查看每条数据属于哪一类
km.labels_
#array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1,
# 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], dtype=int32)
# 借助图像进行验证
plt.scatter(X[:, 0], X[:, 1], c=km.labels_)
plt.plot(km.cluster_centers_[0, 0], km.cluster_centers_[0, 1], 'o', c='red')
plt.plot(km.cluster_centers_[1, 0], km.cluster_centers_[1, 1], 'o', c='cyan')
# 收敛时SSE
km.inertia_
#16.352095518854334
注意,此处和手动实现过程SSE计算结果不同,原因是手动实现时SSE是上一轮迭代完的中心点,若在手动实现部分将中心点改为两次迭代后的质心,则计算结果相同。
# 迭代次数
km.n_iter_
#3
能够发现,和此前手动实现结果一致。此外,K-Means评估器也支持predict方法,对于新的数据,K-Means模型能够依据其距离各中心点的远近来对其类别所属情况进行判别:
X_new = np.random.randn(2, 2)
km.predict(X_new)
#array([0, 0], dtype=int32)
当然,正因如此,我们可以绘制K-Means的决策边界,类似于有监督学习算法,决策边界的形状其实一定程度将决定聚类算法针对不同分布的数据集时聚类的“性能”。
plot_decision_boundary(X, km.labels_, km)
尽管我们可以通过SSE来表示当前K-Means聚类模型效果好坏(甚至作为损失函数),但SSE却不能作为模型超参数(K)的选取依据。其实我们不难发现,伴随K增加,模型整体SSE将会逐渐下降。不过,尽管如此,其实K-Means快速聚类中,还是有部分指标可以一定程度上给出聚成几类的指导意见,其中最有名的就是轮廓系数(silhouette coefficient,简称sc)。注意,对于K-Means来说,这些指标只能参考,最终聚成几类,还应该主要参考模型的业务背景。
轮廓系数的计算过程如下:
(1).对于第i条数据(以下简称i),计算该对象到所属簇的平均距离,记为 a i a_i ai;
(2).如果还存在其他簇(不包含第i个对象的簇,如A、B两个簇),分不同的簇,计算该对象到这些簇的所有点的平均距离(例如计算i到A簇中所有点的平均距离,以及计算i到B簇中所有点的平均距离),并在这些距离中找到最小值记为 b i b_i bi;
(3).则对于i,轮廓系数计算结果为: s i = b i − a i m a x ( a i , b i ) s_i=\frac{b_i-a_i}{max(a_i, b_i)} si=max(ai,bi)bi−ai;
(4).而对于聚类中的所有N条数据,最终轮廓系数为单个 s i s_i si的均值,即 s = m e a n ( s i ) s=mean(s_i) s=mean(si)
尽管轮廓系数可以在[-1, 1]区间内取值,但我们并不希望轮廓系数出现负值,此时代表组内的平均距离要大于组外平均距离的最小值,此时说明聚类算法无效。我们希望 b i > a i b_i>a_i bi>ai,并且希望 a i a_i ai尽可能的小,此时 s i s_i si也就趋近于1,而当轮廓系数趋于0时,则说明各簇重叠现象明显。并且,非常重要的一点是,轮廓系数取值的大小一定程度上能够给K的取值提供建议,当轮廓系数比较大时,往往说明数据在特征空间中本身的分布情况就和聚类的类别数量相同。
和SSE不同,轮廓系数受到K的影响相对较小,这也是轮廓系数相对可靠的原因之一。
当然,我们也可以借助sklearn中metrics模块下的silhouette_score函数来进行轮廓系数的计算:
from sklearn.metrics import silhouette_score
silhouette_score(X, km.labels_)
#0.7241755028408805
而更进一步的,轮廓系数如何指导K值的选取,我们可以通过如下实例来进行说明。此处手动生成一组三分类明显的数据集,观察K取值不同时轮廓系数的变化情况。
np.random.seed(23)
X, y = arrayGenCla(num_examples = 50, num_inputs = 2, num_class = 3, deg_dispersion = [2, 0.5])
plt.scatter(X[:, 0], X[:, 1], c=y)
ss = []
for i in range(2, 12):
km = KMeans(n_clusters=i).fit(X)
ss.append(silhouette_score(X, km.labels_))
ss
#[0.5917969390803755,
# 0.6753180189915984,
# 0.580872808406484,
# 0.47687683047050644,
# 0.3685113521594094,
# 0.3653820829962011,
# 0.3639995054273048,
# 0.3524637605205039,
# 0.3579462241667135,
# 0.3682450219445942]
能够发现,当K取值为3时轮廓系数取值最高,也就是说明从特征空间的数据分布来看,整体呈现聚成三类的趋势。当然,这个我们创建数据集时赋予的规律一致。
不过,仍然需要强调的是,除非特征矩阵在特征空间的“分界”非常明显,才能在轮廓系数上有明显差异。而聚类算法在分类上的性能,其实也远远弱于有监督学习算法。
除了K-Means快速聚类意外,还有两种常用的聚类算法,其一是能够进一步提升快速聚类的速度的Mini Batch K-Means算法,其二则是能够和K-Means快速聚类形成性能上互补的算法DBSCAN密度聚类。
# 科学计算模块
import numpy as np
import pandas as pd
# 绘图模块
import matplotlib as mpl
import matplotlib.pyplot as plt
# 自定义模块
from ML_basic_function import *
# K-Means
from sklearn.cluster import KMeans
K-Means算法作为最常用的聚类算法,在长期的使用过程中也诞生了非常多的变种,典型的如提高迭代稳定性的二分K均值法、能够显著提升算法执行速度的Mini Batch K-Means,由于聚类算法的稳定性可以通过k-means++以及多次迭代选择最佳划分方式等方法解决,此处重点介绍Mini Batch K-Means算法。
顾名思义,所谓Mini Batch K-Means算法,就是在K-Means基础上增加了一个Mini Batch的抽样过程,并且每轮迭代中心点时,不在带入全部数据、而是带入抽样的Mini Batch进行计算。即每一轮的迭代操作更新为
(1).从数据集中随机抽取一些数据形成小批量,把他们分配给最近的质心;
(2).根据小批量数据划分情况,更新质心;
此处可以用梯度下降和小批量(Mini Batch)梯度下降之间的差异进行类比,梯度下降过程中,我们带入全部数据构造损失函数,相当于带入全部数据进行参数的更新,就类似于K-Means带入每个簇的全部数据进行中心点位置计算,而在小批量梯度下降过程中,实际上我们是借助小批数据构造损失函数并对参数进行更新,就类似于Mini Batch K-Means中利用小批数据更新中心点。
而Mini Batch K-Means的有效性,其实也和小批量梯度下降的有效性类似,那就是对于一组规律连贯的数据集来说,小批量数据能够很大程度反映整体数据集规律,因此带入小批量数据进行计算是有效的。此外,Mini Batch K-Means相比K-Means的优劣势,也和小批量梯度下降对比梯度下降过程类似,采用小批数据带入进行计算能够极大缩短单次运算时间,因此迭代速度会更快,但由于小批量数据还是和整体数据之间存在差异,因此每次计算结果的精度不如带入整体数据的计算结果。不过对于K-Means是否会落入局部最小值陷阱,我们可以通过k-means++以及重复多次训练模型来解决,因此Mini Batch K-Means并不用承担跨越局部最小值陷阱的职责,所以Mini Batch K-Means对比K-Means,其实就相当于牺牲了部分精度来换取聚类速度。而聚类算法毕竟不是有监督学习算法,因此如果是面对海量数据的聚类,我们是可以考虑牺牲部分精度来换取聚类执行的速度的(当然这也要视情况而定)。
此处所谓小批量聚类精度不足,指的是小批量聚类和K-Means聚类结果上的差异,一般我们会认为K-Means聚类是精准的,而小批量聚类如果出现了和K-Means聚类不同的结果,则说明小批量聚类出现了误差,也就是精度不足。
更多Mini Batch K-Means算法的信息,查阅“Web Scale K-Means clustering” D. Sculley, Proceedings of the 19th international conference on World wide web (2010)
接下来尝试调用相关评估器进行Mini Batch K-Means聚类:
from sklearn.cluster import MiniBatchKMeans
我们发现,MiniBatchKMeans中部分参数和K-Means中参数不同,我们针对这些不同的参数来进行解释
MiniBatchKMeans?
# Init signature:
# MiniBatchKMeans(
# n_clusters=8,
# *,
# init='k-means++',
# max_iter=100,
# batch_size=100,
# verbose=0,
# compute_labels=True,
# random_state=None,
# tol=0.0,
# max_no_improvement=10,
# init_size=None,
# n_init=3,
# reassignment_ratio=0.01,
# )
能够发现,MiniBatchKMeans在参数设置上和K-Means有两方面差异:
其一是在迭代收敛条件上,通过查看说明文档我们不难发现,MiniBatchKMeans主要通过max_no_improvement和max_iter两个参数来控制收敛,在默认情况下不采用tol参数。其根本原因在于小批量聚类往往需要迭代很多轮,因而出实际未收敛、但现两次相邻的迭代结果中SSE变化值小于tol的情况的概率会显著增加,因此此时我们不能以tol条件作为收敛条件;
其二,是在控制结果精度上,尽管小批量聚类是用精度换速度,但仍然提供了可以提升聚类精度的参数,也就是reassignment_ratio,当发现聚类结果不尽如人意时,可以适当提升该参数的取值。
接下来,尝试调用相关评估器进行建模:
mbk = MiniBatchKMeans(n_clusters=2)
np.random.seed(23)
X, y = arrayGenCla(num_examples = 20, num_inputs = 2, num_class = 2, deg_dispersion = [2, 0.5])
plt.scatter(X[:, 0],X[:, 1],c=y)
mbk.fit(X)
#MiniBatchKMeans(n_clusters=2)
# 观察聚类结果
plt.scatter(X[:, 0], X[:, 1], c=mbk.labels_)
plt.plot(mbk.cluster_centers_[0, 0], mbk.cluster_centers_[0, 1], 'o', c='red')
plt.plot(mbk.cluster_centers_[1, 0], mbk.cluster_centers_[1, 1], 'o', c='cyan')
我们发现,在简单数据集的聚类过程中,MiniBatchKMeans和KMeans并没有太大差异,接下来我们尝试在更大的数据集上来进行聚类,测试二者的精度和运算速度:
np.random.seed(23)
X, y = arrayGenCla(num_examples = 1000000, num_inputs = 10, num_class = 5, deg_dispersion = [4, 1])
km = KMeans(n_clusters=5, max_iter=1000)
mbk = MiniBatchKMeans(n_clusters=5, max_iter=1000)
# 导入时间模块
import time
# K-Means聚类用时
t0 = time.time()
km.fit(X)
t_batch = time.time() - t0
t_batch
#12.087070941925049
# MiniBatchKMeans聚类用时
t0 = time.time()
mbk.fit(X)
t_batch = time.time() - t0
t_batch
#3.6028831005096436
能够发现,MiniBatchKMeans聚类速度明显快于K-Means聚类,接下来查看二者SSE来对比其精度:
km.inertia_
#49994316.22276671
mbk.inertia_
#50166895.159873486
能够发现,MiniBatchKMeans精度略低于K-Means,但整体结果相差不大,基本可忽略不计,当然这也是因为当前数据集分类性能较好的原因。不过经此也可验证MiniBatchKMeans聚类的有效性。一般来说,对于2万条以上的数据集,MiniBatchKMeans聚类的速度优势就会逐渐显现。
当然,如果希望更进一步提高迭代速度,可以适度减少batch_size、减少reassignment_ratio、max_no_improvement这三个参数,不过代价就是聚类的精度可能会进一步降低,而如果希望提高精度,则可以提升reassignment_ratio参数,不过相应的,运行时间将会有所提升。
尽管MiniBatchKMeans能够有效提高聚类速度、提升聚类效率,但从最终聚类效果上来看,MiniBatchKMeans和K-Means聚类算法仍然属于同一类聚类——假设簇的边界是凸形的聚类。换而言之,就是这种聚类能够较好的捕捉圆形/球形边界(直线边界可以看成是大直径的圆形边界),而对于非规则类边界,则无法进行较好的聚类,当然这也是和K-Means聚类的核心目的:让更相近同一个中线点的数据属于一个簇,息息相关。但有些时候,更接近同一个中心点的数据却不一定应该属于一个簇,例如如下情况:
from sklearn.datasets import make_moons
X, y = make_moons(200, noise=0.05, random_state=24)
plt.scatter(X[:,0], X[:,1], c = y)
其中make_moons函数是datasets模块中创造数据集的函数,默认创建月牙形数据分布的数据集,并且noise参数取值越小、数据分布越贴近月牙形状。当然此时我们发现,上述数据集明显可分为两个簇,但两个簇的边界却不是凸型的。此时如果我们用K-Means对其进行聚类,则会得到如下结果:
km = KMeans(n_clusters=2)
km.fit(X)
#KMeans(n_clusters=2)
plt.scatter(X[:,0], X[:,1], c = km.labels_)
plt.plot(km.cluster_centers_[:,0], km.cluster_centers_[:,1], 'ro')
从上述结果中也能很明显的看出K-Means聚类的凸型边界,但很明显,此时聚类结果并不合理,上图中有多处彼此相邻但却不属于同一类的情况出现。此时如果我们希望捕获上述非凸的边界,则需要使用一种基于密度的聚类方法,也就是我们将要介绍的DBSCAN密度聚类。
不过此处需要强调的是,尽管上述情况主观判断不太合理,但最终上述结果是否可用,还是需要结合实际业务进行考虑,这也是无监督学习算法没有统一的评价标准的具体表现,此处我们只能说K-Means算法性能使得其无法捕获不规则边界,但这个特性导致的结果好坏无法直接通过数据结果进行得出。
和K-Means依据中心点划分数据集的思路不同,DBSCAN聚类则是试图通过寻找特征空间中点的分布密度较低的区域作为边界,并进一步以此划分数据集。正是因为以低密度区域作为边界,DBSCAN最终对数据的划分边界很有可能是不规则的,从而突破了K-Means依据中心点划分数据集从而使得边界是凸型的限制。
当然对于给予密度的聚类算法,最重要的是给出密度的相关的定义。在DBSCAN中,我们通过两个概念和密度密切相关:分别是半径(eps)与半径范围内点的个数(num_samples)。对于数据集中任意一个点,只要给定一个eps,就能算出对应的num_samples,例如对于下述A点,在一个eps范围内,num_samples为7(包括自己)。
当然,eps越小、num_samples越大,则说明该点所在区域密度较高。当然,我们可以据此设置一组参数,即半径(eps)和半径范围内至少包含多少点(min_samples)作为评估指标,来对数据集中不同的点进行密度层面的分类:例如我们令eps=Eps(某个数),min_samples=6,并且如果某点在一个Eps范围内包含的点的个数大于min_samples,则称该点为核心点(core point),如下图中的A点;而如果某个点不是核心点,但是在某个核心点的一个eps领域内,则称该点为边界点,例如下图B点;而如果某点既不是核心点也不是边界点,则成该点为噪声点,如下图的C点。
当我们对数据集中的所有点完成上述三类的划分之后,接下来,我们一个eps范围内的核心点化为一个簇,并且将边界点划归到一个临近的核心点所属簇中,并且抛弃噪声点,最终完成数据集整体的划分。而实际上DBSCAN整体划分过程,就是在将高密度区域划分成一个簇,将低密度区域视作不同簇的分界线。
很明显,在DBSCAN聚类中,核心参数就是eps和min_samples,其不仅可以控制高低密度区域的划分,并且可以实际控制聚成几类的结果:当eps较小而min_samples较大时,核心点的定义较为严格、同一个簇对簇内的密度要求更高,此时更容易划分出多个簇;反之,划分成的簇的个数可能会更少。接下来我们尝试在sklearn进行DBSCAN建模试验。
from sklearn.cluster import DBSCAN
DBSCAN?
#Init signature:
#DBSCAN(
# eps=0.5,
# *,
# min_samples=5,
# metric='euclidean',
# metric_params=None,
# algorithm='auto',
# leaf_size=30,
# p=None,
# n_jobs=None,
#)
其中核心参数就是上面介绍的eps和min_samples,其他参数都是距离计算相关和最近邻计算相关的参数,暂时可以不做考虑。接下来围绕上述月牙型数据进行建模:
# 实例化模型
DB = DBSCAN(eps=0.3, min_samples=10)
# 训练模型
DB.fit(X)
#DBSCAN(eps=0.3, min_samples=10)
# 查看聚类结果
plt.scatter(X[:,0], X[:,1], c = DB.labels_)
能够发现,DBSCAN通过捕获低密度区域作为聚类划分的边界线,使得最终聚类结果和预想中的情况更加接近。接下来我们尝试在上一小节定义的数据集中执行DBSCAN聚类:
np.random.seed(23)
X, y = arrayGenCla(num_examples = 20, num_inputs = 2, num_class = 2, deg_dispersion = [2, 0.5])
plt.scatter(X[:, 0],X[:, 1],c=y)
DB = DBSCAN(eps=0.5, min_samples=5).fit(X)
plt.scatter(X[:,0], X[:,1], c = DB.labels_)
DB.labels_
#array([ 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0,
# 0, -1, 0, 1, 1, 1, 1, 1, 1, 1, -1, 1, 1, 1, 1, 1, -1,
# 1, 1, 1, 1, 1, 1])
能够发现,DBSCAN舍弃了一些点(噪声点,标签为-1),并且将数据聚成两类。当然我们也可以尝试减少eps、提高min_samples:
DB = DBSCAN(eps=0.4, min_samples=6).fit(X)
plt.scatter(X[:,0], X[:,1], c = DB.labels_)
此时出现了更多的噪声点,而如果降低密度要求,则会有更多的点被划分到不同的簇中。
DB = DBSCAN(eps=0.6, min_samples=4).fit(X)
plt.scatter(X[:,0], X[:,1], c = DB.labels_)
至此,我们就完成了DBSCAN算法从理论到实践的全过程。还是需要值得一提的是,由于聚类算法的特殊性,导致聚类算法本身的原理和应用难度都远低于有监督学习算法,并且在实际进行聚类的过程中,选择算法的过程要重于调参的过程,而且该过程需要加入实际业务背景作为聚类效果好坏评估的更加具体的指导意见。目前介绍的K-Means和DBSCAN,能够在实际分类性能上形成很好的互补,建议在使用的过程中先尝试K-Means,如效果不佳,则可考虑尝试使用DBSCAN进行聚类。