简介:这份资源是面向数据科学初学者与算法实践者的K-means聚类可视化Python代码包,聚焦聚类分析中簇数选择这一核心难点,通过肘部法则与轮廓系数两条路径帮助读者判断最优K值,适用于课程作业、项目原型与自学练手。压缩包共27个文件,约10.04MB,其中7个py脚本承载聚类主流程与示例调用,18张png图表记录不同参数下的聚类效果与评估曲线,另附txt依赖清单与md说明文档,便于快速理解目录结构与运行方式。目前已有165人学习下载。代码基于numpy、matplotlib、seaborn、scikit-learn与pandas构建,读者可直接运行主程序查看完整演示,并借助生成的肘部曲线、轮廓系数对比图直观感受聚类质量变化,同时参考示例数据与结果目录,把可视化分析思路迁移到自己的数据集上,减少从零调试的成本。
1. K-means 聚类可视化:肘部法则和轮廓系数到底该怎么用
跑完一个 K-means 聚类,最常被问到的不是「模型怎么写的」,而是「k 到底取几」。这个问题不解决,后面所有的可视化都是自欺欺人——你画出来的簇边界再漂亮,k 选错了,业务解释就是空中楼阁。肘部法则和轮廓系数就是用来回答这个问题的两把尺子:前者看簇内误差随 k 增大的下降速度,后者看每个点跟自己簇的紧密程度与跟最近邻簇的分离程度。这篇内容面向已经会用 Python 做数据分析、但聚类调参还在凭感觉的从业者,从零把 K-means 的可视化流程搭起来,包含 SSE 肘部曲线、轮廓系数曲线、聚类散点图和轮廓图四张核心图,每一步都给可复现的代码和参数说明。读完你能直接把这套流程套到自己的数据集上,知道 k 怎么定、图怎么看、哪里容易翻车。
2. 环境准备与数据生成:把 K-means 可视化跑起来的最小闭环
2.1 依赖库安装与版本确认
做 K-means 聚类可视化,核心依赖就四个:numpy负责数值计算,matplotlib负责画图,scikit-learn提供KMeans和silhouette_score,pandas用来整理中间结果。如果你用的是 Anaconda,这些基本都预装了;如果是裸 Python 环境,一条命令补齐:
pip install numpy matplotlib scikit-learn pandas装完之后建议在脚本开头打印版本,避免因为 sklearn 版本差异导致KMeans参数行为不一致(比如n_init在不同版本默认值不同,这个后面避坑章节会细说):
import numpy as np import matplotlib.pyplot as plt import pandas as pd from sklearn.cluster import KMeans from sklearn.datasets import make_blobs from sklearn.metrics import silhouette_score, silhouette_samples from sklearn.preprocessing import StandardScaler import sklearn print(f"numpy: {np.__version__}") print(f"sklearn: {sklearn.__version__}") print(f"matplotlib: {plt.matplotlib.__version__}")这段代码的作用是确认环境可用。silhouette_samples和silhouette_score是两个不同粒度的函数:前者返回每个样本的轮廓系数,画轮廓图必须用它;后者返回全局平均值,用来画轮廓系数随 k 变化的曲线。很多人只 import 了silhouette_score,到画轮廓图时才发现少东西,这是最常见的低级翻车。
2.2 用 make_blobs 造一份可控的聚类数据
真实数据往往没有标签,调试阶段不好判断聚类对不对。我一般先用make_blobs造一份已知簇数的数据,这样能验证整套可视化流程是否正常工作。参数上,n_samples控制样本量,centers控制真实簇数,cluster_std控制簇的松散程度,random_state保证每次生成的数据一致:
# 生成 3 簇、每簇 300 个样本的二维数据 X, y_true = make_blobs( n_samples=900, centers=3, cluster_std=0.80, random_state=42 ) # 标准化:K-means 基于欧氏距离,量纲差异会主导距离计算 scaler = StandardScaler() X_scaled = scaler.fit_transform(X) print(f"数据形状: {X_scaled.shape}") print(f"真实簇标签分布: {np.bincount(y_true)}")这里有个关键决策:要不要标准化。K-means 用欧氏距离衡量相似度,如果两个特征的量纲差了几个数量级(比如年龄 0-100 和收入 0-1000000),距离会被大量纲特征完全主导,聚类结果等于只用了那一个特征。所以只要特征量纲不统一,StandardScaler是必须的。make_blobs造的数据本身量纲接近,但养成标准化的习惯没坏处,换成真实数据时不会忘。
cluster_std=0.80这个值是我调出来的:太小(比如 0.3)三簇分得太开,肘部曲线拐点不明显,看不出效果;太大(比如 2.0)三簇糊在一起,轮廓系数全线偏低,也不利于演示。0.8 左右能造出「肉眼能分但有重叠」的效果,最接近真实业务数据的形态。
3. 肘部法则:SSE 曲线怎么画、拐点怎么读
3.1 SSE 的计算逻辑与 KMeans 关键参数
肘部法则的核心指标是 SSE(Sum of Squared Errors),也就是每个样本到其所属簇中心的距离平方和。k 增大时 SSE 必然下降——极端情况下每个点自成一簇,SSE 为 0。所以不能只看 SSE 绝对值,要看它的下降速率:在真实簇数附近,SSE 会有一个明显的「拐点」,过了这个点再增加 k,SSE 下降变缓,说明多出来的簇没有带来实质性的紧凑度提升。
用 sklearn 算 SSE 很直接,KMeans拟合后通过inertia_属性拿到:
sse = [] k_range = range(1, 11) for k in k_range: km = KMeans( n_clusters=k, init='k-means++', # 智能初始化,降低陷入局部最优的概率 n_init=10, # 独立跑 10 次取最优,sklearn 1.4+ 默认已是 'auto' max_iter=300, # 单次迭代上限 random_state=42 ) km.fit(X_scaled) sse.append(km.inertia_) # 打印每个 k 对应的 SSE for k, v in zip(k_range, sse): print(f"k={k:2d} SSE={v:.2f}")参数逐个说清楚。init='k-means++'是初始化策略,标准 K-means 随机选初始中心,运气不好会收敛到很差的局部最优;k-means++ 让初始中心尽量分散,是现在的默认推荐。n_init=10表示用不同随机种子跑 10 次,取 SSE 最小的那次结果,这个参数直接关系到结果稳定性——设成 1 的话,同一份数据跑两次可能得到不同聚类,这是很多人觉得 K-means「玄学」的根源。max_iter=300是单次运行的迭代上限,一般数据几十次就收敛了,300 足够。
3.2 肘部曲线的绘制与拐点判读
拿到 SSE 列表后画折线图,横轴 k,纵轴 SSE:
plt.figure(figsize=(8, 5)) plt.plot(k_range, sse, marker='o', linewidth=2, color='#2c7fb8') plt.xlabel('簇数 k', fontsize=12) plt.ylabel('SSE(簇内误差平方和)', fontsize=12) plt.title('肘部法则:SSE 随 k 的变化', fontsize=14) plt.xticks(list(k_range)) plt.grid(alpha=0.3) # 标注拐点(这里真实簇数是 3) plt.annotate('拐点 k=3', xy=(3, sse[2]), xytext=(5, sse[2] + 50), arrowprops=dict(arrowstyle='->', color='red'), fontsize=11, color='red') plt.tight_layout() plt.savefig('elbow_method.png', dpi=150) plt.show()画图本身不难,难的是读图。肘部法则的「拐点」在数学上没有严格定义,它是视觉判断:曲线从陡峭下降转为平缓的那个转折位置。实操中我一般这样处理——先看曲线最明显的弯折处,再结合业务约束。比如电商用户分群,业务方可能只接受 3 到 5 个群体,那就在这个范围内找拐点。如果曲线很平滑没有明显拐点(真实数据经常这样),肘部法则就不可靠了,必须靠轮廓系数来交叉验证。
提示:SSE 曲线在 k 从 1 到 2、2 到 3 时下降幅度最大,之后每增加一个 k 的边际收益递减。如果 k=3 到 k=4 的 SSE 降幅已经小于 10%,继续增大 k 的意义就不大了。
4. 轮廓系数:从全局均值到单样本轮廓图
4.1 轮廓系数的定义与计算
轮廓系数衡量的是「一个样本跟自己簇的相似度」与「它跟最近邻簇的相似度」之间的相对关系。对单个样本 i,设 a(i) 是它到同簇其他样本的平均距离(凝聚度),b(i) 是它到最近邻簇所有样本的平均距离(分离度),则:
s(i) = (b(i) - a(i)) / max(a(i), b(i))
s(i) 的取值范围是 -1 到 1。接近 1 说明该样本聚类合理,接近 0 说明在两个簇边界上,负值说明可能被分错了簇。全局轮廓系数就是所有样本 s(i) 的平均值。
sil_scores = [] for k in range(2, 11): # 轮廓系数要求 k>=2 km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42) labels = km.fit_predict(X_scaled) score = silhouette_score(X_scaled, labels, metric='euclidean') sil_scores.append(score) print(f"k={k:2d} 轮廓系数={score:.4f}")注意循环从 k=2 开始,因为 k=1 时不存在「最近邻簇」,轮廓系数无定义。metric='euclidean'是距离度量,跟 K-means 内部用的欧氏距离保持一致,不要改成余弦距离,否则两个指标衡量的不是同一件事。
4.2 轮廓系数曲线与最优 k 的选择
把轮廓系数随 k 的变化画出来,峰值对应的 k 通常就是推荐值:
plt.figure(figsize=(8, 5)) plt.plot(range(2, 11), sil_scores, marker='s', linewidth=2, color='#e6550d') plt.xlabel('簇数 k', fontsize=12) plt.ylabel('平均轮廓系数', fontsize=12) plt.title('轮廓系数随 k 的变化', fontsize=14) plt.xticks(range(2, 11)) plt.grid(alpha=0.3) best_k = range(2, 11)[int(np.argmax(sil_scores))] plt.axvline(x=best_k, linestyle='--', color='gray', alpha=0.7) plt.annotate(f'最优 k={best_k}', xy=(best_k, max(sil_scores)), xytext=(best_k + 1.5, max(sil_scores)), arrowprops=dict(arrowstyle='->', color='red'), fontsize=11, color='red') plt.tight_layout() plt.savefig('silhouette_scores.png', dpi=150) plt.show()肘部法则和轮廓系数经常给出不同答案,这很正常。我的处理原则是:两者一致时直接用;不一致时优先看轮廓系数,因为它有明确的数值范围,可比较性更强;但如果轮廓系数峰值对应的 k 很小(比如 k=2),而业务上明显需要更细的分群,那就回到肘部曲线找次优拐点,同时接受轮廓系数略低的事实。聚类没有唯一正确答案,只有「在当前数据下相对合理」的答案。
4.3 单样本轮廓图:看清每个簇的质量
全局轮廓系数是个平均值,会掩盖簇与簇之间的质量差异。有的簇内部很紧凑,有的簇拖着一堆边界样本,平均下来看着还行,实际有一个簇根本不能用。轮廓图(silhouette plot)就是用来暴露这个问题的:
def plot_silhouette(X, k, ax=None): km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42) labels = km.fit_predict(X) sil_vals = silhouette_samples(X, labels, metric='euclidean') avg_score = silhouette_score(X, labels, metric='euclidean') if ax is None: fig, ax = plt.subplots(figsize=(8, 6)) y_lower = 10 colors = plt.cm.Set2(np.linspace(0, 1, k)) for i in range(k): cluster_sil = np.sort(sil_vals[labels == i]) size = cluster_sil.shape[0] y_upper = y_lower + size ax.fill_betweenx(np.arange(y_lower, y_upper), 0, cluster_sil, facecolor=colors[i], edgecolor=colors[i], alpha=0.7) ax.text(-0.05, y_lower + 0.5 * size, f'簇 {i}', fontsize=11) y_lower = y_upper + 10 ax.axvline(x=avg_score, color='red', linestyle='--', label=f'平均={avg_score:.3f}') ax.set_xlabel('轮廓系数', fontsize=12) ax.set_ylabel('样本(按簇分组)', fontsize=12) ax.set_title(f'k={k} 的轮廓图', fontsize=14) ax.legend(loc='best') return avg_score plot_silhouette(X_scaled, 3) plt.tight_layout() plt.savefig('silhouette_plot_k3.png', dpi=150) plt.show()读轮廓图看三点:一是每个簇的「刀片」是否都超过红色平均线,如果某个簇大量样本的轮廓系数低于平均线甚至为负,这个簇就有问题;二是各簇刀片宽度是否均匀,宽度代表样本量,某个簇特别窄说明它可能不该独立存在;三是刀片顶端是否尖锐,越尖锐说明簇内样本越紧凑。我见过不少案例,全局轮廓系数 0.55 看着不错,但轮廓图一画,有一个簇的样本全部在平均线以下,这种结果拿去给业务方是要被打回来的。
5. 聚类结果可视化与避坑排查
5.1 二维散点图叠加簇中心
二维数据可以直接画散点图,每个点按簇标签着色,再把簇中心标出来:
def plot_clusters(X, k): km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42) labels = km.fit_predict(X) centers = km.cluster_centers_ plt.figure(figsize=(8, 6)) scatter = plt.scatter(X[:, 0], X[:, 1], c=labels, cmap='Set2', s=30, alpha=0.6) plt.scatter(centers[:, 0], centers[:, 1], c='red', marker='X', s=200, edgecolors='black', linewidths=1.5, label='簇中心') plt.xlabel('特征 1(标准化后)', fontsize=12) plt.ylabel('特征 2(标准化后)', fontsize=12) plt.title(f'K-means 聚类结果(k={k})', fontsize=14) plt.legend() plt.grid(alpha=0.3) plt.tight_layout() plt.savefig(f'cluster_result_k{k}.png', dpi=150) plt.show() plot_clusters(X_scaled, 3)如果是三维以上数据,不能直接画散点图,常见做法是先做 PCA 降到二维再画,但要在图注里说明「这是降维后的投影,簇间距离不代表原始空间距离」。这一点经常被忽略,导致业务方误读图上的重叠区域。
5.2 避坑与常见问题排查
现象一:同一份数据跑两次,聚类结果不一样。原因通常是n_init设得太小或没设,K-means 对初始中心敏感,不同随机种子会收敛到不同局部最优。解决办法是把n_init设到 10 以上,同时固定random_state。如果数据本身簇结构不明显,即使n_init=10也可能不稳定,这时候要回到数据层面检查特征是否选对了。
现象二:肘部曲线没有明显拐点,一路平滑下降。原因一般是数据本身没有天然的簇结构,或者簇的密度、大小差异太大。解决办法是不要硬找拐点,改用轮廓系数或 Gap Statistic 交叉验证;如果所有 k 的轮廓系数都低于 0.3,说明这份数据可能不适合 K-means,可以试试 DBSCAN 这类基于密度的聚类算法。
现象三:轮廓系数很高但业务方说分群没意义。原因是轮廓系数只衡量几何紧凑度,不衡量业务可解释性。比如把「高消费低频」和「低消费高频」两个群体合并成一个簇,几何上可能很紧凑,但业务上是两类完全不同的人。解决办法是把聚类结果的簇中心还原到原始量纲,逐个特征对比,看每个簇在业务维度上是否有清晰画像。
现象四:标准化之后聚类结果反而变差了。原因是标准化把某些本来有意义的量纲差异抹掉了。比如收入这个特征,高收入和低收入的绝对差距本身就是重要信号,标准化后这个信号被压缩。解决办法是分情况处理:量纲差异纯粹来自单位不同(米 vs 厘米)时必须标准化;量纲差异本身携带业务信息时,考虑用归一化到 [0,1] 或者干脆不处理,但要确保没有量纲差几个数量级的特征混在一起。
现象五:k 增大时轮廓系数不升反降,但 SSE 一直在降。这是正常的,两个指标衡量维度不同。SSE 只看簇内紧凑度,k 越大必然越小;轮廓系数同时看凝聚度和分离度,k 太大时簇与簇之间会挨得太近,分离度下降,轮廓系数就下来了。这种情况下以轮廓系数为准,不要被 SSE 的持续下降误导。
6. 把可视化流程封装成可复用函数
上面每一步都拆开写了,实际项目中我习惯把整套流程封装成一个函数,输入原始数据和 k 的范围,输出四张图和一份指标汇总表。这样换数据集时只改输入,不用重写代码:
def kmeans_analysis(X, k_max=10, prefix='km'): """ X: 原始特征矩阵(未标准化) k_max: 最大尝试簇数 prefix: 输出文件名前缀 返回: 包含 SSE 和轮廓系数的 DataFrame """ X_scaled = StandardScaler().fit_transform(X) results = [] for k in range(2, k_max + 1): km = KMeans(n_clusters=k, init='k-means++', n_init=10, max_iter=300, random_state=42) labels = km.fit_predict(X_scaled) results.append({ 'k': k, 'SSE': km.inertia_, '轮廓系数': silhouette_score(X_scaled, labels) }) df = pd.DataFrame(results) # 画肘部曲线 fig, axes = plt.subplots(1, 2, figsize=(14, 5)) axes[0].plot(df['k'], df['SSE'], marker='o', color='#2c7fb8') axes[0].set_xlabel('k'); axes[0].set_ylabel('SSE') axes[0].set_title('肘部法则'); axes[0].grid(alpha=0.3) axes[1].plot(df['k'], df['轮廓系数'], marker='s', color='#e6550d') axes[1].set_xlabel('k'); axes[1].set_ylabel('轮廓系数') axes[1].set_title('轮廓系数'); axes[1].grid(alpha=0.3) plt.tight_layout() plt.savefig(f'{prefix}_metrics.png', dpi=150) plt.show() return df # 用法 df_result = kmeans_analysis(X, k_max=10, prefix='demo') print(df_result.to_string(index=False))这个封装里有两个设计取舍值得说。一是标准化放在函数内部做,保证每次调用行为一致,避免调用方忘记标准化;如果你的数据已经标准化过,把StandardScaler那行去掉即可。二是返回 DataFrame 而不是直接打印,方便后续筛选和存档——我一般会把这份表存成 CSV,跟聚类结果一起归档,下次有人问「为什么选 k=3」,直接翻表给数据。
最后说一个我踩过的坑:silhouette_score的计算复杂度是 O(n²),样本量上万时单次计算就要几十秒,如果 k 从 2 试到 10,光轮廓系数就要跑好几分钟。样本量大的时候我一般先随机采样 5000 条算轮廓系数,确认 k 的大致范围后,再用全量数据跑最终的 K-means。这个采样步骤不影响 k 的选择,但能把调试时间从十分钟压到一分钟以内。希望帮到你。
本文还有配套的精品资源,点击获取