从零实现K-Means聚类算法:原理、细节与Python代码解析

发布时间:2026/10/7 17:05:03
从零实现K-Means聚类算法:原理、细节与Python代码解析 K-Means大概是机器学习里最容易被低估的算法了。表面上看它简单到可以用一句话概括把数据分成K堆每堆找中心点反复迭代到稳定。但真到了自己动手从零实现的时候你才会发现里面塞满了细节——质心怎么初始化、距离怎么度量、空簇怎么处理、K值怎么定、怎么判断收敛。这些点在任何调库教程里都被轻描淡写地带过了只有把算法一步步抠出来写的时侯你才对聚类这件事有真正的体感。这篇文章就围绕K-Means从零实现展开目标是把算法拆到底不调sklearn用Python和NumPy从空手开始把K-Means写出来同时把每个关键环节的原理和取舍讲清楚。适合正在学机器学习基础、准备面试、或者想摆脱只会调库状态的读者。别急着抄代码跟着走一遍你会发现很多之前被忽略的坑恰恰是理解这个算法的钥匙。1. 项目概述与整体设计思路1.1 K-Means到底在解决什么问题先说本质。K-Means解决的是无监督学习里的聚类问题给你一堆没有标签的数据点希望把它们自动划分成K个群体使得同一个群体内的点尽量相似不同群体间的点尽量不同。相似度用距离衡量K是人为指定的群体数量。一个很典型的场景是用户分群。假设你有电商平台一万个用户的消费记录每个用户有两个特征月均消费金额、月均购买次数。你想把这批用户分成几个有区分度的运营群体但事先没有任何标签告诉你谁属于哪类。这时候K-Means就能派上用场把每个用户当成二维平面上的一个点K-Means自动把它们聚成K堆每堆就是一个群运营策略就能针对每个群单独设计。这个自动背后的逻辑很朴素叫物以类聚同一类的点到该类中心点的距离应该比到其他类中心点的距离更近。算法做的就是反复调整中心点和点的归属直到这个朴素逻辑被满足到最好。1.2 算法的标准流程拆解从流程上讲K-Means的迭代过程可以压缩成四步选定K值并初始化K个聚类中心质心。对每个样本点计算它到K个质心的距离把点归属到距离最近的质心所在的簇。对每个簇重新计算簇内所有点的均值把这个均值作为新的质心。重复步骤2和3直到质心位置不再明显变化或者达到最大迭代次数。四个步骤看似简单但每一步都有多种实现选择和隐藏的坑。比如步骤1直接随机选K个点当质心在大多数情况下能跑但偶尔会陷入很差的局部最优用K-Means初始化能避免这个问题但很多人不知道它为什么有效。步骤2里的距离度量默认用欧氏距离但数据特征量纲差很大时直接算欧氏距离会出问题。步骤3的均值计算看起来没有争议但遇到空簇时程序直接崩掉也不是没可能。所以从零实现不是把伪代码翻译成Python就完事了你需要对每一步的边界情况和原理做决策。这也是我认为从零实现最有价值的地方迫使你真正去理解算法的假设和局限而不是把sklearn当成黑盒。1.3 为什么非要自己从零写一遍有人会说现在调个库几行代码就搞定聚类何必自己造轮子我的看法是造轮子的过程是在为调库建立可靠的地基。一方面面试和工作中经常会被问到K-Means的原理细节比如K-Means比随机初始化好在哪里K-Means的收敛性如何保证为什么K-Means不适用于类别型特征。这些如果不亲手实现你很难答得深入。另一方面工程实践中你大概率会遇到标准库覆盖不到的变体需求比如要加距离权重、要处理稀疏向量、要在分布式环境下做K-Means。这时候你对照标准实现做修改前提就是你已经吃透了原始算法。还有一层原因是培养调试直觉。我见过太多人出了聚类结果不对第一反应是换个库试试而不是检查数据是否归一化、K值是否选得合理、初始化是否太糟糕。从零实现一遍你会养成先检查数据、再检查算法、最后才怀疑代码的习惯——这个顺序在真实项目中非常重要。2. 核心细节解析每一步背后的原理与取舍2.1 距离度量的选择欧氏距离并不是唯一答案K-Means里最常用的距离度量是欧氏距离公式是d(x, y) sqrt(Σ(xi - yi)²)为什么默认用欧氏距离因为它和均值天然匹配。K-Means的质心更新用的是簇内均值这个均值在数学上恰好就是让簇内欧氏距离平方和最小的点。换句话说用欧氏距离 均值更新是一个自洽的组合目标函数是簇内误差平方和SSE均值就是它的最优解。但这不是說欧氏距离在所有场景下都对。如果特征列的量纲差异很大比如一个特征是年龄20到60另一个特征是收入5000到50000欧氏距离会把收入的影响放大几十倍年龄特征基本失去作用。解决办法是聚类前先做标准化z-score或min-max归一化这是新手最常忽略的一步。也有场景需要换距离度量比如文本向量常用余弦相似度但余弦相似度不是严格意义上的距离不满足三角不等式K-Means用起来需要额外处理再比如数据里有明显的离群点可以考虑曼哈顿距离它对异常值更稳健。但从零实现的第一版我建议先把欧氏距离做扎实再去扩展别的度量。2.2 质心初始化K-Means为什么比随机初始化强初始化是K-Means里最容易翻车的一环。最朴素的做法是从数据里随机选K个点作为初始质心。问题在于随机选到的质心可能非常集中几个质心挤在一团导致迭代收敛到很差的局部最优解。K-Means的思想是让初始质心尽量分散。具体步骤从数据中随机选第一个质心。对每个样本点计算它到最近质心的距离D(x)并按D(x)²的概率加权随机选出下一个质心。重复步骤2直到选出K个质心。用距离的平方做概率权重意味着离已有质心越远的点越有机会被选成新质心。这样初始质心就能拉开距离避免一开局就挤在一起。我在从零实现时直接改用了K-Means实测效果和随机初始化相比收敛速度更快而且多次运行结果的稳定性明显提高。要注意的是K-Means也不是万能的在特别高维的数据或者噪声密集的数据上它同样可能选到孤立点当质心但因为这是初始化的概率问题通常不会造成灾难性后果。2.3 收敛判断质心不动才算完标准实现中收敛判断有两种常见方式一种是比较前后两轮的质心位移量如果每个质心的位置变化都小于某个极小阈值比如1e-4就判定收敛另一种是设置最大迭代次数到了就强制停止。这里有一个实践细节光看质心位置可能会错过震荡。在个别数据分布下簇的归属可能在两个质心之间反复横跳质心位置看起来变动不大但标签已经来回换了。稳妥的方法是同时跟踪损失函数SSE的变化——如果SSE不再下降再判定收敛。这比只看质心位置更可靠。到底用哪个阈值没有统一标准但我习惯把阈值设为1e-4最大迭代次数设为300。前者的作用是确保精度后者的作用是防止死循环。你还需要在代码里加一个提前终止条件如果连续几轮SSE变化率小于阈值就提前结束。这能省下不少无聊的迭代。2.4 K值怎么选肘部法和轮廓系数的实战用法K值是K-Means里最让人头大的超参数。它不像学习率那样有经验范围K值完全取决于数据本身的分布结构。选K有两个最常用的工具肘部法和轮廓系数。肘部法对不同的K值比如1到10分别做聚类计算每个K对应的SSE然后画折线图。SSE会随着K增大单调下降但在某个K值之后下降速度骤减曲线出现一个像手肘一样的拐点这个拐点就是推荐的K。轮廓系数对每个样本计算轮廓系数取值范围在-1到1之间越接近1表示聚类效果越好。一般取多个K值分别计算所有样本的平均轮廓系数平均值最高的K就是优选方案。但这两个方法在实际数据上经常给出不同建议。我的经验是肘部法更直观但拐点有时不明显轮廓系数比较精确但对噪声敏感。不要迷信任何单一指标最好结合业务场景判断——如果你是给用户分群做运营K值要从我能为每个群设计差异化策略这个角度来定而不是完全交给算法。3. 从零实现完整代码与逐行讲解3.1 环境准备与数据结构设定从零实现不需要任何机器学习库只需要Python和NumPy再加一个Matplotlib用于可视化验证。动手前先明确输入输出输入一个形状为(n_samples, n_features)的二维数组X以及类别数K。输出每个样本的簇标签长度为n_samples的一维数组以及K个质心的坐标。为了后续可视化方便我会先造一个二维的模拟数据让点分布在几个簇附近。import numpy as np import matplotlib.pyplot as plt # 生成模拟数据3个簇每个簇200个点 np.random.seed(42) cluster_1 np.random.normal(loc[2, 2], scale0.5, size(200, 2)) cluster_2 np.random.normal(loc[8, 8], scale0.5, size(200, 2)) cluster_3 np.random.normal(loc[2, 8], scale0.5, size(200, 2)) X np.vstack([cluster_1, cluster_2, cluster_3]) # 可视化 plt.scatter(X[:, 0], X[:, 1], s10) plt.title(模拟数据分布) plt.show()这份数据有三个清晰的簇中心分布在(2,2)、(8,8)、(2,8)附近。用这样的数据来验证实现的正确性因为真实答案已知任何错误都能直观反映在图像上。3.2 核心函数实现距离计算与质心更新先写两个最基础的函数计算每个点到质心的距离、更新质心。def compute_distances(X, centroids): 计算每个样本点到每个质心的欧氏距离 X: (n_samples, n_features) centroids: (K, n_features) 返回: (n_samples, K) n_samples X.shape[0] k centroids.shape[0] # 利用广播计算 (n_samples, 1, n_features) - (1, k, n_features) diff X[:, np.newaxis, :] - centroids[np.newaxis, :, :] distances np.sqrt(np.sum(diff ** 2, axis2)) return distances def update_centroids(X, labels, k): 根据簇标签重新计算质心 n_features X.shape[1] new_centroids np.zeros((k, n_features)) for i in range(k): points_in_cluster X[labels i] if len(points_in_cluster) 0: new_centroids[i] points_in_cluster.mean(axis0) else: # 后续会详细讨论空簇怎么处理 new_centroids[i] X[np.random.choice(X.shape[0])] return new_centroidscompute_distances里用了NumPy的广播机制一步算出全部点对全部质心的距离矩阵不需要写双层循环。这是一个性能关键点如果写for i in range(n): for j in range(k)数据量一上万就会明显变慢。向量化是从零实现里一上来就要养成的好习惯。update_centroids里对空簇做了临时兜底处理从原始数据里随机抽一个点当质心。这里先不展开第三节单独讲空簇的深层处理。3.3 K-Means初始化实现初始化部分我直接实现K-Means它比随机初始化多一点点计算量但效果好很多。代码逻辑严格按照前面讲的概率选择思路来写。def kmeans_plusplus_init(X, k): K-Means初始化质心 n_samples X.shape[0] centroids np.zeros((k, X.shape[1])) # 第一步随机选第一个质心 first_idx np.random.choice(n_samples) centroids[0] X[first_idx] for i in range(1, k): # 计算每个点到最近质心的距离 distances compute_distances(X, centroids[:i]) min_distances np.min(distances, axis1) # 按距离平方的概率选下一个质心 probabilities min_distances ** 2 probabilities probabilities / probabilities.sum() next_idx np.random.choice(n_samples, pprobabilities) centroids[i] X[next_idx] return centroids这段代码里有几个细节值得注意。第一个质心是用np.random.choice均匀随机选的后面每个质心都用距离平方加权。probabilities / probabilities.sum()这一步是归一化确保所有概率加起来等于1np.random.choice的p参数才能正确按概率抽样。另外min_distances是每个点到已有质心集合的最近距离这保证了新质心选在离现有质心尽量远的位置。3.4 主循环完整K-Means封装把前面各部分组合起来写一个标准K-Means类。加上了SSE跟踪和收敛判断。class KMeans: def __init__(self, k3, max_iter300, tol1e-4): self.k k self.max_iter max_iter self.tol tol def fit(self, X): self.X X n_samples, self.n_features X.shape # 初始化质心 self.centroids kmeans_plusplus_init(X, self.k) # 记录历史SSE sse_history [] for iteration in range(self.max_iter): # 分配簇标签 distances compute_distances(X, self.centroids) labels np.argmin(distances, axis1) # 计算当前SSE sse 0 for i in range(self.k): points_in_cluster X[labels i] if len(points_in_cluster) 0: sse np.sum((points_in_cluster - self.centroids[i]) ** 2) sse_history.append(sse) # 更新质心 new_centroids update_centroids(X, labels, self.k) # 判断收敛质心位移小于阈值 movement np.linalg.norm(new_centroids - self.centroids) self.centroids new_centroids if movement self.tol: print(f第{iteration 1}轮迭代后收敛SSE{sse:.4f}) break else: print(f达到最大迭代次数停止迭代最终SSE{sse:.4f}) # 最终标签 distances compute_distances(X, self.centroids) self.labels np.argmin(distances, axis1) return self用一个对象来封装K-Means是常见的工程做法这样后续可以很方便地访问labels、centroids、sse_history。收敛判断这里用了质心位移量但我在第2.3节提过还可以结合SSE一起判断。代码里的for...else结构值得注意如果循环正常走完300次没有breakelse块会执行提示达到最大迭代次数。这是一个很容易被忽视但非常实用的Python细节。3.5 实验验证可视化聚类效果与SSE曲线跑一遍上面的实现看看结果。kmeans KMeans(k3, max_iter300, tol1e-4) kmeans.fit(X) # 可视化聚类结果 plt.figure(figsize(12, 4)) plt.subplot(1, 2, 1) plt.scatter(X[:, 0], X[:, 1], ckmeans.labels, cmapviridis, s10) plt.scatter(kmeans.centroids[:, 0], kmeans.centroids[:, 1], cred, markerx, s200, label质心) plt.title(K-Means聚类结果) plt.legend() plt.subplot(1, 2, 2) plt.plot(range(1, len(kmeans.sse_history) 1), kmeans.sse_history, markero) plt.title(SSE随迭代变化) plt.xlabel(迭代次数) plt.ylabel(SSE) plt.tight_layout() plt.show()我实测时这个实现会在几轮迭代内收敛SSE迅速下降然后趋于平缓。聚类结果和原始生成数据的三个簇完全对上质心坐标也基本落在真实中心(2,2)、(8,8)、(2,8)附近。这说明对于分布清晰、簇间分离度高的数据标准K-Means非常简单可靠。3.6 从零实现时需要避开的性能陷阱第一个陷阱是循环里重复计算距离矩阵。有人会写一个for iter in range(max_iter)然后在循环内又用for i in range(n_samples)逐个计算距离——在小数据集上没事但数据量到十万级别就会卡到怀疑人生。正确做法永远是向量化。第二个陷阱是直接用np.linalg.norm去循环算每个点到每个质心的距离这同样低效。上面代码里利用广播把距离矩阵一次性算出来不仅快而且代码更简洁。第三个陷阱是用Python列表存储所有中间结果而不预分配数组。在迭代过程中不断append是灵活的但如果你已经知道最终结果大小最好提前用np.zeros或np.empty分配好。这个习惯做起来很简单对性能的提升却立竿见影。4. 常见问题与调试实录4.1 空簇问题质心彻底失去了归属样本K-Means在迭代过程中偶尔会遇到一个簇的样本数为0的情况也就是空簇。这是我第一次从零实现时就中招的坑。原因通常是初始化时质心选得太偏或者某个簇的样本在迭代过程中全被其他质心抢走了。处理空簇有几种常用策略我按推荐的优先级排序策略一重新随机初始化空簇质心。这是最简单粗暴的方式给空簇一个随机点作为新质心让它重新参与竞争代码里我们用的就是这种兜底。缺点是可能再次陷入空簇。策略二选择离所有现有质心最远的点作为新质心。这样能让空簇质心跳到数据分布最稀疏的地方起到探索新区域的作用。比纯随机更聪明实现也不复杂。策略三从SSE最大的簇中分裂出一些样本给空簇。这个策略更激进但能直接降低全局SSE。我实际工程里最常用的是策略二。它效果好代码量也不大计算出每个点到最近质心的距离挑距离最大的那个点作为空簇的新质心即可。新手建议先用随机初始化兜底跑通流程后再升级为这个策略。4.2 局部最优问题同一份数据多次运行结果不同K-Means的迭代过程是一个贪心式下降最终收敛到的解取决于初始化落在哪里。你可能会遇到这样的情况跑三次算法三次的SSE都不一样其中一次明显比其他两次高。这是因为那次初始化让算法掉进了局部最优。解决办法有几个层次。最常用的是多重初始化同一份数据跑多次比如10次每次用不同的随机种子初始化最终取SSE最小的那次结果。这个技巧简单有效是工业界最通用的做法。我在实际写代码时会把整个fit过程包进一个循环记录所有运行中的最小SSE和对应模型最后返回最优结果。另外K-Means已经大幅降低了局部最优的概率但不能完全消除。如果数据太复杂还可以考虑使用二分K-MeansBisecting K-Means它把聚类过程拆成层次化的二分类稳定性会更好。但那是另一个话题了。4.3 特征量纲不一致聚类结果被某些特征主导我接手过一个真实案例数据包含消费频次和消费金额两个特征金额的数值动辄几千频次只有个位数。直接用K-Means聚类结果几乎是按金额单维度切分频次特征基本没起作用。这不是算法的问题是数据预处理的问题。解决办法是在聚类前做特征缩放。最常用的有标准化Z-score减去均值除以标准差让每个特征均值为0、方差为1。Min-Max归一化让每个特征缩放到[0, 1]区间。具体选哪个没有绝对答案。Z-score对离群点更稳健Min-Max对分布范围有硬性约束。我在大多数聚类场景下偏好Z-score因为它保留了数据分布的形状信息而Min-Max容易把长尾分布压扁。不过在文本或图像数据上Min-Max也很多见。关键是要理解K-Means对特征的尺度极其敏感不做归一化的聚类结果基本没有参考价值。4.4 聚类结果不可复现随机种子与工程化从零实现和调库有个非常大的区别库函数通常帮你管理了随机种子但自己实现时每次运行的初始化都是随机的。如果你在写论文、做实验对比或者给老板汇报结果不可复现会很尴尬。解决办法是固定随机种子。在NumPy层面用np.random.seed(42)就能让整个流程中的随机过程确定下来。如果代码里有多个随机源比如Python的random模块也用了需要一并固定。我在工程实践中还会把随机种子和算法参数一起记录到配置文件中这样任何一次实验结果都能追溯到生成它时的完整环境。另外一个工程细节不要在生产环境里直接使用上面的KMeans类建议在fit方法里加入数据校验比如检查输入是否包含NaN、特征维度是否一致、K是否小于样本数。这些校验代码看起来不起眼但在真实数据上能救你一命。4.5 常见问题速查表问题表现可能原因解决方案聚类结果每次运行都不一样随机初始化导致落入不同局部最优固定随机种子或采用多重初始化某个簇始终为空初始化质心过偏或样本被其他簇夺走空簇随机初始化或选最远点作为新质心某个特征主导聚类结果特征量纲差异过大聚类前做Z-score标准化或Min-Max归一化迭代很快停止但SSE偏高初始化太差掉入局部最优改用K-Means或多次重启数据量大时跑得极慢循环计算距离而非向量化使用广播/距离矩阵一次性计算轮廓系数很低聚类边界模糊K值选取不合理或数据本身无簇结构用肘部法重新选K检查数据分布质心坐标出现NaN空簇且均值计算时除零在均值计算前检查簇内样本数是否大于0这张表是我在实际调试中总结出的高频问题。如果你在从零实现时遇到奇怪的结果先对照这张表排查一遍大概率能找到原因。5. 结果评估与算法局限性5.1 评估聚类效果不要用SSE作为唯一指标很多人在K-Means实现完成后习惯用SSE看收敛但SSE本身不适合用来横向对比不同的K值或不同的聚类方案。原因很简单SSE一定是K越大越低K样本数时SSE降到0但这显然没有任何意义。我常用的评估指标有两个维度。内部指标不依赖真实标签包括轮廓系数、DB指数Davies-Bouldin Index、Calinski-Harabasz指数外部指标则需要有真实标签做对照比如调整兰德指数ARI、归一化互信息NMI。从零实现K-Means之后我强烈建议再手写一个轮廓系数的计算函数。轮廓系数的公式并不复杂对每个样本计算它与同簇其他样本的平均距离a再计算它与最近邻簇样本的平均距离b然后算(b - a) / max(a, b)。所有样本的平均值就是整体的轮廓系数。手写一遍不仅能加深对评估指标的理解也能反过来检验你的聚类实现是否正确。5.2 K-Means的适用边界什么时候它不workK-Means不是万能的它的核心假设是簇是凸形的——也就是说簇在特征空间中应该是近似球形或凸形的分布。这句话对新手来说可能有点抽象我换个方式说如果数据分布是月牙形、环形、嵌套的螺旋形K-Means几乎不可能把它们正确分开。原因在于K-Means的决策边界是Voronoi图是基于质心的线性划分。对于非凸形状的簇质心落在数据分布的空心位置划分出来的簇和真实结构完全对不上。检测这个问题的办法也很简单聚类完成后画图看一下如果质心明显落在数据的空洞区域说明数据和K-Means的假设不匹配。另外K-Means对离群点非常敏感。离群点会拉拽质心导致整个簇的划分被带偏。如果你清洗数据时发现异常值很多K-Means可能不是最优选可以考虑DBSCAN这类基于密度的聚类算法。5.3 K-Means的变体与应用扩展从零实现一遍标准K-Means之后你可以很自然地往几个方向扩展二分K-Means先不管K把所有数据当成一个簇然后反复把SSE最大的簇拆成两个子簇直到达到K个簇。这种贪心策略能显著减少随机初始化带来的波动。Mini-Batch K-Means每次迭代随机抽一部分数据做质心更新适合海量数据场景。代价是收敛精度略降但速度快很多。K-Means的核化版本Kernel K-Means把数据映射到高维空间后再做聚类能处理部分非线性可分的情况。加权K-Means给不同特征赋予不同权重适合有业务先验知识的场景。这些扩展大多是在标准实现上做小幅改动前提是你已经对标准实现烂熟于心。所以说到底从零实现一遍K-Means不是终点而是后续一系类聚类算法的起点。6. 最终总结与经验心得从零实现K-Means整个过程本身并不长几十行核心代码就能完成。但我一直觉得这个算法是机器学习入门阶段最适合手写的项目之一它够简单能在一天内从零写到可视化它又够复杂涉及初始化、距离度量、空簇处理、收敛判断、特征缩放、模型评估等一堆关键决策。把这些决策都做过一遍你对无监督学习的理解深度会和只调sklearn的人明显拉开差距。根据我自己的实操经验有几点特别想分享给准备动手的朋友不要追求代码行数少先追求逻辑清晰。把你写的每一步都对应回算法流程能在注释里写出这一步在干什么、为什么这么干才算真的理解了。等逻辑完全通了再考虑用向量化优化性能。调试时先用简单人造数据验证正确性再上真实数据。我见过太多人一上来就用真实业务数据跑K-Means结果出问题了也分不清是代码bug还是数据本身的分布问题。先用像本文开头那种生成数据跑通你心里对什么是对的有数了再面对真实数据时才能快速定位问题。最后别停留在只跑通标准版。试着把K-Means换成随机初始化把欧氏距离换成曼哈顿距离把均值更新改成中位数更新观察结果变化。这种改一个变量看一个结果的实验方式是理解算法鲁棒性的最好训练。从零实现K-Means的意义不在于写出一个可用的聚类器而在于通过亲手构建看清楚这个算法每一步的脾气和底牌。