简介:本资源是一份面向本科及以上层次学习者与科研初学者的MATLAB聚类分析实践工具包,聚焦K-means算法中关键参数k的科学选取问题,通过肘部法(Elbow Method)实现聚类效果的可视化评估与优化。资源包含2个核心MATLAB脚本文件(main.m与main1.m)和1个预置数据集文件(data.mat),总大小仅3KB,结构精简、注释详尽,便于快速理解肘部曲线绘制逻辑、SSE计算流程及聚类结果评估方法,支持直接运行与二次扩展。目前已有712人学习下载,适用于机器学习入门实践、课程设计、毕业设计中聚类模块的快速验证与参数调优。用户可基于该框架灵活替换数据、调整距离度量或集成轮廓系数等其他评估指标,代码具备良好可读性与工程延展性,是掌握无监督学习核心思想的实用教学范例。
1. 肘部法不是“画个图就完事”:它本质是用误差下降率反推聚类结构的拐点,但90%的人跑出的肘部图根本不可信——因为没做标准化、没重采样、没验证稳定性,最后选的k值在新数据上直接翻车
你手头有一批用户行为日志、一批传感器读数、或一批销售区域坐标,想用KMeans自动分群。但k设成3?5?8?试出来效果差,调参像玄学。肘部法(Elbow Method)常被当作“标准答案”推荐:画出不同k值对应的簇内平方和(SSE),找那个“拐得最急”的点。听起来很直观,但实际落地时,同一份数据,有人画出清晰肘部,有人画出平滑曲线,还有人画出多个疑似肘点——而所有这些图,都可能来自完全相同的代码。问题不在图本身,而在背后三个被长期忽视的前提:数据必须经Z-score标准化(否则量纲差异会彻底扭曲SSE量级),k的取值范围必须覆盖合理区间(k=1到√n太窄,k=1到20又太宽),且单次运行结果必须通过多次随机初始化+重采样验证其稳定性(KMeans对初始中心敏感,一次运行的SSE曲线可能纯属偶然)。本文不讲数学推导,只聚焦一线工程师真正卡住的环节:怎么让肘部图从“看起来像有拐点”变成“能说服老板和算法同事的决策依据”。所有代码基于scikit-learn 1.3+、numpy 1.24+、matplotlib 3.7+,数据集为真实脱敏的零售门店地理坐标(经纬度)与月均销售额,共427条记录,已按标准流程清洗完毕(无缺失、无异常离群点、坐标已转为平面投影单位米)。你会看到:如何用5行核心代码生成可复现的肘部曲线,如何用Bootstrap重采样给每个k值打置信区间,如何用轮廓系数交叉验证肘部点是否真对应语义合理的分群,以及——为什么你上次画的肘部图大概率是个黑匣子。
2. 从原始数据到可信肘部图:标准化、k范围设定与SSE计算的三步闭环
2.1 数据加载与预处理:为什么必须做Z-score标准化,而不是Min-Max?
提示:地理坐标(经纬度)与销售额(万元)量纲差异超10⁶倍,不做标准化时SSE几乎完全由销售额主导,地理空间结构被淹没。
我们使用的数据retail_stores.csv包含4列:store_id,lon,lat,monthly_sales。关键不是“要不要标准化”,而是“为什么Z-score比Min-Max更鲁棒”:
- Min-Max将每列缩放到[0,1],但对异常值极度敏感(一个极端高销售额会把其余所有店压到0.01附近);
- Z-score(
StandardScaler)以均值和标准差为基准,天然抑制异常值影响,且使各维度方差均为1,SSE计算时各特征贡献权重均衡。
import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score # 加载数据(确保路径正确) df = pd.read_csv("retail_stores.csv") X_raw = df[["lon", "lat", "monthly_sales"]].values # 原始特征矩阵 # 标准化:必须用fit_transform,且后续所有分析都基于X_scaled scaler = StandardScaler() X_scaled = scaler.fit_transform(X_raw) print(f"原始数据形状: {X_raw.shape}") print(f"标准化后均值 ≈ {X_scaled.mean(axis=0).round(3)}") # 应接近[0,0,0] print(f"标准化后标准差 ≈ {X_scaled.std(axis=0).round(3)}") # 应接近[1,1,1]逻辑说明:StandardScaler对每列独立计算均值和标准差,再执行(x - mean) / std。fit_transform一次性完成拟合与转换,绝不能对训练集和测试集分别fit(会导致尺度不一致)。输出中mean≈[0,0,0]和std≈[1,1,1]是验证标准化成功的黄金指标——若不满足,说明数据存在严重偏态或异常值,需先做清洗(如用IQR法剔除销售额top 1%离群点)。
参数说明:
X_raw:原始数值型特征,不含ID或文本列;X_scaled:后续所有KMeans输入,维度与X_raw相同,但数值分布已统一;- 若业务要求保留原始量纲解释性(如“距离单位是米”),可在聚类后用
scaler.inverse_transform()反向还原中心点坐标,但SSE计算必须在标准化空间进行。
2.2 k值范围设定:为什么k=1到√n是新手陷阱,而k=1到20又可能漏掉关键结构?
k的取值不是拍脑袋决定的。常见错误:
- 下限陷阱:k=1无意义(所有点一簇),但k=2是必须包含的起点;
- 上限陷阱:
k_max = int(np.sqrt(len(X_scaled)))(如427→20)看似合理,但对地理数据失效——城市商圈天然存在5-8个核心集群,√n=20会迫使算法强行拆分本应同属一区的门店; - 业务约束:运营团队明确要求“最多划分7个大区”,则k_max必须≤7。
我们采用双轨制k范围:
- 探索范围:k=2到15(覆盖典型商业分群粒度,且15<√427≈20.7,留有余量);
- 业务约束范围:k=2到7(若运营强约束存在,此范围优先)。
# 定义k值列表:从2开始,避免k=1;上限取min(15, 运营最大允许k) k_range = range(2, 16) # 探索范围:k=2,3,...,15 # 若有业务约束,改为 k_range = range(2, 8) # k=2到7 # 初始化存储容器 k_list = list(k_range) sse_list = [] silhouette_list = [] # 对每个k值,运行KMeans并计算SSE for k in k_list: # 关键:设置n_init=10,确保每次k值都尝试10次不同初始中心,取最优SSE kmeans = KMeans(n_clusters=k, n_init=10, random_state=42, max_iter=300) kmeans.fit(X_scaled) sse_list.append(kmeans.inertia_) # inertia_即SSE # 同时计算轮廓系数(用于后续交叉验证) labels = kmeans.labels_ silhouette_list.append(silhouette_score(X_scaled, labels))逻辑说明:n_init=10是KMeans的救命参数——默认n_init=10(scikit-learn 1.0+),但很多旧教程仍写n_init=1,导致SSE曲线剧烈抖动。random_state=42保证结果可复现,max_iter=300防止收敛失败。kmeans.inertia_是模型拟合后的簇内平方和,注意它不是归一化值,绝对数值大小无意义,只看相对变化趋势。
参数说明:
n_init=10:必须显式设置,避免因随机种子导致单次运行结果偏差;random_state=42:固定随机种子,使不同人运行代码得到完全一致的SSE序列;k_list:明确列出k值,避免用np.arange产生浮点数(虽不影响,但易引发类型混淆);silhouette_score:同步计算轮廓系数,为第4章的交叉验证埋下伏笔。
2.3 绘制基础肘部图:用matplotlib生成带标注的SSE曲线
import matplotlib.pyplot as plt plt.figure(figsize=(10, 6)) plt.plot(k_list, sse_list, 'bo-', linewidth=2, markersize=8, label='SSE') plt.xlabel('Number of Clusters (k)', fontsize=12) plt.ylabel('Sum of Squared Errors (SSE)', fontsize=12) plt.title('Elbow Method for Optimal k', fontsize=14) plt.grid(True, alpha=0.3) plt.xticks(k_list) # 强制x轴显示所有k值 plt.legend() plt.tight_layout() plt.show()逻辑说明:此图是肘部法的起点,但仅凭此图做决策风险极高。图中“肘部”位置(如k=5或k=6)只是视觉判断,未考虑统计波动。下一步必须引入重采样来量化不确定性。
参数说明:
'bo-':蓝色圆点+实线,符合数据可视化惯例;linewidth=2, markersize=8:确保线条和点在报告中清晰可辨;plt.xticks(k_list):避免matplotlib自动跳过部分k值(如k=2,4,6...),保证横轴信息完整;- 此图不添加垂直线或箭头标注“肘点”,因为单点标注会误导读者认为该k值绝对最优——真相在第3章揭晓。
3. 让肘部图从“看起来像”变成“说得清道理”:Bootstrap重采样与置信区间绘制
3.1 为什么单次SSE曲线不可信?KMeans的随机性如何放大误差?
KMeans的inertia_受两个随机因素影响:
- 初始中心随机选择:即使
n_init=10,最终选取的也是10次中SSE最小的一次,但这个“最小值”本身有抽样方差; - 数据顺序随机性:
fit()内部对样本顺序无要求,但不同顺序可能影响收敛路径(尤其当max_iter不足时)。
单次运行的SSE曲线,本质是一个随机变量的单次观测值。例如,k=5时SSE=124.3,但真实期望值可能是122.1±3.8。若不量化这个±3.8,就无法判断k=5和k=6的SSE差异(如124.3 vs 118.7)是否具有统计显著性。
解决方案:Bootstrap重采样——从原始数据中有放回地抽取n个样本(n=len(X_scaled)),重复B=50次,对每个重采样数据集独立计算k=2到15的SSE曲线,最后对每个k值,汇总50个SSE值,计算均值与95%置信区间(CI)。
from sklearn.utils import resample def compute_sse_bootstrap(X, k_list, n_bootstraps=50, n_init=10, random_state=42): """ 对每个k值,用Bootstrap计算SSE的均值与95%置信区间 返回: sse_mean, sse_lower, sse_upper (均为长度=len(k_list)的数组) """ sse_all = np.zeros((n_bootstraps, len(k_list))) for b in range(n_bootstraps): # 有放回重采样:生成新数据集X_boot X_boot = resample(X, n_samples=len(X), random_state=random_state+b) for idx, k in enumerate(k_list): kmeans = KMeans(n_clusters=k, n_init=n_init, random_state=random_state+b, max_iter=300) kmeans.fit(X_boot) sse_all[b, idx] = kmeans.inertia_ # 计算每个k值的均值和95% CI(使用percentile,非正态假设) sse_mean = np.mean(sse_all, axis=0) sse_lower = np.percentile(sse_all, 2.5, axis=0) sse_upper = np.percentile(sse_all, 97.5, axis=0) return sse_mean, sse_lower, sse_upper # 执行Bootstrap(耗时约30秒,值得等待) sse_mean, sse_lower, sse_upper = compute_sse_bootstrap( X_scaled, k_list, n_bootstraps=50, n_init=10, random_state=42 )逻辑说明:resample(X, n_samples=len(X), replace=True)实现标准Bootstrap抽样。random_state=random_state+b确保每次重采样独立。np.percentile(..., 2.5)和97.5给出双侧95%置信区间,不假设SSE分布服从正态,更鲁棒。50次重采样是经验平衡点:10次太少(CI过宽),100次耗时翻倍但收益递减。
参数说明:
n_bootstraps=50:足够稳定,且单次运行时间可控(CPU i5-10210U约25秒);replace=True:必须有放回,否则退化为普通重排,无法模拟抽样变异;sse_all二维数组:行=重采样次数,列=k值索引,是后续统计的基础。
3.2 绘制带置信区间的肘部图:识别真正的“拐点稳定性”
plt.figure(figsize=(10, 6)) plt.plot(k_list, sse_mean, 'ro-', linewidth=2, markersize=8, label='Mean SSE') plt.fill_between(k_list, sse_lower, sse_upper, alpha=0.2, color='red', label='95% CI') plt.xlabel('Number of Clusters (k)', fontsize=12) plt.ylabel('Sum of Squared Errors (SSE)', fontsize=12) plt.title('Elbow Method with Bootstrap Confidence Intervals', fontsize=14) plt.grid(True, alpha=0.3) plt.xticks(k_list) plt.legend() plt.tight_layout() plt.show()逻辑说明:填充区域(fill_between)直观显示每个k值SSE的不确定性。真正的肘部,应满足:
- 斜率突变:k从4→5时,SSE均值下降幅度显著大于k从5→6(计算相邻ΔSSE);
- CI不重叠:k=4与k=5的CI无重叠,表明下降显著;
- CI宽度收窄:k增大时CI变宽(因小簇内方差增大),但肘部附近CI应相对紧凑。
参数说明:
alpha=0.2:半透明填充,避免遮挡主曲线;color='red':与均值线同色,强调CI属于同一统计量;- 此图已具备决策依据:若k=5的CI为[120.1, 128.5],k=6为[115.3, 122.9],二者重叠,则k=5→6的下降不显著,肘部应在k=4或k=5。
3.3 计算“肘部强度”指标:用二阶差分量化拐点陡峭度
视觉判断肘部主观性强。我们定义肘部强度(Elbow Strength)为SSE曲线二阶差分的绝对值:
- 一阶差分:
ΔSSE_k = SSE_k - SSE_{k-1}(下降量); - 二阶差分:
Δ²SSE_k = ΔSSE_k - ΔSSE_{k-1}(下降量的变化率); - 肘部强度 =
|Δ²SSE_k|,值越大,拐点越“尖锐”。
# 计算一阶和二阶差分 sse_diff1 = np.diff(sse_mean) # 长度=len(k_list)-1,对应k=3,4,...,15的ΔSSE sse_diff2 = np.diff(sse_diff1) # 长度=len(k_list)-2,对应k=4,5,...,15的Δ²SSE # 肘部强度:取绝对值,映射回k值(k=4对应第一个Δ²SSE) elbow_strength = np.abs(sse_diff2) k_for_strength = k_list[2:] # k=4,5,...,15 # 找出强度最大的k值(即最尖锐拐点) optimal_k_by_elbow = k_for_strength[np.argmax(elbow_strength)] print(f"肘部强度最大点: k={optimal_k_by_elbow}, 强度值={elbow_strength.max():.3f}") # 可视化肘部强度 plt.figure(figsize=(10, 4)) plt.bar(k_for_strength, elbow_strength, color='steelblue', alpha=0.7) plt.xlabel('k value') plt.ylabel('Elbow Strength (|Δ²SSE|)') plt.title('Elbow Strength across k values') plt.grid(True, alpha=0.3) plt.xticks(k_for_strength) plt.show()逻辑说明:二阶差分捕捉“减速”效应。例如,SSE下降从k=3→4(Δ=-15.2)到k=4→5(Δ=-8.7),Δ²SSE = (-8.7) - (-15.2) = +6.5,绝对值大,说明下降明显放缓,此处即肘部。该指标客观量化了“拐得多急”,避免人眼误判平缓下降段为肘部。
参数说明:
np.diff(sse_mean):高效计算相邻元素差,比循环快10倍;k_for_strength = k_list[2:]:因二阶差分丢失前两个k值,故强度指标从k=4开始;- 输出
optimal_k_by_elbow是算法推荐的k值,但必须与业务逻辑交叉验证(第4章)。
4. 避坑:肘部法落地的5个血泪经验,90%的人栽在第3条
4.1 现象:肘部图呈现多峰或单调下降,看不出明显拐点
原因:数据本身缺乏自然聚类结构,或标准化失效(如某特征含大量零值,Z-score后产生NaN)。
解决:先做PCA降维可视化(plt.scatter(X_pca[:,0], X_pca[:,1])),确认数据是否呈团状分布;检查标准化后是否有np.isnan(X_scaled).any(),若有,用SimpleImputer(strategy='mean')填充缺失,而非删除整行(损失信息)。
4.2 现象:Bootstrap后CI极宽(如k=5时CI=[100,180]),无法判断肘部
原因:数据量小(n<100)或特征间高度相关(如lon与lat在局部区域线性相关),导致重采样变异大。
解决:对小数据集,改用Jackknife(留一法)替代Bootstrap;对高相关特征,先做PCA保留95%方差的主成分,再在PCA空间运行肘部法。
4.3 现象:肘部点k=5,但聚类结果中某簇仅含2个样本,业务上不可行
原因:肘部法只优化SSE,不保证簇大小均衡或语义合理。SSE偏好小而密的簇,可能分裂出噪声簇。
解决:增加约束——在KMeans后,过滤掉样本数<总样本5%的簇(本例427×5%≈21),若存在,则强制合并:计算该簇中心到其他簇中心的距离,合并至最近簇,并重新运行KMeans(k=k-1)。代码见第5章。
4.4 现象:同一数据,不同随机种子下肘部点从k=5跳到k=7
原因:n_init设置过小(如=1),或max_iter不足导致未收敛,SSE值不稳定。
解决:n_init至少设为10,max_iter≥300;更重要的是,用轮廓系数二次验证——肘部点k值对应的轮廓系数应局部最大,且>0.5(良好聚类阈值)。
4.5 现象:标准化后肘部消失,SSE曲线更平缓
原因:原始数据已近似同量纲(如全是百分比),标准化反而放大测量噪声。
解决:做两套分析——一套标准化,一套原始数据;比较两者的轮廓系数曲线,选轮廓系数峰值更高的方案。永远以业务解释性为最终判据,而非SSE最小化。
5. 交叉验证与业务落地:用轮廓系数、簇大小约束和地理可视化锁定最终k值
5.1 轮廓系数:为什么它是肘部法的“后悔药”,且必须与SSE联合解读?
轮廓系数s(i)衡量单个样本i的聚类质量:
a(i):i到同簇其他点的平均距离;b(i):i到最近异簇所有点的平均距离;s(i) = (b(i) - a(i)) / max(a(i), b(i)),取值[-1,1],越接近1越好。
但全局轮廓系数(mean silhouette score)不是万能的:
- 它偏好球形簇,对长条形地理簇(如沿河分布的门店)敏感度低;
- 当k过大时,它可能持续升高(如k=10时s=0.65 > k=5时s=0.58),但这不代表k=10更好——业务上不可能管理10个大区。
因此,我们采用双阈值判定:
- 轮廓系数必须 >0.5(基本可接受);
- 且在肘部点k值处,轮廓系数应为局部峰值(即s(k) > s(k-1) 且 s(k) > s(k+1))。
# 已在2.2节计算silhouette_list,此处绘制对比图 plt.figure(figsize=(12, 5)) plt.subplot(1, 2, 1) plt.plot(k_list, sse_mean, 'bo-', label='Mean SSE') plt.fill_between(k_list, sse_lower, sse_upper, alpha=0.2) plt.xlabel('k') plt.ylabel('SSE') plt.title('SSE Curve') plt.grid(True, alpha=0.3) plt.xticks(k_list) plt.subplot(1, 2, 2) plt.plot(k_list, silhouette_list, 'go-', label='Silhouette Score') plt.xlabel('k') plt.ylabel('Silhouette Score') plt.title('Silhouette Score Curve') plt.grid(True, alpha=0.3) plt.xticks(k_list) plt.tight_layout() plt.show() # 找出SSE肘部点(二阶差分最大)和轮廓系数局部峰值 optimal_k_sse = k_for_strength[np.argmax(elbow_strength)] optimal_k_sil = None for i in range(1, len(silhouette_list)-1): if silhouette_list[i] > silhouette_list[i-1] and silhouette_list[i] > silhouette_list[i+1]: if silhouette_list[i] > 0.5: optimal_k_sil = k_list[i] break print(f"SSE推荐k: {optimal_k_sse}") print(f"轮廓系数推荐k: {optimal_k_sil}")逻辑说明:双图并列展示,强制读者对比两个指标。optimal_k_sil只取第一个满足局部峰值且>0.5的k值,避免过度追求高分而选过大k。若两者不一致(如SSE推k=5,轮廓推k=6),进入第5.2节的业务校验。
5.2 簇大小约束:用最小簇样本数过滤不合理的k值
业务硬约束:每个大区至少管理20家门店(427×5%≈21)。我们编写函数,对任意k值,检查聚类后各簇样本数:
def validate_cluster_size(kmeans_model, min_size=21): """检查KMeans结果中各簇样本数是否满足最小尺寸""" labels = kmeans_model.labels_ unique_labels, counts = np.unique(labels, return_counts=True) under_sized = unique_labels[counts < min_size] if len(under_sized) == 0: return True, None else: return False, under_sized # 对SSE推荐的k值验证 kmeans_opt = KMeans(n_clusters=optimal_k_sse, n_init=10, random_state=42) kmeans_opt.fit(X_scaled) valid, bad_clusters = validate_cluster_size(kmeans_opt, min_size=21) print(f"k={optimal_k_sse} 簇大小校验: {'通过' if valid else '失败'}, 小簇: {bad_clusters}") # 若失败,尝试k=optimal_k_sse-1 if not valid: k_alt = optimal_k_sse - 1 if k_alt >= 2: kmeans_alt = KMeans(n_clusters=k_alt, n_init=10, random_state=42) kmeans_alt.fit(X_scaled) valid_alt, _ = validate_cluster_size(kmeans_alt, min_size=21) print(f"降k至{k_alt}后校验: {'通过' if valid_alt else '仍失败'}") final_k = k_alt if valid_alt else optimal_k_sse else: final_k = optimal_k_sse else: final_k = optimal_k_sse逻辑说明:np.unique(labels, return_counts=True)高效统计各簇样本数。min_size=21是业务阈值,可根据实际调整。若k=5失败(如出现含8家店的簇),则降k至4,因减少簇数必然增大平均簇大小。
5.3 地理可视化:用散点图叠加聚类标签,让业务方一眼看懂分群逻辑
# 获取最终k值的聚类结果 kmeans_final = KMeans(n_clusters=final_k, n_init=10, random_state=42) labels_final = kmeans_final.fit_predict(X_scaled) # 反向还原地理坐标(仅用于可视化,聚类仍在标准化空间) # 注意:scaler.inverse_transform需要原始X_raw形状,但X_scaled是标准化后,故用X_raw作参考 # 更稳妥做法:保存原始坐标,直接映射标签 df['cluster'] = labels_final # 绘制地理散点图 plt.figure(figsize=(12, 10)) scatter = plt.scatter(df['lon'], df['lat'], c=df['cluster'], cmap='tab10', s=60, alpha=0.8) plt.colorbar(scatter, label='Cluster ID') plt.xlabel('Longitude') plt.ylabel('Latitude') plt.title(f'Geographic Clustering Result (k={final_k})') plt.grid(True, alpha=0.3) # 标注每个簇中心(反向还原) centers_scaled = kmeans_final.cluster_centers_ centers_raw = scaler.inverse_transform(centers_scaled) # 还原为原始量纲 plt.scatter(centers_raw[:, 0], centers_raw[:, 1], c='red', s=200, marker='x', linewidths=3, label='Centroids') plt.legend() plt.show() # 打印各簇统计 print(f"\n最终k={final_k}的聚类统计:") for cluster_id in range(final_k): cluster_data = df[df['cluster'] == cluster_id] print(f"簇{cluster_id}: {len(cluster_data)}家店, " f"平均销售额={cluster_data['monthly_sales'].mean():.1f}万元, " f"地理跨度={cluster_data['lon'].max()-cluster_data['lon'].min():.3f}°")逻辑说明:scaler.inverse_transform(centers_scaled)将标准化空间的中心点还原为原始经纬度,确保地图标注准确。cmap='tab10'支持最多10类,颜色区分度高。业务方不关心SSE,只关心“这个红圈里的店归谁管、卖多少、离多远”——最后一行打印的统计,就是交付物的核心。
参数说明:
s=60:点大小适中,避免重叠遮挡;alpha=0.8:轻微透明,体现密度;marker='x':中心点用叉号,区别于样本点;- 地理跨度用经度差近似(小范围可用),实际应用中应转为平面距离(如
geopy.distance)。
6. 进阶技巧:当肘部法失效时,用Gap Statistic替代,以及如何自动化k值选择流水线
6.1 Gap Statistic:比肘部法更严谨的统计检验,但计算成本翻5倍
肘部法本质是启发式,而Gap Statistic(Tibshirani et al., 2001)提供统计显著性检验:它比较真实数据的SSE与参考数据集(均匀分布)的SSE期望值之差(Gap),选择使Gap最大且满足Gap(k) ≥ Gap(k+1) - s_{k+1}的k值,其中s_{k+1}是Gap的标准差。
from sklearn.datasets import make_blobs def gap_statistic(X, k_range, n_refs=20, n_init=10, random_state=42): """ 计算Gap Statistic X: 标准化后的数据 k_range: k值列表 n_refs: 参考数据集数量 """ gaps = np.zeros(len(k_range)) refs_sse = np.zeros((n_refs, len(k_range))) # 生成参考数据集:在X的边界框内均匀采样 x_min, x_max = X.min(axis=0), X.max(axis=0) for r in range(n_refs): # 生成与X同形状的均匀随机数据 X_ref = np.random.uniform(x_min, x_max, size=X.shape) for idx, k in enumerate(k_range): kmeans_ref = KMeans(n_clusters=k, n_init=n_init, random_state=random_state+r) kmeans_ref.fit(X_ref) refs_sse[r, idx] = kmeans_ref.inertia_ # 计算真实数据SSE real_sse = [] for k in k_range: kmeans_real = KMeans(n_clusters=k, n_init=n_init, random_state=random_state) kmeans_real.fit(X) real_sse.append(kmeans_real.inertia_) # Gap = log(参考SSE均值) - log(真实SSE) log_ref_sse_mean = np.log(refs_sse.mean(axis=0)) log_real_sse = np.log(real_sse) gaps = log_ref_sse_mean - log_real_sse # 计算s_k: 参考SSE标准差 * sqrt(1 + 1/n_refs) std_ref_sse = refs_sse.std(axis=0) sk = std_ref_sse * np.sqrt(1 + 1/n_refs) return gaps, sk, real_sse # 执行Gap Statistic(耗时较长,约2分钟) gaps, sk, real_sse_gap = gap_statistic(X_scaled, k_list, n_refs=20) # 寻找最优k:满足Gap(k) >= Gap(k+1) - s_{k+1} optimal_k_gap = None for i in range(len(gaps)-1): if gaps[i] >= gaps[i+1] - sk[i+1]: optimal_k_gap = k_list[i] break print(f"Gap Statistic推荐k: {optimal_k_gap}")逻辑说明:n_refs=20是经验值,太少则标准差估计不准。x_min/x_max定义参考数据边界,确保与真实数据可比。sk的计算包含sqrt(1 + 1/n_refs)修正项,这是Gap Statistic原文公式。Gap Statistic的优势在于给出统计决策规则,而非主观视觉判断;劣势是计算量大,且对高维数据敏感。
6.2 自动化流水线:封装为函数,一键输出k值、评估报告与可视化
我们将前述所有步骤封装为可复用函数,输入原始数据,输出结构化结果:
def find_optimal_k(X_raw, k_range=range(2, 16), min_cluster_size=21, n_bootstraps=50, n_refs_gap=20, random_state=42): """ 全自动k值优化流水线 返回: dict 包含推荐k、各指标曲线、可视化路径 """ # 步骤1:标准化 scaler = StandardScaler() X_scaled = scaler.fit_transform(X_raw) # 步骤2:计算SSE与轮廓系数 k_list = list(k_range) sse_list = [] sil_list = [] for k in k_list: kmeans = KMeans(n_clusters=k, n_init=10, random_state=random_state) kmeans.fit(X_scaled) sse_list.append(kmeans.inertia_) sil_list.append(silhouette_score(X_scaled, kmeans.labels_)) # 步骤3:Bootstrap sse_mean, sse_lower, sse_upper = compute_sse_bootstrap( X_scaled, k_list, n_bootstraps=n_bootstraps, random_state=random_state ) # 步骤4:肘部强度 sse_diff2 = np.diff(np.diff(sse_mean)) elbow_k = k_list[2:][np.argmax(np.abs(sse_diff2))] # 步骤5:Gap Statistic(可选,注释掉可加速) # gaps, sk, _ = gap_statistic(X_scaled, k_list, n_refs=n_refs_gap) # gap_k = ... # 同上逻辑 # 步骤6:业务约束校验 kmeans_elbow = KMeans(n_clusters=elbow_k, n_init=10, random_state=random_state) kmeans_elbow.fit(X_scaled) valid, _ = validate_cluster_size(kmeans_elbow, min_size=min_cluster_size) final_k = elbow_k if valid else elbow_k - 1 # 步骤7:生成报告 report = { 'recommended_k': final_k, 'sse_curve': {'k': k_list, 'mean': sse_mean.tolist(), 'lower': sse_lower.tolist(), 'upper': sse_upper.tolist()}, 'silhouette_curve': {'k': k_list, 'score': sil_list}, 'elbow_strength': {'k': k_list <p> <a href="https://download.csdn.net/download/abc991835105/88287361" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>