K-means聚类算法实战:从原理到代码,避开那些坑
简介这份案例面向机器学习与数据分析初学者以经典鸢尾花数据为基础聚焦花瓣长度和花瓣宽度两个特征一步步演示K-Means聚类建模、训练、中心点计算、可视化以及模型持久化的完整流程。资源共封装九个文件包括两个Python脚本、一个Jupyter Notebook交互式文档、鸢尾花CSV数据集、训练完成的pkl模型文件、两张聚类过程与结果图、一份接口说明文档以及一个pyc缓存文件压缩包总大小约225KB便于快速下载与本地运行。已有204人浏览学习。案例特别适合课设、毕业设计或算法入门时参考源码注释清楚帮助理解聚类核心原理可视化图片直观呈现簇划分与中心点位置接口文档进一步说明如何将模型封装成服务实现从算法到应用的衔接。学习后既可掌握K-Means理论要点也能独立完成一个完整的聚类小项目。 先说我自己的经历。最早做K-means聚类算法案例时我在sklearn里调好模型拿公开数据集跑了一遍十分钟就出了聚类图当时觉得这算法也太简单了。直到后来在一个电商客户分群项目里同样的代码却聚出一堆没有业务含义的簇我才明白K-means真正的门槛根本不在“跑通源代码”而在于数据准备、特征理解和评估方式。这篇文章会把一个完整的K-means聚类案例拆开讲包含可以直接运行的源代码和可复现的数据集生成方式从原理、公式、代码到评估指标全覆盖还会把我实际踩过的坑一并列出来。不管你是刚开始学机器学习还是已经上手但结果总是不理想这篇应该都能对你有帮助。1. K-means在聚什么一次迭代就是一次“重新选组长”1.1 聚类和分类的本质差别很多人第一次接触K-means时容易把它和分类混淆。分类任务是有标签的比如一封邮件是垃圾邮件还是正常邮件我们拿带标签的数据训练模型目的是让模型学会“判定”而聚类完全没有标签你手里只有一堆特征数据不知道哪些样本该是一组聚类要做的就是从数据本身的分布里“发现”分组结构。举一个特别生活化的例子。假设班级里来了50个新同学你想快速把他们分成4组怎么分最自然的做法是先随便指4个人当“临时组长”然后让其他同学各自站到自己觉得最像自己的组长那边。接着每组内部大家重新推举一个“更能代表本组”的人当新组长再重新分组。反复几次后分组会变得越来越稳定直到所有人都不会轻易换组。K-means干的就是这件事只不过它认的不是“像不像”而是“空间距离近不近”。1.2 一次迭代的两步操作K-means的完整流程可以拆成清楚的两个步骤所有源代码都是围绕这两个步骤展开的第一步是分配Assignment。每个样本分别计算到当前k个中心点的距离然后把自己归到距离最近的那个中心点名下形成k个临时簇。这里默认用的距离是欧氏距离数学形式是 ||x_i - μ_j||也就是坐标点之间的直线距离。第二步是更新Update。每个簇内所有样本的坐标求平均值得到新的中心点。这个新中心点不一定是数据里真实存在的某个点它只是几何上的“质心”但正是这个质心让下一轮重新分配时整体距离会比上一轮更小。这两个步骤交替执行就是完整的EM思想在K-means里的落地先猜一个中心点然后根据中心点分簇再根据簇更新中心点循环往复。1.3 收敛的判定标准算法什么时候停常见有三种判定方式中心点位置变化极小小于预设的阈值比如 tol1e-4所有样本所属簇不再变化达到最大迭代次数 max_iter比如300次。K-means的数学目标是让总误差平方和SSE也叫inertia最小J Σ_{i1}^{n} min_k ||x_i - μ_k||²这个值会随着迭代次数增加单调下降直到收敛。了解这一点很重要因为后面选k值时我们还要靠SSE画曲线这项技能在很多实训平台的关卡里也是核心考点。2. 手写一遍K-means的核心循环比调库更能感知收敛过程2.1 先准备一份干净的数据集做聚类案例建议先用可控的合成数据验证逻辑再替换成真实数据。我用sklearn的make_blobs生成一个包含4个簇的数据集样本量500簇内标准差0.6这样可视化出来边界清楚方便对照代码效果import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import make_blobs X, y_true make_blobs( n_samples500, centers4, cluster_std0.6, random_state42 ) plt.scatter(X[:, 0], X[:, 1], s10, alpha0.7) plt.title(Synthetic Dataset with 4 Blobs) plt.show()这里random_state42的作用是固定随机种子保证结果可复现。后面所有的实验都建议带上这个参数否则每次生成的数据集都不一样调试时会非常痛苦。2.2 用numpy实现K-means核心循环下面的代码不依赖机器学习库只用了numpy能让你清清楚楚看到每一步在做什么class KMeansManual: def __init__(self, n_clusters4, max_iter100, tol1e-4, random_state42): self.n_clusters n_clusters self.max_iter max_iter self.tol tol self.random_state random_state self.centers None self.labels None self.inertia_history [] def fit(self, X): rng np.random.RandomState(self.random_state) # 随机挑选k个样本作为初始中心点 init_idx rng.choice(X.shape[0], self.n_clusters, replaceFalse) self.centers X[init_idx].copy() for i in range(self.max_iter): # 分配计算每个点到每个中心的欧氏距离 distances np.sqrt( ((X[:, np.newaxis, :] - self.centers[np.newaxis, :, :]) ** 2).sum(axis2) ) labels np.argmin(distances, axis1) # 更新计算新中心点 new_centers np.array([ X[labels j].mean(axis0) for j in range(self.n_clusters) ]) # 计算本轮SSE sse sum( ((X[labels j] - self.centers[j]) ** 2).sum() for j in range(self.n_clusters) ) self.inertia_history.append(sse) # 收敛判定中心点变化量小于阈值则停止 if np.all(np.abs(new_centers - self.centers) self.tol): self.centers new_centers self.labels labels break self.centers new_centers self.labels labels这段代码里最核心的部分是距离矩阵的计算。X的形状是 (500, 2)通过X[:, np.newaxis, :]扩展成 (500, 1, 2)再减去 (1, 4, 2) 形状的中心点广播机制会自动把维度对齐成 (500, 4, 2)这样一次性就拿到了所有样本到所有中心的距离。看起来有点绕但它是numpy高效计算的精髓实用价值很高。2.3 观察迭代过程中的SSE变化模型拟合完后把SSE的变化过程画出来model KMeansManual(n_clusters4) model.fit(X) plt.plot(range(1, len(model.inertia_history) 1), model.inertia_history, markero) plt.xlabel(Iteration) plt.ylabel(SSE) plt.title(SSE Decrease During K-Means Iterations) plt.show()正常情况下第一条到第二条曲线之间SSE会骤降然后趋于平缓最终收敛。这个现象和算法原理是对应的初始中心点是随机选的第一轮分配必然很粗糙但中心点一旦更新整体距离就会大幅下降。我在这个手写版本里省略了空簇处理。如果某个中心点在分配后没有样本归入该簇X[labels j].mean(axis0)就会对空数组求均值产生NaN。实际项目中一定要加保护逻辑比如重新随机初始化中心点或者保留上一次不更新的中心点。这个细节在sklearn封装好的库里是自动处理的但自己手写时非常容易踩坑。3. 工程化案例从数据集清洗到标准化的完整代码3.1 不标准化聚类结果就可能是“假的”K-means依赖欧氏距离而欧氏距离对量纲极其敏感。举个实际例子假设你要对客户做分群特征是“年龄”和“年消费金额”。年龄范围大约18到70年消费金额可能是几千到几十万。如果不做任何处理距离计算时“年消费金额”会完全主导结果年龄几乎不起作用最后分出的簇只是按消费金额简单切片没有任何业务意义。我见过太多初学者直接拿原始特征跑聚类然后抱怨结果看不懂。真实做法是所有参与距离计算的特征必须统一到相近的尺度。sklearn里最常用的是StandardScaler它把每个特征变成均值为0、方差为1的标准正态分布。也有人用MinMaxScaler把特征压到[0,1]区间实际效果要看数据分布但标准化的思路一定不能省。3.2 可复现的完整sklearn源码下面是一段完整可复现的源代码包含数据读取、清洗、标准化、聚类、可视化和输出中心点import pandas as pd import numpy as np import matplotlib.pyplot as plt from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler from sklearn.datasets import load_iris # 这里以鸢尾花数据集为例实际项目替换成自己的CSV即可 # df pd.read_csv(your_data.csv) data load_iris() df pd.DataFrame(data.data, columnsdata.feature_names) # 1. 简单清洗删除缺失值 df df.dropna() # 2. 特征标准化 scaler StandardScaler() X_scaled scaler.fit_transform(df) # 3. 聚类 kmeans KMeans(n_clusters3, initk-means, random_state42) kmeans.fit(X_scaled) # 4. 给原始数据打上簇标签 df[cluster] kmeans.labels_ # 5. 输出每个簇的中心点注意这里是标准化后的中心 center_df pd.DataFrame( scaler.inverse_transform(kmeans.cluster_centers_), columnsdf.columns[:-1] ) center_df[cluster] range(3) print(center_df)initk-means这个参数值得多说一句。它和完全随机初始化不同会尽量让初始中心点彼此远离从而减少陷入局部最优的概率。这个是sklearn的默认值但在很多实训平台或旧代码里经常看到initrandom结果就是不同随机种子跑出完全不同的簇结构。3.3 两个最常用的聚类评估指标聚类没有标签怎么判断好坏我常用两个指标配合用一个是SSEinertia也就是每个样本到它所属簇中心点的距离平方和。这个值越小说明簇内越紧凑但它会随着k增大而持续下降不能单独用来选k。另一个是轮廓系数Silhouette Coefficient它的计算原理是对每个样本同时计算样本到自己簇内其他样本的平均距离a以及到最近另一个簇内样本的平均距离b轮廓系数(b-a)/max(a,b)。结果范围在-1到1之间越接近1表示聚类越合理接近0表示样本处在两个簇的边界上负值则表示可能被分错了。代码很简单from sklearn.metrics import silhouette_score score silhouette_score(X_scaled, kmeans.labels_) print(fSilhouette Score: {score:.4f})用这两个指标配合基本能判断聚类效果的大方向。但要注意这些指标只刻画几何上的“紧凑和分离”不保证业务上有意义业务解读又是另一层功夫。4. k值的麻烦肘部法则和轮廓系数帮你做决定4.1 肘部法则的操作细节K-means需要预先指定簇数k这是它最大的使用门槛。不知道k怎么办最常用的方法是肘部法则不断尝试不同的k值记录每个k对应的SSE然后画折线图。代码写法通常是sse_list [] for k in range(2, 11): km KMeans(n_clustersk, initk-means, random_state42) km.fit(X_scaled) sse_list.append(km.inertia_) plt.plot(range(2, 11), sse_list, markero) plt.xlabel(Number of Clusters (k)) plt.ylabel(SSE) plt.title(Elbow Method for Optimal k) plt.show()所谓“肘部”就是曲线从快速下降到缓慢下降的那个转折点。理想情况下k3或4处会有明显拐弯就像人的手肘一样之后SSE下降速度变得平缓说明再增加簇数收益很小那这个拐点就是最合理的k。4.2 轮廓系数选择法肘部法则看全局趋势轮廓系数则可以对每个候选k打分。通常的做法是同时画两条曲线做交叉验证sil_scores [] for k in range(2, 11): km KMeans(n_clustersk, initk-means, random_state42) labels km.fit_predict(X_scaled) sil_scores.append(silhouette_score(X_scaled, labels)) plt.plot(range(2, 11), sil_scores, markero, colororange) plt.xlabel(Number of Clusters (k)) plt.ylabel(Silhouette Score) plt.title(Silhouette Score for Different k) plt.show()轮廓系数最大的k通常更可靠但也不是绝对的。我见过某个数据集在k2时轮廓系数最高但业务方明确说“两个客户群完全不够用至少分四类”这种情况就以业务诉求为主几何指标只做参考。4.3 一个很多人会踩的坑数据本来就没有簇结构肘部法则真正的坑在于有些数据集压根不存在自然的簇结构。举个例子所有样本均匀分布在一个大区域里那SSE曲线会非常平滑没有明显拐点轮廓系数也一直在临界值附近徘徊。如果这时候硬要聚类结果大概率是把连续的数据强行切开边界处样本的归属完全靠运气。遇到这种情况我的建议是先暂停聚类分析重新审视特征。可能是特征选得不够有区分度也可能是数据本身就不适合用聚类方法。强行用K-means做探索性分析可以但别指望它给你一个“像模像样”的结论。5. 真实项目里坑过我四次的问题每一个都有解决办法5.1 不固定随机种子等于每次结果都不一样K-means的初始中心点是随机选择的不同的初始值可能导致算法收敛到不同的局部最优解。最直观的现象是同一份数据你写两遍代码跑两次簇标签的数字顺序完全变了甚至簇的边界也有细微差异。解决办法主要有三种固定random_state保证实验可复现设置n_init大于1让sklearn用不同的初始中心点跑多次取SSE最小的那次结果n_initauto在较新版本里默认10次使用initk-means从源头降低随机初始化的不稳定性。这个坑特别隐蔽因为它不影响代码正常运行只影响结果稳定性。你要是没固定种子今天调好的聚类结果明天重新跑一遍发现全变了那种感觉真的非常抓狂。5.2 离群点会把中心点拖走K-means对离群点非常敏感。影响机制是这样的因为距离计算是平方距离离群点离簇中心越远它对中心点更新的“拉力”就越大。一个极端离群点可能直接把整个簇的中心从数据密集区拖向它那边导致原本该是一个簇的样本被劈成两半而离群点自己独自形成一个只有一两个样本的簇。处理思路有两种一是在聚类前做离群点过滤比如基于四分位距IQR剔除极端值或者用DBSCAN先做一遍噪声过滤再把清洗后的数据交给K-means二是对特征做变换例如对数变换把右偏的长尾分布压缩一下减弱极端值的权重。这里提醒一句标准化不能解决离群点问题标准化只是缩放不会改变样本间的相对距离分布。5.3 数据量大了普通KMeans跑不动K-means虽然算法简单但每一轮迭代都要计算所有样本到所有中心点的距离样本量几百万、特征几百维时普通KMeans的速度会慢得让你怀疑人生。我之前处理过一份千万级的用户行为数据用普通KMeans跑一轮要几分钟而且内存占用非常高。这种情况可以换用MiniBatchKMeans。它每次从全量数据中随机抽一小批样本做中心点更新收敛速度快一个量级以上聚类效果和普通KMeans非常接近。代价是需要多调一个batch_size参数批量越小越快但稳定性会下降批量越大越接近原始KMeans但提速效果有限。实际经验是从1024开始试观察结果稳定后再调整。5.4 非球形簇K-means的结构性局限K-means假设每个簇是凸的、近似球形且尺寸相近。如果数据呈现环形、条带状、新月形这类非凸分布K-means无论怎么调k都会失败。最典型的就是同心圆数据K-means会把内外环硬切成几块扇形分出的簇完全没有对应真实结构。遇到这种情况不是你的代码有bug是算法选型出了问题。可以考虑改用DBSCAN这种基于密度的聚类算法它通过邻域半径eps和最小样本数min_samples来识别稠密区域能够识别任意形状的簇而且不需要预先指定簇数。也可以用层次聚类输出一个树状图适合分析嵌套结构。选型时看数据形态比硬调K-means参数有效得多。from sklearn.cluster import DBSCAN db DBSCAN(eps0.5, min_samples5) labels_db db.fit_predict(X_scaled)这里eps是最核心也最难调学名叫“扫描半径”直观理解就是“多大范围内的点算邻居”。没有万能参数只能结合数据分布多试几次。最后再分享一个小习惯。我现在拿到一份特征数据第一件不是急着聚类而是先做可视化看看样本在二维平面上的分布形状判断它到底适不适合用K-means。如果特征超过二维就先PCA降维到两维看个大概再决定算法路线。聚类毕竟是探索性分析工具你对数据的理解越深聚类结果能用得多好就取决于这一步功课做得有多扎实。本文还有配套的精品资源点击获取