ML实战:手写K-means算法

写在前面:

   写了也有几十篇博客了,这次是第一次使用markdown编辑器编写文档,虽然markdown编辑器的使用不是那么容易。尤其是html标签和文本画图片的插入有些麻烦。不过难题总归是要克服的!加油!

本文目录

  • 前言
  • 一、聚类与K均值算法
    • 1. 聚类原理
    • 2. K均值算法
    • 3. 算法步骤
  • 二、K均值算法的实现
    • 1. 引入库
    • 2. plt保存配置
    • 3. 数据导入
    • 4. k-means方法
      • 4.1 求解距离
      • 4.2 初始化均值向量
      • 4.3 求得最小簇归属并更新均值向量
    • 5. plt最终结果
  • 三、运行结果
    • 1. 运行代码
    • 2. 运行结果
  • 总结
  • 本文引用

前言

  K均值算法是聚类算法里最基础、最广泛的一类算法。这次机器学习作业需要自己手敲k-means算法,本文先分析一下k-means算法原理,再结合sklearn的官方库,手敲k-means算法。


一、聚类与K均值算法

1. 聚类原理

  在无监督学习 (unsupervised learning) 中,训练样本的标记是未知的,目标是通过对无标记训练样本的学习来揭示数据的内在性质及规律,为进一步的数据分析提供基础。
  聚类试图将数据集中的样本划分成若干个通常不相交的子集,每个子集称为一个“  ” (cluster)。这样的划分可以挖掘出样本中潜在的概念(类别)

聚类算法一般分为三类:

  • 原型聚类此类算法假设聚类结构能通过一组原型刻画。 通常情况下,算法先对原型初始化。然后对原型进行迭代更新求解。采用不同的原型表示,不同的求解方式,将产生不同的算法。
  • 密度聚类此类算法假设聚类结构能够通过样本分布的紧密程度确定。 通常情况下,密度聚类算法从样本密度的角度来考察样本之间的可连接性,并基于可连续性样本不断扩展聚类簇以获得最终的聚类结果。
  • 层次聚类此类算法试图在不同层次对数据集进行划分,从而形成树形的聚类结构。 数据集的划分可采用“自底向上”的聚合策略,也可采用“自顶向下”的分拆策略。

2. K均值算法

  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=1kxCixμi22(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=Ci1xCix(2)
  直观的说,式(1)在一定程度上刻画了簇内样本围绕簇均值向量的紧密程度, E E E值越小则簇内样本相似度越高。

3. 算法步骤

NO
YES
YES
NO
NO
YES
程序开始
随机选取K个样本作为均值向量
划分簇开始
计算样本与各均值向量之间的距离
根据距离最近的均值向量确定每个样本的簇标记
将样本划入相应的簇
遍历完成?
更新簇均值向量开始
计算各簇新均值向量μ
所有计算完成?
保留μ集合为簇新中心点
所有μ是否更新?
结束

二、K均值算法的实现

1. 引入库

导入需要用到的库文件:

# 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

2. plt保存配置


创建绘图保存路径及方法:
# 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)

3. 数据导入


数据预处理,这次的数据比较简单,省去了格式转换等过程,直接导入即可:
# 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

4. k-means方法

  K-means 方法的实现过程核心只有三步:

    1. 初始化K个样本点作为均值向量。
    2. 求解各点距离各均值向量的距离并找出最优。
    3. 更新均值向量。

  下面分别介绍方法实现:

4.1 求解距离

  这里我们先说一下距离的求解,因为从初始均值向量开始这点就很重要。
  通常来说,求解两点间的欧式距离是件很容易的事情:
( x a − x b ) 2 + ( y a − y b ) 2 \sqrt{(x_a-x_b)^2+(y_a-y_b)^2} (xaxb)2+(yayb)2   但是实际上,对于我们这种需要遍历每个点与所有均值向量的距离的任务,采用如上实现是非常费时的。
  这里我查阅了sklearn的k-means实现,参考这位博主的文章:NumPy之计算两个矩阵的成对平方欧氏距离,发现他们采用的是一种很有意思的矩阵运算方式:
  先把原式展开为:

原式展开
  下面逐项地化简或转化为数组/矩阵运算的形式:

ML实战:手写K-means算法_第1张图片

  式中, ∘ \circ 表示按元素积 (element-wise product), 又称为 Hadamard 积; 1 ⃗ k \vec1_k 1 k 表示维的全1向量 (all-ones vector),余者类推。上式中 1 ⃗ k \vec1_k 1 k 的作用是计算 X ∘ X X \circ X XX 每行元素的和,返回一个列向量; 1 ⃗ n T {\vec1}^T_n 1 nT 的作用类似于 NumPy 中的广播机制,在这里是将一个列向量扩展为一个矩阵,矩阵的每一列都是相同的。

ML实战:手写K-means算法_第2张图片

ML实战:手写K-means算法_第3张图片

  所以,容易得到:

ML实战:手写K-means算法_第4张图片

  上述转化式中出现了 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=13aibi=a1b1+a2b2+a3b3
  具体约定如下:
  1、在同一项中,如果同一指标(如上式中的  i i i )成对出现,就表示遍历其取值范围求和。这时求和符号可以省略,如上所示。
  2、上述成对出现的指标叫做哑指标,简称哑标。表示哑标的小写字母可以用另一对小写字母替换,只要其取值范围不变。
  3、当两个求和式相乘时,两个求和式的哑标不能使用相同的小写字母。为了避免混乱,常用的办法是根据上一条规则,先将其中一个求和式的哑标改换成其它小写字母。

4.2 初始化均值向量

  初始化均值向量的核心就是先随机挑选一个中心点,再以所有点与之距离平均值为基准,挑选剩下的 N c l u s t e r s − 1 N_{clusters}-1 Nclusters1 个点。每个点选取一定的候选中心,并最终取得最小候选中心作为选区的初始均值向量样本:


随机初始化样本点实现代码如下:

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

4.3 求得最小簇归属并更新均值向量

  根据程序流程,获得初始均值向量后,进入循环过程:首先根据均值向量求簇划分;再根据簇划分更新均值向量。循环直至最终均值向量不再更新为止。
  我们可以根据最终的结果调用 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

5. plt最终结果


绘制最终边界和分类中心点的代码如下:

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)

三、运行结果

1. 运行代码

最终运行如下代码调用上述方法:

# 获得数据
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()

2. 运行结果

我们的样本最终经过5 clusters 的分类结果如下:
ML实战:手写K-means算法_第5张图片


总结

  本次经过实战,对k-means算法有了进一步了解。对numpy的使用和matplotlib绘图库有了更深入的认识。   本次实践还学习了使用markdown编辑器及相关组件进行博客编写,markdown编辑器的可扩展性和展示性都比之前的传统富文本编辑器要好很多。以后应该会继续使用markdown进行博客撰写。

本文引用

  1. 知乎:NumPy之计算两个矩阵的成对平方欧氏距离
  2. 百度百科:爱因斯坦求和约定
  3. CSDN:np.einsum(爱因斯坦求和约定)
  4. 知乎:如何理解和使用NumPy.einsum?

另有部分图片来自网络,如有重复请联系我及时删除。


如有疑问或错误,欢迎和我私信交流指正。
版权所有,未经授权,请勿转载!
Copyright © 2021 by YuxiChen. All rights reserved.

你可能感兴趣的:(#,机器学习,算法,kmeans,聚类)