从零手撕K-Means聚类:原理、Python实现与实战调优全解析

📅 发布时间:2026/8/17 4:45:21
从零手撕K-Means聚类:原理、Python实现与实战调优全解析
1. 项目概述从“黑盒”到“白盒”的聚类之旅刚接触数据科学或者数学建模的朋友常常会听到“聚类分析”这个词感觉很高深像是算法自己在数据里发现了什么秘密。而“k-means”作为其中最经典、最常用的算法之一名字听起来简单但很多教程要么直接甩公式要么给个“黑盒”调用代码看完之后还是云里雾里它到底是怎么把一堆点分成几类的为什么我调出来的结果总是不理想那些初始点是怎么选的今天我就结合自己踩过的无数坑用最“白盒”的方式手把手带你拆解k-means聚类并附上从零实现的、带详细注释的Python源码。我们的目标不是仅仅学会调用sklearn的一行代码而是真正理解其内在的“肌肉”和“骨骼”明白每一个参数变动背后的数学意义和实际影响。无论你是正在准备数学建模竞赛的学生还是希望夯实基础的算法工程师这篇笔记都将帮你把k-means从“听说过”变成“完全拿捏”。2. k-means核心思想与数学原理拆解2.1 直观理解什么叫“物以类聚”抛开数学公式k-means的想法非常朴素。想象你有一堆散落在地上的玻璃珠数据点你的任务是把它们按颜色特征分到k个不同的碗簇里。你怎么做一个很自然的想法是先随便找k个位置各放一个“代表珠”初始聚类中心。看看地上的每一颗珠子把它归到离它最近的那个“代表珠”所在的碗里。等所有珠子都分完每个碗里都有一堆珠子了。这时原来那个“代表珠”的位置可能已经不是这个碗里珠子的“中心”了。于是我们重新计算每个碗里所有珠子位置的平均值把这个平均值点作为新的“代表珠”。重复第2步和第3步直到“代表珠”的位置不再发生明显变化或者说碗里的珠子成员稳定下来。这个过程就是k-means的核心迭代分配Assignment和更新Update。它的目标是让同一个碗里的珠子彼此尽量靠近簇内紧凑不同碗的珠子彼此尽量远离簇间分离。这个目标在数学上被形式化为最小化所有数据点到其所属簇中心的距离平方和也就是我们常说的误差平方和Sum of Squared Errors, SSE。2.2 数学形式化目标函数与迭代步骤设我们有数据集 $X {x_1, x_2, ..., x_n}$ 每个数据点 $x_i$ 是一个d维向量。我们要将其划分为k个簇 $C {C_1, C_2, ..., C_k}$ 每个簇有一个中心点 $\mu_j$。k-means的目标是找到簇划分C和簇中心$\mu$ 使得以下目标函数SSE最小化 $$J(C, \mu) \sum_{j1}^{k} \sum_{x \in C_j} ||x - \mu_j||^2$$其中$||x - \mu_j||$ 表示数据点x到簇中心$\mu_j$的欧氏距离。平方意味着我们对远离中心的点给予更大的惩罚。这个优化问题是一个NP难问题无法直接求出全局最优解。k-means采用了一种启发式的迭代优化策略来寻找局部最优解其步骤如下初始化Initialization从n个数据点中随机选择k个点作为初始簇中心 $\mu_1^{(0)}, \mu_2^{(0)}, ..., \mu_k^{(0)}$。这是整个算法中影响最大也最不稳定的环节之一。分配步骤Assignment Step对于数据集中的每一个数据点 $x_i$ 计算它到k个簇中心的距离并将其分配到距离最近的簇中心对应的簇中。 $$C_j^{(t)} { x_i : || x_i - \mu_j^{(t)} ||^2 \le || x_i - \mu_p^{(t)} ||^2 \ \forall p, 1 \le p \le k }$$ 这里 $t$ 表示当前迭代次数。更新步骤Update Step重新计算每个簇的中心点。新的簇中心是该簇所有数据点的均值这也是“means”一词的由来。 $$\mu_j^{(t1)} \frac{1}{|C_j^{(t)}|} \sum_{x \in C_j^{(t)}} x$$迭代Iteration重复步骤2和步骤3直到满足停止条件。常见的停止条件有簇中心的位置变化小于某个阈值如1e-4。簇的成员不再发生变化。达到预设的最大迭代次数。注意这个迭代过程本质上是一种坐标下降法Coordinate Descent。在分配步骤我们固定簇中心$\mu$优化簇分配$C$以降低SSE在更新步骤我们固定簇分配$C$优化簇中心$\mu$以降低SSE。每一步都保证SSE不会增加因此算法最终会收敛到一个局部最优点。2.3 核心假设与算法局限理解一个算法的局限和它的原理同等重要。k-means有几个很强的隐含假设球形簇假设它使用欧氏距离作为相似度度量这隐含地假设各个簇是凸形的特别是球形或超球形且大小相近。对于流形、环形或不规则形状的簇k-means效果会很差。各向同性假设它对所有特征方向一视同仁。如果数据在不同维度上的尺度量纲差异巨大必须进行标准化如Z-score标准化否则距离计算会被大尺度的特征主导。需要指定k值你必须事先知道或确定要将数据分成几类。k值选择不当会得到无意义的结果。对初始值敏感由于寻找的是局部最优解不同的初始中心可能导致完全不同的聚类结果和不同的SSE最终值。对噪声和离群点敏感均值计算受极端值影响大离群点会显著拉动簇中心的位置。明白了这些我们就能在应用时保持清醒我的数据符合这些假设吗如果不符合我该怎么办这引出了后续的优化技巧和变种算法。3. 从零实现k-means源码逐行精讲理解了原理我们动手实现它。我将代码分为几个函数并附上详尽的注释。你可以直接复制到Jupyter Notebook或Python文件中运行。3.1 核心函数实现import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import make_blobs class KMeansFromScratch: 从零实现的K-Means聚类算法类。 强调可读性和教学性性能并非最优。 def __init__(self, n_clusters3, max_iter300, tol1e-4, random_stateNone): 初始化KMeans参数。 参数: n_clusters (int): 要形成的簇数量即k值。 max_iter (int): 单次运行的最大迭代次数。 tol (float): 收敛阈值。当簇中心移动的范数小于此值时停止迭代。 random_state (int): 随机种子用于复现结果。 self.n_clusters n_clusters self.max_iter max_iter self.tol tol self.random_state random_state self.centroids None # 簇中心点 self.labels_ None # 每个样本所属的簇标签 self.inertia_ None # SSE即样本到其最近簇中心的平方距离之和 def _initialize_centroids(self, X): 初始化簇中心。这里采用最简单的随机选择法。 更优的方案是K-Means后续会讨论。 参数: X (np.ndarray): 形状为(n_samples, n_features)的数据矩阵。 返回: np.ndarray: 初始化的簇中心形状为(n_clusters, n_features)。 np.random.seed(self.random_state) # 随机选择数据点的索引 indices np.random.choice(X.shape[0], self.n_clusters, replaceFalse) # 根据索引选取初始中心 centroids X[indices] return centroids def _compute_distance(self, X, centroids): 计算每个数据点到所有簇中心的距离。 使用向量化操作避免低效循环。 参数: X (np.ndarray): 数据点形状(n_samples, n_features)。 centroids (np.ndarray): 簇中心形状(n_clusters, n_features)。 返回: np.ndarray: 距离矩阵形状(n_samples, n_clusters)。 dist[i, j] 表示样本i到中心j的欧氏距离。 # 利用广播机制计算 (x - c)^2 # X[:, np.newaxis, :] 形状变为 (n_samples, 1, n_features) # centroids[np.newaxis, :, :] 形状变为 (1, n_clusters, n_features) # 相减后形状为 (n_samples, n_clusters, n_features) 再平方求和开方 distances np.sqrt(((X[:, np.newaxis, :] - centroids[np.newaxis, :, :]) ** 2).sum(axis2)) return distances def _assign_clusters(self, distances): 根据距离矩阵将每个样本分配到最近的簇中心。 参数: distances (np.ndarray): 距离矩阵形状(n_samples, n_clusters)。 返回: np.ndarray: 每个样本的簇标签形状(n_samples,)。 # argmin返回每一行每个样本最小值的列索引即最近中心的索引 labels np.argmin(distances, axis1) return labels def _update_centroids(self, X, labels): 根据当前的簇分配重新计算每个簇的中心均值。 参数: X (np.ndarray): 数据点。 labels (np.ndarray): 每个样本的簇标签。 返回: np.ndarray: 更新后的簇中心。 new_centroids np.zeros((self.n_clusters, X.shape[1])) for j in range(self.n_clusters): # 获取属于第j簇的所有样本 cluster_points X[labels j] if len(cluster_points) 0: # 计算均值作为新中心 new_centroids[j] cluster_points.mean(axis0) else: # 极端情况某个簇没有分配到任何点则随机重新初始化该中心 # 在实际应用中更复杂的策略是选择距离当前中心最远的点作为新中心 new_centroids[j] X[np.random.randint(0, X.shape[0])] return new_centroids def _compute_inertia(self, X, labels, centroids): 计算当前聚类状态下的SSE误差平方和。 参数: X, labels, centroids: 数据、标签和中心。 返回: float: SSE值。 inertia 0.0 for j in range(self.n_clusters): cluster_points X[labels j] if len(cluster_points) 0: # 计算簇内所有点到其中心的距离平方和 inertia ((cluster_points - centroids[j]) ** 2).sum() return inertia def fit(self, X): 在数据X上拟合K-Means模型。 参数: X (np.ndarray): 训练数据形状(n_samples, n_features)。 返回: self: 返回实例自身。 # 1. 初始化簇中心 self.centroids self._initialize_centroids(X) for i in range(self.max_iter): # 2. 计算所有点到所有中心的距离 distances self._compute_distance(X, self.centroids) # 3. 分配簇标签 labels self._assign_clusters(distances) # 4. 更新簇中心 new_centroids self._update_centroids(X, labels) # 5. 检查收敛中心点移动的距离是否小于阈值 # 计算所有中心点移动的欧氏距离范数这里用Frobenius范数 centroid_shift np.linalg.norm(new_centroids - self.centroids) # 6. 更新中心 self.centroids new_centroids # 如果已收敛则提前退出循环 if centroid_shift self.tol: print(f迭代 {i1} 次后收敛。) break else: # 如果for循环正常结束未break说明达到了max_iter print(f达到最大迭代次数 {self.max_iter} 可能未完全收敛。) # 最终分配一次标签并计算SSE final_distances self._compute_distance(X, self.centroids) self.labels_ self._assign_clusters(final_distances) self.inertia_ self._compute_inertia(X, self.labels_, self.centroids) return self def predict(self, X): 预测新数据点的簇标签。 参数: X (np.ndarray): 新数据形状(n_samples, n_features)。 返回: np.ndarray: 预测的簇标签。 distances self._compute_distance(X, self.centroids) labels self._assign_clusters(distances) return labels3.2 代码使用示例与可视化让我们用sklearn的make_blobs生成一些模拟数据测试我们的实现并可视化整个过程。# 1. 生成模拟数据 np.random.seed(42) # 生成500个样本2个特征4个中心点簇标准差为0.8 X, y_true make_blobs(n_samples500, centers4, cluster_std0.8, random_state42) # 2. 使用我们实现的KMeans进行聚类 kmeans KMeansFromScratch(n_clusters4, max_iter300, tol1e-4, random_state42) kmeans.fit(X) labels kmeans.labels_ centroids kmeans.centroids print(f簇中心坐标:\n{centroids}) print(fSSE (Inertia): {kmeans.inertia_:.4f}) # 3. 可视化结果 plt.figure(figsize(12, 5)) # 子图1真实标签如果已知 plt.subplot(1, 2, 1) plt.scatter(X[:, 0], X[:, 1], cy_true, s30, cmapviridis, edgecolork) plt.scatter(np.array([X[:,0].mean() for _ in range(4)]), np.array([X[:,1].mean() for _ in range(4)]), marker*, s300, cred, labelPossible Centers (Illustration)) plt.title(Ground Truth Clusters (if available)) plt.xlabel(Feature 1) plt.ylabel(Feature 2) plt.legend() # 子图2K-Means聚类结果 plt.subplot(1, 2, 2) plt.scatter(X[:, 0], X[:, 1], clabels, s30, cmapviridis, edgecolork) plt.scatter(centroids[:, 0], centroids[:, 1], marker*, s300, cred, labelCluster Centroids) plt.title(K-Means Clustering Result) plt.xlabel(Feature 1) plt.ylabel(Feature 2) plt.legend() plt.tight_layout() plt.show()这段代码会生成一个对比图左图是数据真实的生成分布如果我们知道的话右图是我们的k-means算法找出的簇和中心。你可以清晰地看到算法如何将空间划分成几个区域。实操心得在编写_compute_distance函数时我最初使用了双层循环计算500个点对4个中心的距离在数据量稍大时速度慢得令人发指。改用基于numpy的广播机制进行向量化计算后性能提升了两个数量级。向量化是Python科学计算的生命线务必掌握。同时注意在_update_centroids中处理了空簇的情况这是实现鲁棒性时必须考虑的细节。4. 关键问题深度剖析与实战技巧实现了一个能跑的k-means只是开始。要让它在实际项目中真正发挥作用你必须面对并解决以下几个核心问题。4.1 如何选择最佳的k值这是应用k-means时被问得最多的问题。k值不是算法给你的而是需要你根据数据和业务目标来确定的。以下是几种常用方法1. 肘部法则Elbow Method这是最直观的方法。其思想是随着k增大样本被划分得更细SSE自然会下降。但当k增加到真实簇数附近时SSE的下降幅度会突然变缓之后趋于平缓。这个拐点就像手肘对应的k值就是较优的选择。def plot_elbow_method(X, max_k10): 绘制肘部法则图 inertias [] K range(1, max_k1) for k in K: kmeans KMeansFromScratch(n_clustersk, random_state42) kmeans.fit(X) inertias.append(kmeans.inertia_) plt.figure(figsize(8, 5)) plt.plot(K, inertias, bo-) plt.xlabel(Number of clusters (k)) plt.ylabel(Inertia (SSE)) plt.title(Elbow Method For Optimal k) plt.grid(True) plt.show() # 使用上面的数据 plot_elbow_method(X, max_k10)你需要观察曲线找到那个“肘点”。但很多时候这个点并不明显需要结合其他方法判断。2. 轮廓系数Silhouette Coefficient轮廓系数结合了簇内的凝聚度和簇间的分离度。对于单个样本i其轮廓系数$s(i)$计算如下 $$s(i) \frac{b(i) - a(i)}{\max{a(i), b(i)}}$$ 其中$a(i)$是样本i到同簇其他样本的平均距离凝聚度$b(i)$是样本i到最近其他簇中所有样本的平均距离分离度。$s(i)$的取值范围是[-1, 1]越接近1说明聚类越合理。我们可以计算所有样本轮廓系数的平均值作为对当前k的评价。from sklearn.metrics import silhouette_score # 注意这里使用sklearn的score函数我们的实现可以添加此功能 def evaluate_k_with_silhouette(X, max_k10): scores [] K range(2, max_k1) # 轮廓系数要求k2 for k in K: kmeans KMeansFromScratch(n_clustersk, random_state42) labels kmeans.fit(X).labels_ score silhouette_score(X, labels) scores.append(score) print(fk{k}, Silhouette Score: {score:.4f}) plt.figure(figsize(8,5)) plt.plot(K, scores, ro-) plt.xlabel(Number of clusters (k)) plt.ylabel(Silhouette Score) plt.title(Silhouette Analysis For Optimal k) plt.grid(True) plt.show() evaluate_k_with_silhouette(X, max_k10)选择轮廓系数最大的k值。3. 业务理解与可视化有时数据本身没有绝对正确的k。你需要结合业务目标。例如对客户分群是分成“高价值”、“中价值”、“低价值”3类还是更精细的5类此时可以将不同k值下的聚类结果降维如用PCA到2维后可视化看哪个结果在业务解释上最合理。注意事项肘部法则和轮廓系数都是启发式方法并非绝对真理。对于结构复杂的数据如嵌套的环形它们可能给出误导性建议。永远不要脱离对数据本身和业务场景的观察。4.2 如何优化初始中心的选择——K-Means我们之前随机选择初始中心这可能导致算法收敛到较差的局部最优解且每次运行结果不稳定。K-Means是一种智能初始化方法其核心思想是让初始的簇中心彼此尽可能远离。K-Means初始化步骤从数据点中随机均匀地选择第一个簇中心。对于每个数据点$x_i$计算它到已选中心中最近的那个的距离$D(x_i)$。依据概率$p_i D(x_i)^2 / \sum_{j} D(x_j)^2$随机选择下一个中心点。距离越远的点被选中的概率越大。重复步骤2和3直到选出k个初始中心。这种策略显著提高了找到全局较优解的概率和算法的稳定性。sklearn的KMeans默认初始化方法就是k-means。我们可以将其加入到我们的实现中class KMeansPlusPlus(KMeansFromScratch): 继承自KMeansFromScratch 使用K-Means初始化 def _initialize_centroids(self, X): np.random.seed(self.random_state) n_samples, n_features X.shape centroids np.zeros((self.n_clusters, n_features)) # 步骤1随机选择第一个中心 first_idx np.random.randint(n_samples) centroids[0] X[first_idx] for c in range(1, self.n_clusters): # 步骤2计算每个点到最近中心的距离平方 distances self._compute_distance(X, centroids[:c]) # 只计算到已选中心的距离 min_distances np.min(distances, axis1) ** 2 # D(x)^2 # 步骤3根据距离平方计算概率并依概率选择下一个中心 prob min_distances / min_distances.sum() next_idx np.random.choice(n_samples, pprob) centroids[c] X[next_idx] return centroids4.3 数据预处理标准化与异常值处理标准化至关重要。如果特征A的取值范围是[0, 10000]而特征B是[0, 1]那么计算欧氏距离时特征A将完全主导结果特征B的作用微乎其微。常用的标准化方法有Z-score标准化(x - mean) / std使数据均值为0标准差为1。适用于数据分布近似正态的情况。Min-Max归一化(x - min) / (max - min)将数据缩放到[0, 1]区间。对异常值敏感。from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_scaled scaler.fit_transform(X) # 然后在X_scaled上运行KMeans异常值处理。k-means对异常值非常敏感一个远离群体的点会严重扭曲簇中心的位置。处理方法包括可视化发现通过箱线图、散点图检查。统计方法如使用Z-score绝对值大于3或IQR法则识别。使用更稳健的算法变种如K-Medoids用中位数代替均值作为中心点。4.4 评估聚类效果对于无监督学习没有绝对的“正确答案”但我们可以从内部和外部进行评估。内部评估指标无需真实标签SSE (Inertia)我们一直在优化的目标。越小越好但需结合肘部法则看。轮廓系数如上所述越高越好在[-1,1]之间。Calinski-Harabasz指数簇间离散度与簇内离散度的比值值越大表示聚类效果越好。外部评估指标需要真实标签如果你的数据有真实类别如我们生成的make_blobs数据可以用来验证。调整兰德指数 (Adjusted Rand Index, ARI)衡量两个数据划分之间的一致性取值范围[-1,1]1表示完全一致0表示随机划分。互信息 (Mutual Information, MI)衡量两个划分共享的信息量值越大越好有标准化版本NMI。同质性、完整性、V-measure从不同角度衡量聚类结果与真实标签的匹配程度。from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score # 假设我们有真实标签 y_true 和聚类标签 labels ari adjusted_rand_score(y_true, labels) nmi normalized_mutual_info_score(y_true, labels) print(fAdjusted Rand Index: {ari:.4f}) print(fNormalized Mutual Info: {nmi:.4f})5. 高级话题与算法变种当标准k-means无法满足需求时了解其变种是必要的。5.1 K-Medoids应对噪声与异常值K-Means使用均值作为中心对异常值敏感。K-Medoids则选择簇内实际存在的一个数据点作为中心Medoid这个点是簇内所有点到其距离之和最小的点。它使用绝对误差和SAD而非平方误差和SSE作为目标函数因此更稳健。经典的PAM算法是求解K-Medoids的一种方法但计算成本比K-Means高。5.2 二分K-Means一种层次化策略二分K-Means不是一次性找到k个簇而是采用一种“自上而下”的层次分裂策略开始时将所有点视为一个簇。选择当前所有簇中SSE最大的那个簇对其进行一次标准的2-means聚类将其一分为二。重复步骤2直到簇的数量达到k。 这种方法有时能产生比直接进行k-means更好的结果因为它每次只关注局部最优分裂。5.3 基于密度的聚类DBSCAN与k-means的对比当数据不是球形分布或者簇的密度不均匀、形状不规则时k-means会失效。DBSCANDensity-Based Spatial Clustering of Applications with Noise基于密度进行聚类能发现任意形状的簇并能识别噪声点。它不需要预先指定k值但需要设置邻域半径eps和最小样本数min_samples两个参数。理解k-means的局限才能知道何时该换用DBSCAN这样的算法。6. 常见问题排查与性能优化实录在实际编码和调试中你肯定会遇到下面这些问题。6.1 算法不收敛或振荡症状SSE或中心点在几次迭代后开始上下波动无法稳定。可能原因与解决数据存在完全相同的点这会导致在分配时点到两个中心的距离相等引发随机分配和不稳定。检查并处理重复值。空簇问题如果某个簇在迭代中失去了所有点我们的代码虽然做了处理随机重初始化但这可能破坏收敛性。使用K-Means初始化可以极大减少空簇出现概率。学习率/动量思想高级在更新中心时不完全采用新计算的均值而是采用一个加权移动平均new_center old_center lr * (calculated_mean - old_center)其中lr是一个小的学习率。这可以平滑更新过程避免跳跃过大。6.2 聚类结果高度依赖初始值症状多次运行算法得到差异很大的SSE和聚类结果。标准解决方案使用K-Means初始化。这几乎是当前的最佳实践。进阶方案多次随机初始化。即使使用K-Means为了追求更优解可以运行算法多次如n_init10每次使用不同的随机种子初始化最后选择SSE最小的那次结果作为最终模型。sklearn的KMeans默认n_init10。6.3 处理大规模数据Mini-Batch K-Means标准K-Means每次迭代需要计算所有样本到所有中心的距离时间复杂度为$O(nki*d)$其中n是样本数k是簇数i是迭代次数d是特征数。对于海量数据如数百万样本这会非常慢。Mini-Batch K-Means是应对此问题的有效变种。其核心思想是每次迭代不使用全部数据而是随机抽取一个小批量mini-batch样本来更新中心。随机抽取一个batch的数据。为这个batch中的数据分配簇标签。用这个batch的数据而不是全部数据来更新簇中心。更新公式是一个在线学习的形式new_center old_center lr * (batch_mean - old_center)其中lr是学习率随迭代次数衰减。这种方法牺牲了一点精度但换来了巨大的速度提升非常适合在线学习或大规模数据场景。sklearn提供了MiniBatchKMeans实现。6.4 类别不平衡与样本权重在某些场景下样本的重要性不同。例如在客户分群中高净值客户的样本可能更重要。标准的k-means无法处理这一点。一个改进思路是在计算簇中心时引入样本权重 $$\mu_j \frac{\sum_{x_i \in C_j} w_i * x_i}{\sum_{x_i \in C_j} w_i}$$ 其中$w_i$是样本$i$的权重。在距离计算时也可以考虑权重但这会改变目标函数的几何意义需要谨慎设计。6.5 如何将聚类结果用于实际业务聚类本身不是终点而是手段。得到簇标签后你需要刻画簇特征分析每个簇在原始特征上的统计分布均值、中位数、分位数等。例如“簇1的客户年龄均值35岁年消费额中位数5万元偏好数码产品”。业务解读与命名根据特征分析给每个簇起一个业务上可理解的名字如“高价值年轻数码爱好者”、“低频次高客单价中年群体”。制定策略针对不同的簇制定差异化的运营、营销或产品策略。这才是聚类分析最终要创造的价值。踩过无数次坑之后我最大的体会是k-means是一个极其强大的基准工具但绝不是万能钥匙。它的简洁既是优点也是缺点。在动手之前花时间可视化你的数据理解它的分布形态思考业务目标比盲目调参重要十倍。对于那份从零实现的源码我建议你不仅运行它更要尝试修改它比如实现K-Means加入轮廓系数计算或者尝试处理空簇的不同策略。只有亲手“拆解”过这个算法你才能真正理解它的每一个“齿轮”是如何咬合的也才能在遇到复杂问题时知道该拧紧哪颗“螺丝”。