写在前面:
写了也有几十篇博客了,这次是第一次使用
markdown
编辑器编写文档,虽然markdown
编辑器的使用不是那么容易。尤其是html标签和文本画图片的插入有些麻烦。不过难题总归是要克服的!加油!
K均值算法是聚类算法里最基础、最广泛的一类算法。这次机器学习作业需要自己手敲k-means算法,本文先分析一下k-means算法原理,再结合sklearn的官方库,手敲k-means算法。
在无监督学习 (unsupervised learning) 中,训练样本的标记是未知的,目标是通过对无标记训练样本的学习来揭示数据的内在性质及规律,为进一步的数据分析提供基础。
聚类试图将数据集中的样本划分成若干个通常不相交的子集,每个子集称为一个“ 簇 ” (cluster)。这样的划分可以挖掘出样本中潜在的概念(类别)①。
聚类算法一般分为三类:
- 原型聚类:此类算法假设聚类结构能通过一组原型刻画。 通常情况下,算法先对原型初始化。然后对原型进行迭代更新求解。采用不同的原型表示,不同的求解方式,将产生不同的算法。
- 密度聚类:此类算法假设聚类结构能够通过样本分布的紧密程度确定。 通常情况下,密度聚类算法从样本密度的角度来考察样本之间的可连接性,并基于可连续性样本不断扩展聚类簇以获得最终的聚类结果。
- 层次聚类:此类算法试图在不同层次对数据集进行划分,从而形成树形的聚类结构。 数据集的划分可采用“自底向上”的聚合策略,也可采用“自顶向下”的分拆策略。
K均值算法是一种典型的原型聚类。设给定的样本集:
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} 这些簇划分的最小化平方误差为:
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 \tag{1} E=i=1∑kx∈Ci∑∣∣x−μi∣∣22(1) 其中簇均值向量 μ i \mu_i μi为:
μ i = 1 C i ∑ x ∈ C i x (2) \mu_i=\frac{1}{C_i}\sum _{x\in C_i}x \tag{2} μi=Ci1x∈Ci∑x(2)
直观的说,式(1)在一定程度上刻画了簇内样本围绕簇均值向量的紧密程度, E E E值越小则簇内样本相似度越高。
导入需要用到的库文件:
# Common imports
import os
import numpy as np
import warnings
from scipy import sparse
# To plot pretty figures
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
# Where to save the figures
PROJECT_ROOT_DIR = "."
CHAPTER_ID = "Prototype-based_Clustering"
IMAGES_PATH = os.path.join(PROJECT_ROOT_DIR, "images", CHAPTER_ID)
os.makedirs(IMAGES_PATH, exist_ok=True)
# Make dir and config save
def save_fig(fig_id, tight_layout=True, fig_extension="png", resolution=300):
path = os.path.join(IMAGES_PATH, fig_id + "." + fig_extension)
print("Saving figure", fig_id)
if tight_layout:
plt.tight_layout()
plt.savefig(path, format=fig_extension, dpi=resolution)
# data preprocess
def preprocess(data_name):
x, y = [], []
with open(data_name, "r") as test_datasets:
for data in test_datasets.readlines():
data = data.strip("\n")
if data and data[0].isnumeric():
data = data.split(",")
data_x = [int(data_) for data_ in data]
else:
continue
x.append(data_x)
x = np.array(x)
return x
K-means 方法的实现过程核心只有三步:
1. 初始化K个样本点作为均值向量。
2. 求解各点距离各均值向量的距离并找出最优。
3. 更新均值向量。
下面分别介绍方法实现:
这里我们先说一下距离的求解,因为从初始均值向量开始这点就很重要。
通常来说,求解两点间的欧式距离是件很容易的事情:
( x a − x b ) 2 + ( y a − y b ) 2 \sqrt{(x_a-x_b)^2+(y_a-y_b)^2} (xa−xb)2+(ya−yb)2 但是实际上,对于我们这种需要遍历每个点与所有均值向量的距离的任务,采用如上实现是非常费时的。
这里我查阅了sklearn的k-means实现,参考这位博主的文章:NumPy之计算两个矩阵的成对平方欧氏距离,发现他们采用的是一种很有意思的矩阵运算方式:
先把原式展开为:
式中, ∘ \circ ∘ 表示按元素积 (element-wise product), 又称为 Hadamard 积; 1 ⃗ k \vec1_k 1k 表示维的全1向量 (all-ones vector),余者类推。上式中 1 ⃗ k \vec1_k 1k 的作用是计算 X ∘ X X \circ X X∘X 每行元素的和,返回一个列向量; 1 ⃗ n T {\vec1}^T_n 1nT 的作用类似于 NumPy 中的广播机制,在这里是将一个列向量扩展为一个矩阵,矩阵的每一列都是相同的。
所以,容易得到:
上述转化式中出现了 X Y T XY^T XYT(矩阵乘),矩阵乘在 NumPy 等很多库中都有高效的实现,对代码的优化是有好处的。
快速计算欧式距离实现代码如下:
def row_norms(self, X, squared=False):
'''计算行向量点积
:param X: Input Array
:param squared: whether square result
:return: norms
'''
# aij * bij -> ci
# Here a is same as b, so the result is ci=sum(ai^2)
norms = np.einsum('ij,ij->i', X, X)
# If squared, Vector result means each axis distance
if not squared:
np.sqrt(norms, norms)
return norms
def safe_sparse_dot(self, a, b, *, dense_output=False):
"""正确处理稀疏矩阵情况的点积。
Parameters
----------
a : {ndarray, sparse matrix}
b : {ndarray, sparse matrix}
dense_output : bool, default=False
When False, ``a`` and ``b`` both being sparse will yield sparse output.
When True, output will always be a dense array.
Returns
-------
dot_product : {ndarray, sparse matrix}
Sparse if ``a`` and ``b`` are sparse and ``dense_output=False``.
"""
if a.ndim > 2 or b.ndim > 2:
if sparse.issparse(a):
# sparse is always 2D. Implies b is 3D+
# [i, j] @ [k, ..., l, m, n] -> [i, k, ..., l, n]
b_ = np.rollaxis(b, -2)
b_2d = b_.reshape((b.shape[-2], -1))
ret = a @ b_2d
ret = ret.reshape(a.shape[0], *b_.shape[1:])
elif sparse.issparse(b):
# sparse is always 2D. Implies a is 3D+
# [k, ..., l, m] @ [i, j] -> [k, ..., l, j]
a_2d = a.reshape(-1, a.shape[-1])
ret = a_2d @ b
ret = ret.reshape(*a.shape[:-1], b.shape[1])
else:
ret = np.dot(a, b)
else:
ret = a @ b
if (sparse.issparse(a) and sparse.issparse(b)
and dense_output and hasattr(ret, "toarray")):
return ret.toarray()
return ret
def euclidean_distances(self, X, Y=None):
"""
将 X(和 Y=X)的行视为向量,计算每对向量之间的距离矩阵。
出于效率原因,一对行向量 x 和 y 之间的欧几里德距离计算如下:
dist(x, y) = sqrt(dot(x, x) - 2 * dot(x, y) + dot(y, y))
与其他计算距离的方法相比,此公式有两个优点。
首先,它在处理稀疏数据时计算效率高。
其次,如果一个参数发生变化而另一个参数保持不变,则可以预先计算“dot(x, x)” 或者 “dot(y, y)”。
"""
array = X
array_orig = X
if Y is X or Y is None:
Y = np.array(array, dtype=array_orig.dtype)
XX = self.row_norms(X, squared=True)[:, np.newaxis]
YY = self.row_norms(Y, squared=True)[np.newaxis, :]
distances = - 2 * self.safe_sparse_dot(X, Y.T, dense_output=True)
distances += XX
distances += YY
distances = distances.astype(np.float64)
np.maximum(distances, 0, out=distances)
# 确保向量和自身之间的距离设置为0.0。
# 由于浮点舍入错误,可能不是这样。
if X is Y:
np.fill_diagonal(distances, 0)
# 返回平方根的数值
return np.sqrt(distances, out=distances)
这里 row_norms 函数所谓 Einstein 约定求和就是略去求和式中的求和号。在此规则中两个相同指标就表示求和,而不管指标是什么字母,有时亦称求和的指标为“哑指标”。
a i b i = ∑ i = 1 3 a i b i = a 1 b 1 + a 2 b 2 + a 3 b 3 a_ib_i=\sum_{i=1}^3a_ib_i=a_1b_1+a_2b_2+a_3b_3 aibi=i=1∑3aibi=a1b1+a2b2+a3b3
具体约定如下:
1、在同一项中,如果同一指标(如上式中的 i i i )成对出现,就表示遍历其取值范围求和。这时求和符号可以省略,如上所示。
2、上述成对出现的指标叫做哑指标,简称哑标。表示哑标的小写字母可以用另一对小写字母替换,只要其取值范围不变。
3、当两个求和式相乘时,两个求和式的哑标不能使用相同的小写字母。为了避免混乱,常用的办法是根据上一条规则,先将其中一个求和式的哑标改换成其它小写字母。
初始化均值向量的核心就是先随机挑选一个中心点,再以所有点与之距离平均值为基准,挑选剩下的 N c l u s t e r s − 1 N_{clusters}-1 Nclusters−1 个点。每个点选取一定的候选中心,并最终取得最小候选中心作为选区的初始均值向量样本:
随机初始化样本点实现代码如下:
def stable_cumsum(self, arr, axis=None, rtol=1e-05, atol=1e-08):
"""使用高精度求和向量中给定轴元素,并检查最终值是否与sum匹配。
Parameters
----------
arr : array-like
To be cumulatively summed as flat.
axis : int, default=None
Axis along which the cumulative sum is computed.
The default (None) is to compute the cumsum over the flattened array.
rtol : float, default=1e-05
Relative tolerance, see ``np.allclose``.
atol : float, default=1e-08
Absolute tolerance, see ``np.allclose``.
"""
out = np.cumsum(arr, axis=axis, dtype=np.float64)
expected = np.sum(arr, axis=axis, dtype=np.float64)
if not np.all(np.isclose(out.take(-1, axis=axis), expected, rtol=rtol,
atol=atol, equal_nan=True)):
warnings.warn('cumsum was found to be unstable: '
'its last element does not correspond to sum',
RuntimeWarning)
return out
def kmeans_plusplus(self, X):
n_samples, n_features = X.shape
centers = np.empty((self.n_clusters, n_features), dtype=X.dtype)
# 设置本地种子试验的次数
n_local_trials = 2 + int(np.log(self.n_clusters))
# 随机选取第一个中心并跟踪点的索引
center_id = self.random_state.randint(n_samples)
indices = np.full(self.n_clusters, -1, dtype=int)
# 生成第一个索引对应的第一个点
centers[0] = X[center_id]
indices[0] = center_id
# 初始化最近距离列表并计算当前潜力
# 计算随机生成中心点与所有点的距离
closest_dist_sq = self.euclidean_distances(centers[0, np.newaxis], X)
# 求出与所有点的距离和
current_pot = closest_dist_sq.sum()
# 选择剩余的 n_clusters-1 个点
for c in range(1, self.n_clusters):
# 通过抽样选择候选中心,抽样概率与距离最近的现有中心的平方距离成正比
# 随机产生若干抽样个与前一个中心点距离和成比例的距离值
rand_vals = self.random_state.random_sample(n_local_trials) * current_pot
# 找到与这些随机距离值最接近的样本点索引,这些点即被确定为下一次候选中心
candidate_ids = np.searchsorted(self.stable_cumsum(closest_dist_sq),
rand_vals)
# 数值不精确可能导致候选id超出范围,不能超过最大索引距离
np.clip(candidate_ids, None, closest_dist_sq.size - 1,
out=candidate_ids)
# 计算所有点到每个候选中心的距离
distance_to_candidates = self.euclidean_distances(X[candidate_ids], X)
# 更新每个候选中心的最近距离平方和矩阵
# 每个样本点到min(候选点, 上一个中心点)的距离
np.minimum(closest_dist_sq, distance_to_candidates,
out=distance_to_candidates)
# 距离和矩阵
candidates_pot = distance_to_candidates.sum(axis=1)
# 通过最近距离平方和决定哪个候选点是最好的
best_candidate = np.argmin(candidates_pot)
# 最近的距离和
current_pot = candidates_pot[best_candidate]
# 最近的距离矩阵
closest_dist_sq = distance_to_candidates[best_candidate]
# 最近的点
best_candidate = candidate_ids[best_candidate]
# 添加在尝试中找到的最佳中心候选点
if sparse.issparse(X):
centers[c] = X[best_candidate].toarray()
else:
centers[c] = X[best_candidate]
indices[c] = best_candidate
return centers, indices
根据程序流程,获得初始均值向量后,进入循环过程:首先根据均值向量求簇划分;再根据簇划分更新均值向量。循环直至最终均值向量不再更新为止。
我们可以根据最终的结果调用 kmeans_one_from_cluster 函数查询所属簇的类别。
求解最小簇并更新均值向量:
def kmeans_divide_cluster(self, X, centers):
'''
根据计算的均值向量重新划分簇
:param X: Input sample array
:param centers: Center Vectors loc
:return:
'''
# 计算所有样本点与均值向量的距离
distance_to_candidates = self.euclidean_distances(X, centers)
# 计算距离最近的均值向量确定簇标记
index_list = np.argmin(distance_to_candidates, axis=1)
# 生成n个簇数组
divide_list = [[] for s in range(len(centers))]
# 划分簇
for i, s in enumerate(X):
index = divide_list[index_list[i]]
index.append(s.tolist())
return divide_list
def kmeans_update_MeanVector(self, center_list, divide_list):
'''
更新均值向量
:param center_list: Center Vectors loc
:param divide_list: Dots belonging lists
:return:
'''
raw_mean_vector = center_list
self.mean_vector = np.empty([len(divide_list), len(divide_list[0][0])])
# 更新均值向量
for i, s in enumerate(divide_list):
cluster = np.array(s)
self.mean_vector[i] = cluster.mean(axis=0)
# 判断是否还需要更新
if not np.all(raw_mean_vector - self.mean_vector):
return -1, self.mean_vector
else:
return 0, self.mean_vector
def kmeans_one_from_cluster(self, dot):
'''
计算某一样本所属簇
:param dot: one dot
:return:
'''
# 计算所有样本点与均值向量的距离
distance_to_candidates = self.euclidean_distances(dot, self.mean_vector)
# 计算距离最近的均值向量确定簇标记
index_list = np.argmin(distance_to_candidates, axis=1)
return index_list
绘制最终边界和分类中心点的代码如下:
def plot_data(X, color='k', subplt=plt):
'''
绘制样本点集
:param X: Input sample dots
:param color: color plot
:param subplt: which subplot plot
:return:
'''
subplt.plot(X[:, 0], X[:, 1], f'{color}x', markersize=2)
def plot_centroids(centroids, circle_color='w', cross_color='r', subplt=plt):
'''
绘制中心点
:param centroids: Center Vectors loc
:param circle_color: circle_color
:param cross_color: cross_color
:param subplt: which subplot plot
:return:
'''
subplt.scatter(centroids[:, 0], centroids[:, 1],
marker='o', s=35, linewidths=8,
color=circle_color, zorder=10, alpha=0.9)
subplt.scatter(centroids[:, 0], centroids[:, 1],
marker='x', s=2, linewidths=12,
color=cross_color, zorder=11, alpha=1)
def plot_decision_boundaries(kmeans, centers, X, divide_list,
resolution=1000, show_centroids=True, subplt=plt):
'''
绘制样本区域分界
:param kmeans: kmeans Category,ask classification result
:param centers: Center Vectors loc
:param X: Input sample dots
:param divide_list: Dots belonging lists
:param resolution: step for sampling boundaries
:param show_centroids: whether draw centroies
:param subplt: which subplot plot
:return:
'''
# 获取颜色表
color_dict = dict(mcolors.BASE_COLORS, **mcolors.CSS4_COLORS)
color_label = list(color_dict.keys())
# 生成取样点
mins = X.min(axis=0) - 0.5
maxs = X.max(axis=0) + 0.5
# 转换成坐标
xx, yy = np.meshgrid(np.linspace(mins[0], maxs[0], resolution),
np.linspace(mins[1], maxs[1], resolution))
dots = np.c_[xx.ravel(), yy.ravel()]
# 预测取样点所属分类矩阵
Z = kmeans.kmeans_one_from_cluster(dots)
# 将1维分类矩阵转换成2维
# 因为送入的xx,yy采样点是二维的,所以将处理完的结果重新转换回二维之后,
# 每个元素对应的即为(xx, yy)
Z = Z.reshape(xx.shape)
# 绘制分界背景
subplt.contourf(Z, extent=(mins[0], maxs[0], mins[1], maxs[1]),cmap="Pastel2")
# 绘制分界线
subplt.contour(Z, extent=(mins[0], maxs[0], mins[1], maxs[1]),linewidths=1, colors='k')
# 绘制样本点
for i, s in enumerate(divide_list):
plot_data(np.array(s), color=color_label[i%8], subplt=subplt)
# 绘制均值向量
if show_centroids:
plot_centroids(centers, subplt=subplt)
最终运行如下代码调用上述方法:
# 获得数据
X= preprocess('K-means.data')
# 运行kmeans算法,迭代获得结果
kmeans = KMeans(n_clusters=5, random_state=42)
centers, _ = kmeans.kmeans_plusplus(X)
while(True):
divide_list = kmeans.kmeans_divide_cluster(X, centers)
res, centers = kmeans.kmeans_update_MeanVector(centers, divide_list)
if res < 0:
break
# 绘图,上下子图分别绘制
fig = plt.figure(figsize=(14, 7))
# 上子图绘制原始样本
plt1 = fig.add_subplot(211)
plt1.scatter(X[:,0], X[:, 1], s=6, marker='.')
plt1.set_xlabel("$x_1$", fontsize=14)
plt1.set_ylabel("$x_2$", fontsize=14, rotation=0)
plt1.set_xlim(-0.5, 20.5)
# 下子图绘制分类结果
plt2 = fig.add_subplot(212)
plot_decision_boundaries(kmeans, centers, X, divide_list, subplt=plt2)
plt2.set_xlabel("$x_1$", fontsize=14)
plt2.set_ylabel("$x_2$", fontsize=14, rotation=0)
# 保存、显示
save_fig("k-means")
plt.show()
另有部分图片来自网络,如有重复请联系我及时删除。