简介:一套基于MATLAB的肘部法K-means聚类优化代码,面向需要确定最佳聚类数K的选址聚类场景,适合本科及以上学生或研究人员用于课设、实验复现与小型项目开发。压缩包共3个文件,包含2个m脚本和1个mat数据文件;m文件实现肘部法计算、轮廓系数评估与结果可视化,mat文件内置可直接使用的测试数据,整体大小仅3KB,结构精简便于部署运行。代码全程带注释,逻辑清晰,能帮助理解K-means调参原理,通过肘部图与轮廓线双重判断最优聚类数,可灵活迁移至物流中心选址、客户群划分等实际聚类分析任务。下载后直接运行主脚本即可一键复现聚类结果,也可按需修改距离计算或聚类参数,适应不同业务数据;同时提供另一脚本便于二次开发。目前已有711人学习/下载,是开展聚类参数优化实验的实用参考。
1. 肘部法不是用来“看图”的:kmeans聚类的参数优化与选址落地
给kmeans聚类选k,最常听到的办法就是肘部法:画一条簇内误差平方和曲线,找到一个“拐点”当作k。但真实业务里,尤其是网点选址、充电桩布局、仓库分仓这类场景,SSE曲线往往没有明显的肘,或者拐了两次,很多人卡在这里就开始拍脑袋。造成这个局面的原因很简单,肘部法只是目标函数的一维投影,它受数据分布、距离度量、随机初始化和样本量影响很大,不先把这些关联参数固定下来,曲线根本不可信。这篇会从代价函数讲到参数组合,再给出一套代码完整的数据处理流程,覆盖“看懂肘部图、调出稳定k、落到选址坐标”三件事,适合评估网点数量的运营、写服务端聚类逻辑的工程师,以及需要给k值一个交代的算法岗。
2. 肘部法的基础:kmeans聚类的SSE与距离度量选择
2.1 为什么曲线会有“肘”:WCSS与簇内紧致度
KMeans的优化目标是最小化所有样本到所属簇中心的距离平方和,形式化成目标函数就是:
WCSS = Σ_{j=1}^{k} Σ_{x∈C_j} ||x - μ_j||²μ_j是第j个簇的中心,||·||是距离范数。当k=1时,WCSS接近全局方差;当k=N时,每个样本自己是簇,WCSS=0。随着k增加,WCSS单调递减,但递减速度会分成明显的两个阶段:第一阶段,新增簇把原本松散的样本切分开,WCSS下降很快;第二阶段,多出来的簇只是在已有簇内部做细分,每个簇本身已经很紧凑,边际收益骤降。这个两侧斜率差异明显的折点,就是“肘”。
实际数据里,如果某个“肘”看起来在k=3,另一个人换一种距离度量跑一遍,肘可能挪到k=4或k=5。原因在于WCSS里的范数不是唯一选择。曼哈顿距离会改变簇中心的计算方法,Haversine距离则处理球面上的弧长量纲,三者尺度和出口都不同,肘的位置天然不同。所以讨论肘部法之前,必须先定距离度量。
2.2 距离度量对肘部位置的三种影响
| 距离度量 | 簇中心定义 | 推荐数据结构 | 对肘部的影响 |
|---|---|---|---|
| 欧氏距离 | 均值 | UTM平面投影坐标 | 对离群点敏感,大离群点会把肘部拉平,拐点不明显 |
| 曼哈顿距离 | 各维中位数 | 量纲差异大的业务特征 | 对离群点更鲁棒,SSE趋势更陡,肘部相对靠前 |
| Haversine距离 | 球面均值,需迭代求解 | 原始经纬度 | 高纬度或跨带区域结果与平面法差异明显,肘位置可能偏移1到2个k |
选址场景如果直接用经纬度,我一般不会对lat/lon算欧氏距离,因为纬度1度的地面长度和经度1度不一样,中高纬度偏差超过10%。最稳妥的做法是把经纬度转成UTM平面投影再算欧氏距离,这样后续计算“覆盖半径”“最大配送距离”时,单位就是米,业务含义直接对应。
2.3 不依赖库的SSE手动实现
验证inertia到底在算什么,可以自己写十几行代码。下面这段不依赖sklearn,纯靠numpy实现WCSS:
import numpy as np def wcss_manual(X, labels, centers): """手动计算簇内误差平方和 X: (n_samples, n_features) 样本矩阵 labels: (n_samples,) 每个样本所属簇 centers: (k, n_features) 簇中心矩阵 """ n, _ = X.shape sse = 0.0 for i in range(n): diff = X[i] - centers[labels[i]] sse += np.dot(diff, diff) return sse逻辑说明:逐个样本累加它到所属簇中心的欧氏距离平方,返回值与sklearn中KMeans.inertia_在欧氏距离条件下完全一致。这里对diff做点乘而不是用np.linalg.norm() ** 2,可以避免开方再平方带来的浮点精度损耗。复杂度是O(n·d),5万样本以内手动实现没问题;超过这个量级就直接读inertia_,它底层是C扩展,速度差一个数量级。
注意:
inertia_只有在欧氏距离下才等于WCSS。换成Haversine距离后sklearn不会帮你算,需要自己实现球面距离版本的WCSS,选址脚本里我会这么处理。
3. kmeans聚类参数怎么调:基于肘部法的k值搜索与预处理
3.1 用sklearn的inertia_画最小肘部图
先给出最小可复现代码。这里用make_blobs构造演示数据,只是为了验证流程,真实业务中替换成预处理后的坐标矩阵即可:
import numpy as np import matplotlib.pyplot as plt from sklearn.cluster import KMeans from sklearn.datasets import make_blobs # 构造2000个样本、5个真实簇的演示数据 X, _ = make_blobs(n_samples=2000, centers=5, cluster_std=1.2, random_state=42) sse = [] k_range = range(1, 11) for k in k_range: km = KMeans(n_clusters=k, init="k-means++", n_init=10, max_iter=300, random_state=42) km.fit(X) sse.append(km.inertia_) plt.plot(list(k_range), sse, "o-") plt.xlabel("k") plt.ylabel("WCSS / inertia") plt.title("Elbow Method") plt.show()逻辑说明:循环里固定了init="k-means++"、n_init=10、random_state=42,这三个参数决定搜索出来的inertia不抖动。如果不固定random_state,k较大时会因为初始中心选择差异,导致同一条曲线在两次运行中出现两个位置不同的肘。
参数说明:n_init的含义是“从不同初始中心出发跑完整KMeans,取其中inertia最小的一次”。这个参数的历史默认值有坑:sklearn 1.2改成'auto',在小k时等价于10,大k时退化为1;1.4版本又把默认值改回10。因为默认值跨版本变动过,线上代码必须显式写n_init,否则升级依赖后聚类结果会变。max_iter=300是迭代上限,几万样本没问题,百万级样本调到500更稳。团队技术栈如果是MATLAB,原理完全一致:kmeans函数的sumd输出按簇累加后就是每个k对应的SSE,画出来的曲线和Python侧完全可比。
3.2 数据齐全不是直接跑:坐标预处理与标准化
选址类数据的原始字段一般是lat和lon。直接对经纬度跑kmeans是高频错误,原因前面已经说过,这里给标准处理流程。第一步是投影转换,用pyproj把经纬度转到UTM平面坐标:
import pyproj import numpy as np # WGS84经纬度 -> UTM 50N(中国东部及中部常用带) transformer = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:32650", always_xy=True) lon = data["lon"].values lat = data["lat"].values x, y = transformer.transform(lon, lat) X = np.column_stack([x, y])逻辑说明:UTM按6度经度分带,选错带号会导致y坐标严重变形。跨带城市建议按数据中心的经度动态计算带号:zone = int((lon.mean() + 180) / 6) + 1,再拼成对应EPSG编号。always_xy=True表示输入顺序是经度在前、纬度在后,参数写反不会报错,但平面坐标会扭曲,聚类结果在图上看着正常,落地后对不上真实地理位置。
投影之后是否需要标准化,分场景:如果只有x、y两个维度且单位都是米,量纲一致,不需要标准化,标准化反而会扭曲东西方向和南北方向的实际距离比例。如果X里还叠加了人口、需求量、地租等业务特征,则必须对每一列做z-score标准化,否则数量级大的特征会主导距离计算。这一步直接影响肘部曲线形状,没处理过的数据画出来只能算参考。
3.3 肘部不明显的三类场景与对应处理
第一类,簇大小悬殊,一个大簇加若干小簇,SSE拐点被大簇拖平,跑到k=10还在稳定下降。常见做法是取log变换或对样本做按簇规模采样,让大小簇对SSE的贡献接近。
第二类,特征维度高,比如10维以上的业务特征,SSE被无关维度稀释,肘部几乎不可见。此时先跑PCA降到2到3维再画肘部图,看到的是主要成分上的聚类可分性,业务上也更容易解释。
第三类,数据本质没有簇结构,均匀分布,不管k怎么调都不会有肘。此时肘部法本身就失效,要靠轮廓系数或Gap Statistic兜底,这部分在第5章展开。
提示:如果你发现肘部法跑出来的k值每次都不稳定,先查
n_init和random_state,再查数据预处理,最后才怀疑算法本身。顺序反了会浪费大量排错时间。
4. 肘部法选址聚类的参数组合:从拐点到落地网点坐标
4.1 业务半径对k的上限约束
选址场景里,k不是越大越好。如果业务规定网点服务半径是R公里,总面积是S平方公里,理论上网点数上限是S除以πR²。把肘部法选出的k和这个上限做对比:k超过上限,说明至少两三个网点位置重叠,这时候只看手肘没有意义;k明显小于下限,说明单个网点覆盖区域超出半径,落地后单点负载过高。
有两种常见修正方式:一种是把半径约束转成样本权重,比如对每个候选点计算它到最近已有点的距离,超出3公里的点权重置为1,否则置为0.5,通过sample_weight参数传入KMeans;另一种是聚类后检查每个簇的最大距离,超限就回退k值重新训练。我在实际项目里倾向后者,因为输出结果直接对应“哪些网点需要调整”的业务决策。
4.2 选址脚本的完整代码
下面的脚本可以直接跑,输入CSV里只需要三列:id、lat、lon。流程包含投影转换、肘部搜索、选k训练、中心点转回经纬度、覆盖半径统计:
import numpy as np import pandas as pd import pyproj from sklearn.cluster import KMeans # 读数据,字段要求: id, lat, lon data = pd.read_csv("poi.csv") # 经纬度转UTM平面坐标 transformer = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:32650", always_xy=True) x, y = transformer.transform(data["lon"].values, data["lat"].values) X = np.column_stack([x, y]) # 肘部搜索,记录每个k的SSE k_range = range(1, 11) sse = [] for k in k_range: km = KMeans(n_clusters=k, init="k-means++", n_init=20, max_iter=500, random_state=2024) km.fit(X) sse.append(km.inertia_) # 人工或kneed确定k,这里按k=4演示 k = 4 km = KMeans(n_clusters=k, init="k-means++", n_init=20, max_iter=500, random_state=2024) km.fit(X) # 中心点转回经纬度 inv_transformer = pyproj.Transformer.from_crs("EPSG:32650", "EPSG:4326", always_xy=True) center_lon, center_lat = inv_transformer.transform( km.cluster_centers_[:, 0], km.cluster_centers_[:, 1]) # 计算每个簇的95分位覆盖半径 coverage = [] for j in range(k): pts = X[km.labels_ == j] d = np.sqrt(((pts - km.cluster_centers_[j]) ** 2).sum(axis=1)) coverage.append(np.percentile(d, 95)) result = pd.DataFrame({ "center_lon": center_lon, "center_lat": center_lat, "coverage_95pct_m": np.round(coverage, 0), "size": np.bincount(km.labels_) }) result.to_csv("centers.csv", index=False)逻辑说明:训练阶段固定n_init=20,选址方案要用于后续评估,不能出现换台机器重跑一次结果就变的情况。固定random_state和n_init之后,KMeans实际上就是确定性算法,输出完全可复现。覆盖半径取95分位而不是最大值,是为了避免个别极端离群点把半径撑大,95分位对应“保证95%的订单距离可控”,在配送类业务里比max更有工程意义。
参数说明:EPSG:32650是中国中部常用的UTM带,其他地区需要按数据范围换带号。bincount统计每个簇的样本数,对应每个网点覆盖的用户量,后续做容量规划时直接用这列数据。coverage_95pct_m单位是米,输出时要和业务口径对齐,避免出现“预期3公里,算出来3000米”的单位乌龙。
4.3 肘点不唯一时怎么定k
完整代码把流程串起来之后,最常遇到的情况是肘部图给出两个候选k,比如k=4和k=6都有明显下降。这时候不要纠结于“哪个更像肘”,回到业务参数做对比:k=6相对k=4,95分位覆盖半径下降了多少,每个网点覆盖人数拆分后是否低于运营下限,多出来的两个网点增加的固定成本是否划算。这个决策过程可以写进算法报告,比反复跑随机初始化有用得多。
5. 用Kneedle与轮廓系数让肘部法可复现落地
5.1 自动找肘:KneeLocator替代人工读图
肘部图目测误差很容易在k=3到k=5之间漂移,多人评审时各执一词。用kneed库可以自动定位:
from kneed import KneeLocator knee = KneeLocator(list(k_range), sse, curve="convex", direction="decreasing") print("auto k =", knee.knee)参数说明:KneeLocator的原理是计算曲线上每个点到两端连线的距离,距离最大的点就是肘。curve="convex"和direction="decreasing"必须和曲线形态匹配,WCSS随k递减且向原点凹陷,所以是convex加decreasing;填反了会输出两个端点之一。注意kneed在0.8.0版本之前用elbow参数标记曲线方向,升级后换成了curve,旧脚本直接跑会报参数错误。
5.2 用轮廓系数复核选出的k
肘部法有个盲区:SSE只刻画紧致度,不刻画分离度。如果簇之间完全重叠,SSE很小但聚类没有实际意义。复核用轮廓系数:
from sklearn.metrics import silhouette_score score = silhouette_score(X, km.labels_, sample_size=5000, random_state=42) print("silhouette =", round(score, 4))逻辑说明:轮廓系数范围在-1到1之间,越大越好。选址类数据普遍在0.4到0.6之间,高于0.7说明簇结构非常明显,低于0.2说明这些点位本身没有簇状分布,肘部法选出的k不具解释力。sample_size=5000必须指定,因为silhouette是O(n²)复杂度,十万样本直接跑会卡到分钟级,采样估计趋势足够。
5.3 多方案批量导出对比
选址决策不能只出一版。写个循环,对每个候选k输出一份centers_k.csv,同时把95分位覆盖半径和簇规模打印出来,业务方拿到的就是一份带数据的对比表。这个做法能省掉大量重复操作,也让选k从“看图说话”变成“按数据比较”。脚本整体固化成一个函数后,下次换城市、换需求密度,只要换一份poi.csv就能重跑整套聚类流程。
本文还有配套的精品资源,点击获取