十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

k-means++初始化:让KMeans聚类更稳更优的默认选择

k-means++初始化:让KMeans聚类更稳更优的默认选择 如果你用过sklearn里的KMeans大概率会注意到一个默认参数initk-means。大多数教程都会叫你“保持默认不要动”但很少说清楚它到底是什么、为什么好用。k-means不是新聚类算法它只是k-means的一个初始化策略解决的是“种子中心点怎么选更靠谱”的问题。随机选中心点会让结果不稳定甚至收敛到明显不合理的分组k-means用一套概率分散的策略让初始中心尽量拉开距离在绝大多数实际场景下都能显著改善聚类效果和稳定性。这篇文章适合三类人一是用sklearn做数据分析、对聚类原理半懂不懂的实践者二是正在学机器学习、被各种初始化方案绕晕的学生三是想手写聚类算法、但总在初始化上吃亏的开发者。我会把k-means的前因后果、数学直觉、手写实现、线上避坑全部串起来讲读完你不仅能说清它为什么是默认值还能在真实项目里换着花样用它。1. 先聊k-means的老毛病为什么初始中心点这么要命1.1 三步走回顾k-means完整流程k-means的算法流程其实非常朴素核心就三个步骤交替进行。第一步选定k个初始中心点这k个点通常是从样本里挑出来的。第二步把每个样本分配给离它最近的中心点形成k个簇。第三步重新计算每个簇内所有样本的均值用这个均值作为新的中心点。然后重复第二步和第三步直到中心点的变化足够小或者达到最大迭代次数。整个过程的优化目标是一个叫“簇内误差平方和”的函数通常写作[ J \sum_{j1}^{k}\sum_{x_i \in C_j} |x_i - c_j|^2 ]说白了就是算每个样本到它所属簇中心的距离平方和希望这个值越小越好。k-means本质上是在对这个目标函数做迭代优化每轮迭代都会让J不增但问题在于J是一个非凸函数它有很多局部最小值。你从哪个山头往下走取决于你一开始站在哪。我遇到不少初学者有个误解以为k-means收敛到的一定是全局最优。实际上不是k-means只能保证收敛到局部最优而初始中心点的选择直接决定了最后落到哪个局部最优。1.2 随机初始化带来的三大坑随机初始化的第一个坑就是中心点扎堆。假设数据里有两个距离很远的真实簇但你随机选的两个初始中心都落在其中一个簇附近那么另一个簇很可能被强行拆开或者合并最后聚出来的结果完全偏离数据本身的结构。第二个坑是收敛慢。初始中心点如果离最终解比较远迭代次数会明显变多。收敛慢不只是浪费时间更麻烦的是慢归慢最后还可能停在一个不太好的局部最优里白白浪费算力还得不到好结果。第三个坑是空簇问题。某个初始中心点如果周围几乎没有样本那么分配阶段可能没有任何样本被分给它这个簇就空了。空簇在工程实现里很讨厌因为计算均值时除数为零你得额外写逻辑去处理它。sklearn遇到空簇会自动用最远点重新初始化但如果你自己手写算法这就是一个必须处理的边界条件。1.3 一个真实的失败案例我最早接触这个问题是在一次用户分群的任务里。数据大概是四个维度的消费特征几千条样本k取6。第一次跑随机初始化效果还不错第二次再跑结果完全变了样——有两类用户被合成了一类另一类又被劈成两半。我当时第一反应是代码写错了仔细排查了半天才发现问题出在初始点上。后来我用合成数据复现过这个现象生成五个互相有部分重叠的高斯簇分别用随机初始化和k-means做初始化各跑50次。随机初始化那组inertia即目标函数J的值分布有个很明显的长尾最差的结果能比中位数高出一倍多而k-means那组结果非常稳定inertia的波动幅度小得多。这个对比让我彻底服气之后再写聚类初始化方案永远放在第一位。2. k-means核心原理让种子点“别挤在一起”2.1 算法五步拆解k-means是Arthur和Vassilvitskii在2007年提出的一种初始化方法思想一句话就能概括逐个挑选初始中心每挑一个新中心时让距离已有中心越远的样本越有可能被选中。完整步骤如下。第一步从数据集中均匀随机地选一个样本作为第一个中心点。第二步对每个样本x计算它与当前所有已选中心点的最近距离记作D(x)。第三步按照概率正比于D(x)的平方来随机选取下一个中心点也就是说D(x)越大被选中的概率越高。第四步重复第二步和第三步直到选出k个中心点。第五步用这k个中心点作为k-means的初始中心然后跑标准的k-means迭代过程。注意第一步是均匀随机后面每一步都是加权随机。加权随机意味着距离远的点更可能被选中但距离近的点也不是完全没机会只是概率低。这种“带倾向的随机”正是算法的聪明之处它既保证中心点尽量分散又保留了一定的随机性避免每次结果都一样导致无法多次尝试取优。2.2 为什么要用“距离平方”而不是距离本身很多人第一次看完算法描述会问按D(x)加权不好吗为什么要用D(x)的平方关键在于距离平方会显著放大“远”的优势。假设某个样本距离最近中心是1另一个是10。如果按距离线性加权后者的选中概率是前者的10倍但如果按距离平方加权概率比变成1:100。这意味着算法对“离所有中心都很远”的孤点极其敏感几乎一定会先把这些点选为中心从而快速铺满整个样本空间。从数学直觉上讲k-means的目标函数是距离平方和那么用距离平方来引导初始化相当于让初始状态和优化目标保持一致的尺度。论文里给了一个漂亮的结论经过k-means初始化后期望的目标函数值不超过最优解的O(log k)倍具体常数是8(ln k 2)。虽然这只是期望意义上的保证但已经比随机初始化的无保证强太多了。2.3 复杂度分析多花的代价很划算k-means初始化阶段的额外计算量主要在第二步——每挑选一个新中心都要计算所有样本到最近中心的距离。如果数据量是n要选k个中心复杂度是O(kn)。对于绝大多数中小规模数据这个成本完全可以接受跟后面k-means迭代本身的O(kn·iterations)比通常只占很小比例。对于非常大的数据集原始的k-means有一个缺点它是串行的每选一个中心都依赖前一个中心的全量扫描。针对这个问题有k-means||这样的改进方案通过多轮并行采样选出候选点集合再对候选点做一次加权聚类得到最终初始中心。sklearn的Mini-Batch KMeans内部也做了类似的优化所以大规模场景下我们并不是没有选择只是需要知道原始k-means的适用边界。3. 实操从零实现到sklearn一行切换3.1 手写k-means初始化理解了原理手写实现其实非常直接。我建议用一个数组维护“每个样本到最近中心的距离”这样每加入一个新中心后只需要把新中心到所有样本的距离和已有距离逐位取最小值就能高效更新。完整代码如下import numpy as np def kmeans_plusplus(X, k, random_stateNone): rng np.random.default_rng(random_state) n X.shape[0] # 第一步均匀随机选第一个中心 first_idx rng.integers(0, n) centers_idx [first_idx] # 计算每个样本到最近中心的距离平方 closest_dist np.linalg.norm(X - X[first_idx], axis1) ** 2 for _ in range(1, k): # 概率与距离平方成正比 total closest_dist.sum() if total 0: # 极端情况所有样本都重合退化为均匀随机 next_idx rng.integers(0, n) else: probs closest_dist / total next_idx rng.choice(n, pprobs) centers_idx.append(next_idx) # 更新最近距离取“旧距离”和“到新中心距离”的较小值 new_dist np.linalg.norm(X - X[next_idx], axis1) ** 2 closest_dist np.minimum(closest_dist, new_dist) return X[centers_idx], np.array(centers_idx)这里有两个细节想强调。第一closest_dist的更新方式非常关键。因为我们要的永远是每个样本到“已选中心集合”的最近距离所以每次加入新中心后只需要和这个新中心的距离比较一次不需要重新计算所有中心复杂度从O(k² n)降到O(kn)。第二total 0的极端情况一定要处理。当样本里存在大量重复点或者k设置得比去重后的样本数还大时某个中心一旦选中了重复点组的核心周围样本距离就全是零总和为零np.random.choice会直接报错。我在项目里遇到过几次全是因为没做数据去重加个保护逻辑能省不少排查时间。3.2 完成一整套k-means并不难有了初始化后面就是经典的迭代过程。我给出一个适合教学和中等数据量的版本def kmeans(X, k, max_iter100, tol1e-5, random_stateNone): centers, _ kmeans_plusplus(X, k, random_state) for _ in range(max_iter): # 分配阶段每个样本分给最近的中心 dists np.linalg.norm(X[:, None, :] - centers[None, :, :], axis2) labels np.argmin(dists, axis1) # 更新阶段用簇内均值作为新中心 new_centers [] for j in range(k): cluster_points X[labels j] if len(cluster_points) 0: # 空簇保留原中心下一轮继续参与 new_centers.append(centers[j]) else: new_centers.append(cluster_points.mean(axis0)) new_centers np.array(new_centers) # 收敛判断中心点移动量小于阈值 if np.linalg.norm(new_centers - centers) tol: break centers new_centers return centers, labels这段代码里我特意用循环处理每个簇而不是用列表推导式主要为了把空簇分支写得清晰。实际工程中可以再用numpy向量化加速但逻辑上这个版本足够支撑你理解整个流程。3.3 sklearn一行切换与对比实验实际项目里很少有人真的手写k-meanssklearn已经把k-means做成默认选项。关键在于理解参数含义from sklearn.cluster import KMeans model KMeans( n_clusters4, initk-means, # 默认值但最好明确写出来 n_init10, # 重点新版本sklearn默认是auto见下文 random_state42 ) labels model.fit_predict(X)n_init表示用不同初始点跑多少次最后取inertia最小的结果。旧版sklearn默认是10但新版改成了auto当init为k-means时auto实际只跑1次。这就意味着如果你升级了sklearn版本却不留意默认情况下等于放弃了一次十杯里选最优的兜底机会。我的建议是手动设置n_init10中小数据完全跑得起。为了直观看差距我用三个高斯簇的合成数据做了一个小实验每组连续跑30次固定n_init1只比较initk-means和initrandomimport numpy as np from sklearn.cluster import KMeans # 生成三个有部分重叠的二维高斯簇 X np.vstack([ np.random.normal(loc[0, 0], scale0.6, size(300, 2)), np.random.normal(loc[4, 3], scale0.6, size(300, 2)), np.random.normal(loc[-3, 3], scale0.6, size(300, 2)), ]) inertia_pp, inertia_rd [], [] for _ in range(30): km_pp KMeans(n_clusters3, initk-means, n_init1).fit(X) km_rd KMeans(n_clusters3, initrandom, n_init1).fit(X) inertia_pp.append(km_pp.inertia_) inertia_rd.append(km_rd.inertia_) print(k-means mean inertia:, np.mean(inertia_pp)) print(random mean inertia: , np.mean(inertia_rd))在我的实测里random那组偶尔能跑出和k-means一样好的结果但方差明显大得多最差时inertia能高出一倍以上。k-means那组则基本稳定在较低区间。这个实验能很好说明为什么k-means是默认方案它不保证单次一定最优但它让你的算法“大概率稳”。4. 实战踩坑用了k-means也不一定好4.1 k-means不是万能药别迷信k-means显著改善了初始化质量但它并不能根治k-means的所有问题。它只是让初始中心点更分散理论上提供了一个O(log k)的期望近似保证可单次运行的结果仍然可能是次优的。另外它面对的始终是欧氏距离下的凸簇假设。如果数据本身就长得像环形、月牙形形状高度不规则那么不管初始化做得多好k-means都无能为力这时候该换DBSCAN或谱聚类。还有一点要明白k-means不会改变算法收敛到局部最优的本质它只是让“第一次踩点”踩得更好。工程里我依然坚持用n_init10甚至n_init25多次运行取最优这相当于给k-means又加了一层保险丝。4.2 k怎么选肘部法、轮廓系数与实践取舍初始化方案再稳如果k值选错一切都是白搭。最经典的方法是肘部法绘制“不同k值下inertia”的折线图随着k增大inertia会不断下降下降幅度在某一点之后突然变缓这个拐点就是所谓“肘部”。但肘部法的主观性很强很多时候折线没有非常明显的拐角。我一般会配合轮廓系数一起看。轮廓系数对每个样本计算(a-b)/max(a,b)其中a是样本到同簇其他样本的平均距离b是样本到最近邻簇的平均距离取值范围在[-1,1]之间越大说明聚类越合理。实际项目中我会把k从2跑到10同时看inertia曲线、轮廓系数、簇的样本量分布再结合业务含义判断。有一类情况要特别提醒如果数据里本身没有明显的簇结构k-means会把均匀分布的数据硬切成k块这种情况下无论怎么选k聚类结果都不会有太多业务价值。先做PCA或者TSNE可视化看看数据是否真的存在聚集趋势再做聚类能省很多无用功。4.3 数据预处理标准化是必须做的不是可选项k-means的一切都建立在欧氏距离上而欧氏距离对特征尺度极其敏感。举个最典型的例子用户分群时同时用“年龄”和“年收入”两个特征年龄范围20到60收入范围3万到200万直接算距离的话收入的差异会完全覆盖年龄的差异聚类结果等于是只看收入一个维度。解决办法很简单聚类前对所有连续特征做标准化常用的有Z-score标准化让每个特征变成均值为0、方差为1。sklearn里一行StandardScaler就搞定。高维数据是另一个陷阱。当特征维度到达几百上千时欧氏距离会趋向于“所有样本都差不多远”区分度大幅下降。遇到文本TF-IDF这类高维稀疏数据我会先降维到几十维或者换成余弦距离配合k-means的变体。如果特征里有性别、地区这类类别变量不要直接塞进k-means独热编码之后空间会膨胀得很厉害聚类效果也往往一般这时可以考虑k-modes或者先用Embedding把类别特征映射成稠密向量。4.4 大数据量下的效率问题别让初始化拖后腿原始k-means的初始化是串行的每选一个中心都要全量扫描一遍数据。几百万条样本、k取几千时初始化时间会变得非常可观。我处理过一份千万级的行为日志数据单纯初始化就跑了将近十分钟迭代过程反而只花了几分钟。解决办法有两个方向。第一个是用MiniBatchKMeans它每次随机抽一个小批量样本更新中心点速度能快一到两个数量级。第二个是关注初始化方式MiniBatchKMeans底层默认也是k-means风格但在大规模实现里做了并行化和近似效果足够稳。如果项目对结果稳定性和精度要求很高有个折中做法是先对数据做一次Mini-Batch KMeans得到初始中心再在这些中心基础上用原始k-means精跑一轮速度接近快方案精度接近慢方案。5. 常见问题与排查技巧速查表根据这几年我在实际项目里踩过的坑整理了一份速查表遇到问题可以先按表排查症状可能原因排查思路每次运行聚类结果差异很大初始化随机性影响局部最优多固定random_state复现设置更高n_init10以上确认数据预处理完成出现空簇k偏大数据中包含大量重复或离群点先检查特征分布尝试减小k手写算法时写空簇保留逻辑inertia整体偏高特征尺度差异大存在极端离群点标准化所有连续特征用IQR或DBSCAN剔除离群点再看收敛特别慢初始中心扎堆或数据量过大确认使用k-means初始化大数据换MiniBatchKMeans聚类结果不符合业务直觉k选得不合适数据本身无簇结构特征空间不对先用可视化看分布对比不同k下的轮廓系数考虑换DBSCAN或层次聚类新版sklearn结果和旧版不一致n_init默认值变化手动设置n_init10保证新旧版本行为一致这里再补充一条经验无论用哪个库跑聚类之前先看一眼每个簇的样本量。若某个簇样本量少到只有一两百条而其他簇都有上万条很可能是k偏大或者有离群点干扰。先解决问题再往下建模。最后分享一个我自己的使用习惯。接到聚类任务时我不会一开始就调参而是先把数据可视化一遍看看点云形态然后标准化、设一个偏大的n_init用k-means跑一遍拿到结果之后把样本量、轮廓系数、inertia三张表全部打出来再决定要不要调整k值或者换算法。这个过程看起来啰嗦但能避免80%的返工。k-means是默认值不假但它真正值钱的细节藏在“为什么要这么选”和“选了之后怎么兜底”这两件事里。
返回列表