ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

K-Means聚类原理与Python实践:鸢尾花数据集上的初始化、K值选择与标准化解析

K-Means聚类原理与Python实践:鸢尾花数据集上的初始化、K值选择与标准化解析 简介面向机器学习初学者的K-Means聚类入门实践资源基于Python对经典鸢尾花Iris数据集完成无监督聚类分析帮助理解聚类算法原理与基本流程。压缩包共2个文件包括1个CSV数据文件与1个Python脚本整体仅2KB结构轻量、便于直接运行适合在本地环境快速验证。数据文件收录Fisher于1936年提出的三种鸢尾花共150个样本含花萼长度、花萼宽度、花瓣长度、花瓣宽度四个特征Python脚本实现数据清洗、标准化、K-Means聚类、K值选择、SSE/轮廓系数评估及散点图可视化等完整流程。目前已有156人浏览学习尤其适合刚接触无监督学习、想通过代码实践K-Means算法的读者。借助其中的代码与数据可快速掌握从数据处理到聚类分析、结果评估的完整思路也可作为后续复杂聚类任务的基础框架帮助系统理解无监督学习中的关键概念。1. 拿到 iris-kmeans.zip先跑通再解决“簇号对不上品种”的问题这种压缩包在网上一搜一大片解压出来基本是同一副面孔一份 iris.csv一段 kmeans.py跑完能画个散点图能输出一串簇号。但第一次照做的人往往愣住——簇 0、1、2 和山鸢尾、变色鸢尾、维吉尼亚鸢尾的对应关系每次跑都不一样换一个随机种子结果又换一副面孔。这不是你写坏了代码而是 K-Means 只负责把样本分堆不负责给堆起名字。这篇文章就顺着 iris-kmeans.zip 这条路走一遍先讲清楚聚类在优化什么再给你一份能直接复现的 Python 实现最后把初始化、K 值、特征尺度这几个会让结果翻车的地方单独拎出来说清楚。2. 聚类原理先立住K-Means 在优化什么iris 为什么是试金石2.1 损失函数与迭代逻辑距离、分配、均值更新的三角循环K-Means 做的事在数学上很直白给定 N 个样本每个样本是一个 d 维向量选定 K 个聚类中心质心把每个样本分到距离最近的那个中心然后反复调整中心位置让“所有样本到各自中心距离平方和”尽量小。形式化一点目标函数写作J Σ_{i1}^{N} || x_i - μ_{c_i} ||²其中 μ_{c_i} 是样本 x_i 所属簇的质心。很多人把这个公式当成概念背下来却忽略了一个关键事实它是非凸的理论上没有一步到位的解析解你想穷举所有划分方式来计算最小值组合数量是指数级的根本算不动。所以业界普遍采用交替优化的思路把复杂问题拆成两个能单独求解的子问题。常见做法的循环是三步第一初始化随机选 K 个样本当初始质心第二分配固定质心不动每个样本划给距离最近的质心第三更新固定分配结果把每个质心挪到该簇所有样本的均值位置然后回到第二步。只要质心位置的变化量小于某个阈值或者达到最大迭代次数循环就停。你会在各种版本的 kmeans.py 里看到两个关键操作argmin和mean前者对应分配后者对应更新整个算法本质上就是这两步在交替。这里要特别提一句收敛性问题这个循环只能保证收敛到局部最优不保证全局最优。所以同样一份代码同一个数据集换一个初始化种子结果可能完全不同。这不是代码缺陷是问题本身的形态决定的。2.2 iris 数据集的结构4 个数值特征、3 个品种与标签的作用iris 数据集是机器学习里元老级的基准数据。它收集了 3 个鸢尾品种各 50 个样本共 150 条记录每个样本有 4 个数值特征花萼长度、花萼宽度、花瓣长度、花瓣宽度单位都是厘米。数据里还有一个品种标签但注意K-Means 在训练阶段完全不看标签这个标签只在我们事后来评价聚类质量时才有用。先提醒一个容易混淆的地方iris 在英文里也指虹膜识别那个概念但聚类语境下iris.csv 里装的永远是植物的测量数据和摄像头、人眼识别没有任何关系。下载数据时认准“150 行、5 列”这个形态就对了凡行数不是 150 的 iris 数据集大概率是别人加工过的版本我的做法是先确认列名和样本量再往下走。这个数据集被拿去当聚类基准是因为数据形态很有代表性山鸢尾setosa和另外两个品种在特征空间里分得很开而变色鸢尾versicolor和维吉尼亚鸢尾virginica相互交叠。如果在无标签条件下跑 K-MeansK3 时一般只能得到大约 85% 的对应准确率这还是事后把簇号映射回真实标签的结果。剩下那 15% 的交叠样本是数据本身的结构决定的不是你算法调参能消除的这一点对判断结果好坏很重要。2.3 硬分配与几何直觉为什么簇边界是直线为什么簇形必须“凸”K-Means 用的是硬分配每个样本只能属于一个簇没有“我既像簇 0 又像簇 1”的可能性。当我们用欧氏距离时整个特征空间会被质心切成若干个区域区域边界是分段线性的学名叫 Voronoi。落在哪个区域样本就归哪个簇。这个边界天然是直线或超平面所以 K-Means 更擅长拆解形状近似凸的簇遇到环形、月牙形或者缠绕在一起的数据它的能力边界就暴露了。iris 恰好是凸簇的典型三个品种在四维特征空间里各自占据一块近似球形的区域只是其中两块有交叠。有了这层几何直觉你会更容易理解为什么聚类结果看起来是这样交叠区域里的样本就是会被边界线硬生生切开分给其中的一边真实标签并不能参与投票。这也是我喜欢拿 iris 做第一份教程数据的原因。它把 K-Means 的边界和代价都展示得很清楚你既能看到算法如何正确划分山鸢尾也能看到它在交叠区域如何犯错却不会像真实业务数据那样连“正确结果长什么样”都没有参照物。理解清楚这些下面手写实现时你才能判断每一行代码到底在算什么。3. 用 Python 把 kmeans 跑通 iris从解压、预处理到出图3.1 解压后的文件结构以及 iris 数据的读入与缺失值检查这种 zip 压缩包的典型整理方式是iris.csv 放原始数据kmeans.py 放核心代码可能附带一张 cluster_result.png 之类的输出图。我一般拿到包先不急着跑脚本而是先把 iris.csv 单独读进来看一眼结构确认特征列和标签列的顺序因为不同来源的数据集列名不一样有的叫 target有的叫 species有的干脆没有表头。import pandas as pd df pd.read_csv(iris.csv) print(df.info()) print(df.head()) # 检查缺失值K-Means 不能直接处理 NaN print(df.isnull().sum()) # 确认样本量是否为 150类别是否刚好 3 类 print(df.shape) print(df.iloc[:, -1].value_counts())这段代码里df.info()告诉你每列有没有空值、是什么数据类型df.isnull().sum()把缺失值逐列统计出来。K-Means 的距离计算里不允许出现 NaN一旦有缺失值后面求距离时整个结果会变成 NaNargmin也就失去意义。所以拿数据后第一件事永远是缺失值检查而不是开跑算法。df.shape是为了确认它没被加工成别的样本量df.iloc[:, -1].value_counts()是为了确认三个品类是不是各 50 条。如果要把特征和标签分开我的做法是把最后一列看成标签前面几列当特征并把特征转成 numpy 数组X df.iloc[:, :-1].values.astype(float) y_true df.iloc[:, -1].values # 看一眼四个特征的取值范围决定后续要不要标准化 for i, col in enumerate(df.columns[:-1]): print(col, X[:, i].min(), X[:, i].max())这一步输出的 min/max 很有用。你会发现 petal_length 和 petal_width 的数值范围通常比 sepal_length、sepal_width 大不少距离计算时天然会放大数值差异大的特征带来的影响。后面讲到标准化时会再回到这个输出。3.2 手写 kmeans 核心循环初始化、分配、更新、收敛判断下面这份代码是精简版手写 K-Means不依赖 sklearn只用 numpy放到 kmeans.py 里就能跑完整实验import numpy as np def kmeans(X, k, max_iters100, tol1e-4, seed0): rng np.random.default_rng(seed) n, d X.shape # 初始化从样本里随机挑 k 个当初始质心 idx rng.choice(n, k, replaceFalse) centers X[idx].copy() for i in range(max_iters): # 分配计算每个样本到所有质心的欧氏距离 diff X[:, None, :] - centers[None, :, :] # (n, k, d) dist2 np.sum(diff ** 2, axis2) # (n, k) labels np.argmin(dist2, axis1) # 每个样本最近的质心 # 更新用簇内样本均值作为新质心 new_centers np.zeros_like(centers) for j in range(k): member X[labels j] if len(member) 0: new_centers[j] member.mean(axis0) else: # 空簇保留旧质心下一轮继续参与分配 new_centers[j] centers[j] # 收敛判断质心位移小于 tol 则提前停止 shift np.linalg.norm(new_centers - centers) centers new_centers if shift tol: return centers, labels, i 1 return centers, labels, max_iters参数含义如下X是特征矩阵行是样本列是特征k是想要的簇数量iris 场景下先设 3。max_iters100防止极端情况下无限循环tol1e-4是收敛容差当质心整体移动距离小于这个值就认为已经稳定。seed是我习惯主动加的参数不固定的话每次结果都不可控实验报告很难解释。核心的向量化写法是X[:, None, :] - centers[None, :, :]把(n, d)的矩阵扩展成(n, k, d)利用广播一次算出所有样本到所有质心的坐标差再平方求和得到距离矩阵这比嵌套 for 循环算距离快一个数量级。空簇分支也值得留心如果某个簇一个成员都没有mean会得到 NaN所以保留旧质心让它下一轮再参与分配这是手写实现最容易遗漏的地方。3.3 结果评估与可视化用交叉表看簇与真实品种的关系跑完聚类第一件事不是画图而是看簇号和真实标签之间的对应关系。直接用 labels 和 y_true 做一张交叉表import pandas as pd result pd.DataFrame({真实品种: y_true, 簇号: labels}) print(pd.crosstab(result[真实品种], result[簇号]))输出的表格每一行是一个真实品种每一列是簇号。理想情况下每一行应该只有一列有大量计数其它列接近零。实际你会看到一种很典型的格局簇 0 对应山鸢尾簇 1 对应变色鸢尾加一部分维吉尼亚鸢尾簇 2 对应剩余维吉尼亚鸢尾。这说明聚类结果把两个交叠的品种混在一起了这是数据本身的结构决定的。画图时我一般只取两个区分度最高的特征花瓣长度和花瓣宽度。import matplotlib.pyplot as plt plt.figure(figsize(8, 6)) for j in range(3): mask labels j plt.scatter( X[mask, 2], X[mask, 3], labelf簇 {j}, alpha0.7, edgecolorsk, ) plt.xlabel(petal_length) plt.ylabel(petal_width) plt.legend() plt.savefig(cluster_result.png, dpi150)横纵坐标用X[:, 2]和X[:, 3]对应花瓣长度和花瓣宽度。这两个特征在 iris 上区分度很高用在二维图上最容易看出簇边界。alpha 调到 0.7 是为了处理重叠点否则交叠区域的点会被后面的点完全盖住看不清边界。保存成 png 是为了方便后续写实验报告时直接引用。3.4 sklearn 版 KMeans 作对照组关键参数的含义与差异手写版跑通后我还会拿 sklearn 的 KMeans 做一次对照组确认自己的实现没写偏。这个对照不是多余动作相当于给手写代码做一次校准。from sklearn.cluster import KMeans km KMeans(n_clusters3, initk-means, n_init10, max_iter300, random_state0) labels_sk km.fit_predict(X)每个参数都要记住它的含义n_clusters3是簇数initk-means表示用 k-means 初始化替代纯随机初始化能明显改善局部最优问题n_init10表示算法会用 10 个不同的初始质心各跑一遍最后保留其中目标函数最小的那份结果max_iter300是单次运行的最大迭代轮数给足上限random_state0固定随机数保证重建结果。对照时可以分别打印两个版本的簇号交叉表。要注意的是sklearn 版跑出来的簇号顺序通常和手写版不一样这很正常。簇号只是编号没有任何语义只有把簇号和真实品种做成映射表才有可比性。如果想量化两份聚类结果的一致程度可以用调整兰德系数不过那个属于评估指标选型等需要做正式对照实验时再上普通学习阶段看交叉表已经足够。4. kmeans 做 iris 最容易翻车的 5 个现场现象、原因、解决4.1 簇号与真实标签错位以及多次运行结果漂移现象聚类输出的簇 0、簇 1、簇 2 和山鸢尾、变色鸢尾、维吉尼亚鸢尾的对应关系每次运行都不一样。第一次簇 0 是山鸢尾换个种子簇 0 变成维吉尼亚鸢尾直接把簇标签和真实标签比准确率只能得到百分之三四十的离谱数字。原因K-Means 输出的簇号来自argmin的索引初始质心顺序不同簇号就不同。算法本身不携带任何“哪个簇该叫哪个品种”的信息所以簇号天然没有语义也和品种标签没有任何约定。解决评估时永远不要用簇号直接对比标签而是先做交叉表看每一簇主要由哪个品种组成再按多数映射回去。判断聚类质量的标准动作是先对齐类别再算准确率否则得到的是一个意义不明的数字。交叉表的写法在 3.3 节已经给出直接复用即可。现象同一代码同一数据集random_state 换几个数字结果差别巨大簇边界在图上看起来完全不同。原因初始化质心是随机的目标函数又是非凸的算法可能停在不同的局部最优解上。这是 K-Means 的固有特性不是某个实现写坏了。解决在参数层面把初始化改成initk-means把n_init提高到 10 以上让算法从多个初始点出发再挑目标函数最小的那次结果如果要完全复现就固定 random_state。我自己做实验的习惯是先固定种子跑一遍再换 2 个种子复跑确认簇结构稳定后才把结果写进报告。4.2 空簇、局部最优与特征尺度带来的失真现象手写版跑出来出现一个空簇某个质心周围没有任何样本点输出的 labels 里看不到某个簇号。原因质心初始化选到了样本分布中的偏僻位置迭代过程中又没有样本分配到它它就变成了一个“僵尸质心”。多数实现继续拿这个空质心算距离但更新均值时会产生 NaN整轮结果作废。解决手写代码里空簇分支要保留旧质心不参与均值计算这个处理在 3.2 节的代码里已经实现了。更主动的做法是重新初始化质心把空簇质心挪到离它最近的有样本区域的远端。生产实践中我更推荐直接用 sklearn 的 KMeans它对空簇有兜底逻辑比手写实现省一步踩坑的机会。现象四个特征直接喂进去聚类结果似乎只受花瓣长度影响花萼宽度几乎不起作用。具体表现是只拿两个特征做二维图时聚类边界和四维聚类结果冲突明显。原因欧氏距离默认各特征地位平等但如果特征数值范围不同距离贡献就不平等。花瓣长度取值范围大距离贡献就大花萼宽度取值范围小贡献被稀释。这个现象在真实业务数据里更严重比如工资和年龄两列放在一起工资的量级会直接压过年龄。解决聚类前做标准化常见做法用 StandardScalerfrom sklearn.preprocessing import StandardScaler scaler StandardScaler() X_scaled scaler.fit_transform(X)标准化之后每个特征都是零均值、单位方差距离贡献均匀聚类结果会均衡很多。但要记住标准化改变了数据解释方式后续画图、质心解读都要基于标准化后的坐标体系原来的厘米单位意义变弱了。工程上我习惯把原始数据留着做解释标准化数据拿去做聚类两条线分开管理。4.3 K 值拍脑袋定 3少看或无评估还有一个很容易忽视的翻车现场拿到 iris 就直接写 k3理由是“每个品种 50 个样本所以 3 个簇”。教科书练习可以这么干真实数据里这个逻辑靠不住真实簇数和人类直觉可能相差很远。即使对 iris 本身如果把三个品种的样本混杂后再重新排列K3 也未必是最优的。更常见的问题是只跑一个 K既不画惯性曲线也不算轮廓系数就直接拿着簇号做业务决策。一旦业务方问一句“为什么是 3 不是 4”就没有任何依据可以回答。解决的验收习惯是把 K 从 2 到 8 都跑一遍看出一个合理的范围再用解释性判断锁定最终值。具体画法在下一章展开这里想强调的不是选 K 的技巧而是别把“拍脑袋的 K”当成唯一答案。5. 把“能跑”升级成“可靠”K 值选择、kmeans 与轮廓系数5.1 肘部法则怎么画轮廓系数怎么读从 K3 到 K≠3确定聚类方案第一件事是回答 K 取几。肘部法则的原理很直白对 K 从 2 到 8 逐个跑 K-Means记录每个 K 下的组内平方和也就是 sklearn 里的 inertia然后看曲线。随着 K 增大每个点的归属越来越紧inertia 必然下降但在某个 K 值之后下降速度明显放缓这个拐点就是肘部。from sklearn.cluster import KMeans inertia_list [] for k in range(2, 9): km KMeans(n_clustersk, initk-means, n_init10, random_state0) km.fit(X_scaled) inertia_list.append(km.inertia_) print(inertia_list)对 iris 标准化数据跑出来的 inertia 序列通常在 K3 处形成明显拐点。需要注意的是肘部法则不一定总给出一个清晰拐点有些数据的曲线是平滑下降的这时候就要结合下一个指标看。轮廓系数是另一个视角它衡量每个样本对自己簇的归属感和对相邻簇的分离度取值范围是 -1 到 1越接近 1 说明聚类结构越清晰。from sklearn.metrics import silhouette_score best_k 2 best_score -1.0 for k in range(2, 9): km KMeans(n_clustersk, initk-means, n_init10, random_state0) labels_k km.fit_predict(X_scaled) score silhouette_score(X_scaled, labels_k) print(fK{k}, silhouette{score:.4f}) if score best_score: best_k, best_score k, score print(best k:, best_k)经常有人问为什么最佳轮廓系数和肘部法则的结论不完全一致这不奇怪。两个指标的评价立场不同肘部法则关心收益衰减点轮廓系数关心整体分离度峰值。对 iris 而言K3 的轮廓系数通常比 K2 高但可能和 K4 互有胜负这种时候我的选择是优先业务解释性再参考指标。还有个细节指标基于同一份数据的多次运行所以务必固定种子并复用相同的预处理否则小数位变化会让你分不清到底是模型变强了还是种子换了。5.2 kmeans 与多次重启对付局部最优的常见做法k-means 是初始化技巧的改进第一个质心从样本里均匀随机选之后的质心按概率选择这个概率正比于样本到已选质心距离的平方。这样初始质心天然分散减少初始点扎堆导致的坏局部最优。sklearn 里默认就是 initk-means手写版想完整模拟的话需要写一个加权采样流程在初始化阶段多费一点算力但后续收敛速度和稳定性都会好不少。我自己早期用纯随机初始化时经常遇到跑 10 次只有 2 次结果是合理的情况。设了 n_init10 之后算法会用 10 个不同初始化各跑一遍再挑选其中惯性最小的结果。这个做法多花固定时间但成果稳定性提高很多。业务数据做大规模样本时可以调成 n_init5 和更小的 max_iter在效果与耗时之间取平衡。这里也有一个选结果的窍门不要通过肉眼比较簇图或者标签来挑一次运行而是先看 inertia 再决定取哪一份结果。固定随机种子等于是给自己留一颗后悔药任何时候都可以把同一份实验重新跑一遍。如果两个不同初始化的标签在交叉表里结构差异极大通常说明当前 K 值不合适而不是要不要再多跑几次的问题。5.3 标准化到底改了什么一个参数改变整个距离解释前面说过标准化能让特征对距离的贡献趋于均衡。这里补一点实操顺序问题一定要先做标准化再跑聚类。我一般这样写scaler StandardScaler() X_scaled scaler.fit_transform(X) km KMeans(n_clusters3, initk-means, n_init10, random_state0) km.fit(X_scaled)如果先聚类再标准化那就是在改写已有距离的定义两份结果没有比较基础。而在同一份 X_scaled 上重复跑不同 K 值时质心的坐标顺序在 sklearn 的实现里是按均值位置排序的这一点在写自动报告时需要知道。还要注意一点标准化不会改变数据集的内在交叠程度。iris 里变色鸢尾和维吉尼亚鸢尾交叠的区域标准化之后照样交叠它改变的是各个方向上的权重不是数据本质的结构。把标准化当成万能解本身就是一种误读。它的作用是让数值范围大的特征不再天然主导距离并不意味着数据会因此变得线性可分。因此是否标准化的决定要在预处理阶段一次敲定之后所有 K 值实验、指标比较都在同一白受试方案下进行。我见过有人拿未标准化的原始数据跑了四五个 K得到一组结论再拿标准化数据一跑结论完全变了然后又得回头反复解释数据口径白白浪费一下午。先固定预处理方案再谈参数调整这是聚类的第一优先级。6. 我会保留的验证习惯固定种子、对比两份簇标签表、复跑三遍方案说得再完整最后落地的往往是验证动作。我现在固定保留三个习惯缺一个都不放心把结果发出去。第一个习惯是固定随机种子。无论手写实现还是 sklearn 版固定 random_state 或 seed 是我写进代码的第一步而不是最后才补。这样别人拿到同一份数据能跑出完全一样的簇号和质心实验报告里的数字经得起复查。第二个习惯是跑完把真实标签和簇号交叉表打印出来。我见过的不少翻车都是因为只看聚类准确率不看交叉表。准确率是一个汇总数交叉表才是能解释“错在哪里”的地方。没有交叉表你和同事都回答不了“为什么把这两类混在一起”。第三个习惯是换 3 个不同的种子复跑对比三份交叉表的结构。如果三个种子给出的簇归属格局高度一致结果才敢写进结论如果每次差异很大说明当前数据不适合直接用 K-Means或者当前 K 值有问题。为了快速对比我常写一个极小脚本from sklearn.metrics import adjusted_rand_score scores [] for seed in [0, 1, 42]: km KMeans(n_clusters3, initk-means, n_init10, random_stateseed) labels_seed km.fit_predict(X_scaled) scores.append(adjusted_rand_score(y_true, labels_seed)) print(scores)adjusted_rand_score 的范围在 -1 到 1三个种子得到接近且较高的分数说明聚类结果对初始化不敏感分数起伏大我会先把特征尺度和 K 值重新过一遍再做下一轮实验。这三个习惯帮我避掉过很多次“以为聚类已经完成实际上初始化种子一变就面目全非”的坑。如果你拿到的只是 iris-kmeans.zip 里那段跑一遍就出图的脚本也建议先保留这三个验证动作再往业务数据迁移。希望帮到你。本文还有配套的精品资源点击获取
返回列表