ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

K-means聚类算法详解:从原理推导到Python手写实现与避坑指南

K-means聚类算法详解:从原理推导到Python手写实现与避坑指南 K-means大概是聚类分析里最让人又爱又恨的算法。爱它是因为原理简单到可以用一句“近朱者赤”概括恨它是因为一旦数据分布不规整、初始值选得不好它给出的结果就会让你怀疑人生。我在做用户分群、图像压缩和异常检测时经常用它可以说这是绝大多数人接触聚类分析时遇到的第一个算法。这一篇是“聚类分析之——Kmeans算法”系列的第一讲先把最核心的骨架搭起来它到底解决什么问题、背后的数学原理怎么推、如何手写一个能跑的版本以及新手最常踩的几个坑。搞懂这些你再看任何教程里那些调一行KMeans(n_clusters3)的代码都会觉得顺理成章。1. 没有标签的数据怎么找到隐藏的群——K-means的用武之地1.1 分类和聚类到底差在哪很多人刚接触机器学习时会把分类和聚类混在一起。分类是有监督学习训练数据里已经标好了“这是猫”“这是狗”模型要学的是从特征到标签的映射规则聚类是无监督学习手里只有一堆特征值没有任何人告诉算法“哪几个样本应该是一伙的”一切全凭数据自身的分布规律去“物以类聚”。K-means解决的正是后者给定一堆没有标签的数据点把它划分成k个簇让同一个簇里的样本尽量相似不同簇里的样本尽量不同。这里的“相似”在K-means里被具体化为“距离近”默认情况下就是欧氏距离。这种差异带来的决策路径完全不同。分类算法追求的是“预测准不准”可以用准确率、召回率来评估聚类算法追求的是“分完之后簇内是否紧致、簇间是否分离”评估方式也更偏几何。理解这一点你才不会拿着分类器的标准去苛求K-means。1.2 哪些真实场景在用K-meansK-means的应用范围比我一开始想的要广得多。举几个我实际接触过的例子用户分群电商平台把会员按消费金额、购买频次、活跃时长分成高价值客户、潜力客户、沉睡客户然后分别推送不同的营销策略。K-means跑一遍就能给出初步群组运营同事再根据业务认知给每个簇打标签效率很高。图像压缩把一张图片的每个像素看作RGB三维空间里的点K-means聚成16或32个簇用每个簇的中心颜色替代簇内所有像素的颜色。这样一张图只需要保存16种颜色和每个像素的簇编号体积大幅下降肉眼看上去还基本能接受。文档主题归类先把文本转成TF-IDF向量再聚类可以得到粗略的话题分组作为人工打标签前的预筛选。异常检测把正常样本聚成几类后离所有簇中心都很远的点往往就是潜在异常。这个方法在生产环境监控里很常见因为正常数据通常有规律而异常数据“格格不入”。你会发现这些场景有个共同点没有现成的标签或者人工打标成本太高先让聚类算法快速把数据粗分一遍再由人去理解每个簇的含义。1.3 为什么K-means能经久不衰K-means从1957年被提出到现在已经六十多年了依然活跃在各种业务和竞赛里。原因无非几点第一计算效率高。它的时间复杂度大约是O(n·k·t)n是样本量k是簇数t是迭代次数。在同样规模的数据上它比层次聚类、DBSCAN这类算法快得多百万级样本也能扛得住。第二解释性强。每个簇最终落在一个“质心”上这个质心就是簇内所有样本的平均值你可以直接说“这个簇的中心代表什么样的人、什么样的行为模式”业务方听得懂。第三容易做扩展。后面出现的Mini-Batch K-means、K-means、GMM高斯混合模型本质上都脱胎于它的思想。把K-means吃透再学这些算法会非常轻松。2. 从“近朱者赤”的直觉到数学表达K-means的核心原理2.1 算法的一句话描述K-means要做的事情用一句话说就是给定k个初始中心把每个样本分配给离它最近的中心然后重新计算每个簇的中心反复迭代直到中心不再变化。这里有两个关键变量一个是簇数k需要你提前指定另一个是初始中心怎么选直接影响最终结果。后面会专门讲现在先假设我们已经随意给了k个初始位置。2.2 目标函数误差平方和SSE为什么迭代能保证结果越来越好因为在K-means背后有一个明确要最小化的目标函数——误差平方和Sum of Squared Errors简称SSE[ SSE \sum_{i1}^{k}\sum_{x \in C_i} |x - \mu_i|^2 ]其中 (C_i) 是第i个簇(\mu_i) 是第i个簇的质心(|x - \mu_i|) 是样本点到质心的欧氏距离。SSE衡量的是所有样本点到其所属簇中心的距离平方之和。目标很直观让每个样本都离自己所属的簇中心尽可能近。SSE越小说明簇内越紧凑聚类效果通常越好。你可以把这个目标理解为“把所有点重新分组后每个点到组的中心点的距离总和最小”。就像搬家之前要先把物品分类打包每一箱里的东西越相似箱子内部“缝隙”越小整体打包效果越理想。2.3 迭代优化背后的坐标下降思想K-means的迭代过程其实是一个“坐标下降”的经典例子。它没有直接一步求出全局最优解而是把优化问题拆成两步固定质心分配样本在质心不变的情况下把每个样本分给最近的质心这一步是在优化每个样本的簇归属使SSE最小。固定分配更新质心在簇归属不变的情况下用每个簇内所有样本的均值作为新质心这一步也是在优化质心位置。不断交替执行这两步SSE会单调下降不会上升最终收敛到某一个局部最优解。注意是局部最优不是全局最优——初始点不同可能收敛到不同的山谷。这个思想非常像“先假设一个答案然后用它去修正另一个答案再回过头来修正第一个答案”交替优化直到稳定。很多其他算法也用了同样的套路理解K-means之后你再看EM算法会格外亲切。2.4 一个二维小数据的演算过程光讲理论太干我手动演算一个超级简单的例子。假设有6个点A(0,0)、B(1,0)、C(0,1)、D(5,5)、E(6,5)、F(5,6)肉眼一看大概可以分成左下角三个点和右上角三个点。设k2初始质心随便选两个(\mu_1 (0,0))和A重合(\mu_2 (5,5))和D重合。第一轮分配点A(0,0)到μ1的距离是0到μ2的距离是(\sqrt{50}≈7.07)所以分给簇1。点B(1,0)到μ1距离1到μ2距离(\sqrt{41}≈6.4)分给簇1。点C(0,1)到μ1距离1到μ2距离(\sqrt{41}≈6.4)分给簇1。点D(5,5)到μ1距离7.07到μ2距离0分给簇2。点E(6,5)到μ1距离(\sqrt{61}≈7.8)到μ2距离1分给簇2。点F(5,6)到μ1距离(\sqrt{61}≈7.8)到μ2距离1分给簇2。第一轮得到簇1{A,B,C}簇2{D,E,F}。更新质心(\mu_1 ((010)/3, (001)/3) (1/3, 1/3))(\mu_2 ((565)/3, (556)/3) (16/3, 16/3))第二轮分配重新计算距离后A、B、C依然离μ1近D、E、F依然离μ2近簇归属不再变化。质心也就不再更新算法收敛。这个例子因为初始点选得恰好只迭代两轮就结束但现实中的数据往往要迭代几十次。3. 手写一个最简单的K-means把原理变成代码3.1 伪代码与按部就班实现哪怕你平时都用现成库我也强烈建议手写一次K-means。这个过程能帮你把原理刻进脑子里。伪代码长这样输入数据X和簇数k随机选择k个样本作为初始质心循环对每个样本计算它到k个质心的距离归入距离最近的簇对每个簇计算簇内所有样本的均值作为新质心如果质心变化小于阈值或者达到最大迭代次数就退出循环输出每个样本的簇标签和最终质心。3.2 Python实现的细节与结果验证我这里用纯Python加NumPy实现一遍不依赖sklearn方便你看清每个步骤import numpy as np def kmeans_custom(X, k, max_iters100, tol1e-4): # 随机初始化从样本中取k个不重复的点 n_samples, n_features X.shape indices np.random.choice(n_samples, k, replaceFalse) centroids X[indices] for i in range(max_iters): # 计算每个样本到每个质心的欧氏距离 distances np.linalg.norm(X[:, np.newaxis, :] - centroids, axis2) # 分配簇 labels np.argmin(distances, axis1) # 更新质心 new_centroids np.zeros_like(centroids) for j in range(k): new_centroids[j] X[labels j].mean(axis0) # 检查收敛 if np.linalg.norm(new_centroids - centroids) tol: break centroids new_centroids return labels, centroids写完了怎么验证我会随机生成三个高斯分布的点簇然后跑上面这个自定义函数和sklearn的结果对比标签是否一致。注意因为初始质心是随机选的结果可能不完全一致但经过多次运行后SSE应该趋近相同水平。这里的核心细节是距离计算。X[:, np.newaxis, :]把X变成(n_samples, 1, n_features)和centroids的(k, n_features)做广播运算得到(n_samples, k, n_features)再在最后一个轴求norm就得到了每个样本到每个质心的距离矩阵。这一步是向量化写法不用for循环大数据集上效率高很多。3.3 为什么收敛判定常用“质心不再变化”很多初学K-means的人会问为什么迭代停止条件是“质心变化很小”而不是“SSE不再变化”其实两者在绝大多数情况下等价因为质心更新后如果质心没有位移SSE也就不会再变化。但用质心位移作为判据有个额外好处它的数值可以直接和欧氏距离比较方便设定阈值。比如tol1e-4表示前后两轮质心的平均移动距离小于0.0001就认为收敛。还有一点要注意labels j可能为空簇。比如随机初始化选得不好或者数据分布很极端某个簇一个样本都没分到这时X[labels j].mean(axis0)会得到NaN。正规实现里会处理空簇问题——要么重新初始化质心要么把空簇删掉再随机生成一个。这是手写代码最容易踩的雷。4. 初值敏感与K值选择两个绕不开的痛4.1 随机初始化带来的“答案不唯一”问题K-means对初始质心的敏感程度可能超出很多新手的想象。我刚开始做聚类时吃过一次大亏同一份数据跑了十次K-means每次得到的簇都不一样而且某些结果明显不合理——有的簇只有一个点有的簇把两个明显分开的群体糅在一起。原因就是SSE不是凸函数它有很多局部极小值。随机初始化就像把一个人丢在群山环绕的盆地某个位置他只能顺着坡走到最近的谷底而没法保证自己站在全国最低点。解决这个问题最朴素的方案是多跑几次每次随机初始化然后在所有结果里选SSE最小的那个。sklearn的n_init10默认就是干这个的。你最好也养成这个习惯如果你自己写K-means外层一定至少循环10次保留最优结果。4.2 K-means更聪明的初始化方法既然随机初始化不靠谱那能不能让初始质心之间离得远一点K-means就是这个思路。它的流程是从数据集中随机选第一个质心计算每个样本到最近质心的距离D(x)按概率(\frac{D(x)^2}{\sum D(x)^2})采样下一个质心距离现有质心越远的点越容易被选中重复直到选出k个质心。这样做的好处是初始质心能散布到数据的各个区域避免都扎堆在一起。K-means带来的提升往往是立竿见影的它让算法更快收敛也更大几率找到更好的局部最优。sklearn里的initk-means就是默认选项所以一般使用者体会不到初值影响。但如果你在SPSS或自己写代码时没注意初始化方式结果可能就很飘。4.3 肘部法则怎么用才靠谱K-means必须指定k但实际业务里通常没人告诉你应该分3群还是5群。这时候最常用的就是肘部法则Elbow Method。做法跑一系列不同的k值比如1到10记录每个k对应的SSE画成折线图。随着k增大每个簇内部更紧凑SSE肯定会下降但下降速度存在拐点。曲线像胳膊肘一样有个明显的弯折这个“肘部”对应的k就是相对合理的簇数。听起来很美但实际用起来经常很尴尬——真实数据的SSE曲线常常非常平滑没有明显拐点。这时候我会结合业务需求来定比如运营说我需要五个用户等级那就k5或者配合轮廓系数综合判断。单一指标本身不可靠结合可解释性才靠谱。4.4 轮廓系数给聚类效果打个分轮廓系数Silhouette Coefficient是另一个常用指标它不依赖外部标签而是从几何紧密度和分离度两个方面评价。对于每个样本i定义a(i)为它到同一簇内所有其他样本的平均距离簇内不相似度定义b(i)为它到最近的其他簇中所有样本的平均距离最近簇不相似度。样本i的轮廓系数是[ s(i) \frac{b(i) - a(i)}{\max(a(i), b(i))} ]s(i)的范围在-1到1之间。接近1表示样本离自己的簇很近、离别的簇很远聚类很好接近0表示样本在两个簇边界上接近-1表示样本可能被分错了簇。把所有样本的s(i)取平均就得到整体轮廓系数。我在确定k值时经常把肘部法则和轮廓系数结合起来先看肘部确定一个大致范围再算轮廓系数选峰值对应的k。这样比单看一条曲线靠谱得多。5. 数据预处理与常见翻车点给新手的避坑清单5.1 标准化为什么是K-means的前置条件K-means极度依赖距离计算而距离对量纲非常敏感。假设你在做用户分群特征是“年龄”单位岁范围18到60和“年消费金额”单位元范围0到100000。如果直接用原始值算欧氏距离年消费金额的数值量级会完全主导距离年龄几乎不起作用。这会导致聚类结果基本只按消费金额划分年龄维度被忽略。解决办法是标准化最常用的是Z-score标准化[ x \frac{x - \mu}{\sigma} ]或者用Min-Max缩放到[0,1]。经过标准化后每个特征在距离计算中的权重才大致均衡。你可能会说“某些特征本来就该重要一些啊”如果是那样可以在标准化后做特征加权而不是放任量纲扭曲距离。这个顺序很重要。5.2 离群点、分类变量与高维数据该怎么办离群点对K-means的影响是被放大的因为质心是均值一个离群点就能把质心拉偏。比如大部分用户月消费在1000元以下突然一个用户消费100万这个点的存在会让整个簇的质心严重偏离正常群体。遇到这种情况我先用IQR或DBSCAN把明显的离群点剔除掉再做聚类。分类变量也容易让人翻车。K-means算的是数值距离不能直接吃“性别”“城市”这样的文本标签。常见做法是转成哑变量One-Hot但哑变量维度会膨胀而且对欧氏距离的解读变得抽象。更合理的方案是先用PCA等做降维或在特征层面对离散变量做合适的编码比如目标编码。千万别直接把字符串塞给算法。高维数据则是另一个坑。维度越高距离度量越“稀释”样本之间的欧氏距离差异越来越不明显K-means的效果会大幅下降。一般超过几十维我会先做PCA降维到可视化能理解的二维或三维再跑聚类。5.3 局部最优与多次尝试策略前面讲过K-means收敛到局部最优所以单次运行的结果不能完全信任。除了用K-means初始化外还可以用“多次随机重启动”策略每次都用不同的随机初始值跑完整轮迭代最后选出SSE最小的一次作为最终结果。sklearn的n_init参数就是控制这个的。默认值是10如果你发现结果不稳定可以加大到50甚至100。在Spark MLlib里也可以通过设置seed和多次训练来做。我在自己的项目里习惯写一个外层循环记录每次的SSE然后打印出最优结果对应的种子值方便复现。否则你换一次随机数结果就变了领导会觉得你“做得不准”。5.4 关于K-means的“硬分配”与距离度量选择K-means是硬聚类每个样本只能归属到一个簇没有“我既有点像A又有点像B”这种模糊输出。这是它的简化之处也是它的局限。如果业务需要软概率可以考虑GMM。默认距离是欧氏距离但在某些场景下它并不是最佳选择。比如文本向量经过TF-IDF或词嵌入通常用余弦相似度更合适因为文本向量的模长受文章长度影响较大。如果你用余弦相似度需要修改算法里的距离度量。sklearn里的KMeans不支持自定义距离只能靠特征变换比如归一化向量再算欧氏距离就等价于余弦相似度。这是一个非常隐蔽但实用的技巧。6. 在常用工具里快速落地K-means6.1 Pythonsklearn不到十行跑通如果你只是想快速看看数据分成几类的效果直接用sklearn最省事from sklearn.cluster import KMeans import pandas as pd df pd.read_csv(data.csv) X df[[feature1, feature2]] # 记得先标准化 kmeans KMeans(n_clusters3, initk-means, n_init10, random_state42) df[cluster] kmeans.fit_predict(X) print(kmeans.cluster_centers_) print(kmeans.inertia_) # SSE这里的inertia_就是SSE。你可以在调参时监控它用肘部法则选k。整个流程大概是读数据、标准化、选k、跑聚类、可视化落点、解读簇含义。6.2 SPSS也能做——菜单式操作的思路不少非编程背景的同学会用SPSS做聚类分析。其实SPSS里就有K-means只是离得比较远。菜单路径一般是“分析 → 分类 → K-均值聚类”。需要手动填聚类数k还可以把最终聚类中心和每个样本的类别保存到数据表里。用SPSS做K-means时最需要注意的还是标准化。SPSS不会自动做Z-score你需要先用“描述统计”把变量标准化并另存为新变量再用标准化后的变量跑聚类。很多教程忽略了这一步直接拿原始变量跑导致量纲大的变量主导聚类结果不可用。6.3 Matlab里的小案例Matlab的命令行也支持K-meansX [0 0; 1 0; 0 1; 5 5; 6 5; 5 6]; [idx, C] kmeans(X, 2);idx是每个样本的簇标签C是最终质心矩阵。Matlab默认使用K-means初始化同样支持设置Replicates参数来多次运行取最优。做科研绘图时我常用gscatter(X(:,1), X(:,2), idx)来画聚类散点图效果很直观。6.4 系列预告第二部分会深入哪些内容这一篇主要讲了K-means的基础原理和手动实现。第二篇我打算重点聊三个进阶话题一是怎么用池化的方式做特征缩放让聚类结果更稳定二是当数据量到千万级别时Mini-Batch K-means和Spark MLlib里的K-means有什么区别参数该怎么调三是评估聚类的三种不同路线以及如何把聚类结果变成业务故事讲给非技术同事听。如果你在看完这篇之后能自己写一个简单的K-means并且知道为什么需要标准化、为什么初始化和k值都让人头疼那这一篇的目标就达成了。下一次我们继续把剩下的硬骨头啃下来。
返回列表