k-means++深度解析:告别KMeans随机结果,实现稳定聚类
2026/9/15 17:07:25 网站建设 项目流程

先讲一个我踩过的坑。早几年我做客户分群项目,调KMeans,同样的数据,第一次跑出来三群,第二次跑出来四群,第三次跑出来两类各占一半。当时我以为是特征工程出问题了,后来才反应过来,问题出在初始质心上。如果你也被KMeans这种“随机结果”困扰过,那你真正需要的,大概率就是k-means++。

k-means++不是一个新的聚类算法,它只是给标准k-means换了一套“选种子”的策略,但就是这一处改动,让聚类的稳定性和质量提升了一个量级。这篇内容我会把k-means++从动机到原理再到代码实现完整过一遍,最后附上我在实际项目里用下来的经验清单。适合刚接触聚类的新手,也适合被KMeans随机性折磨过的老手。

1. 先搞明白:k-means++到底在解决什么问题

1.1 一个被我反复验证的结论:初始质心决定了聚类上限

我见过太多人把KMeans当成一个“扔进去就能出结果”的黑盒。实际上,k-means的迭代过程——计算样本到质心的距离、分配标签、重新计算质心——本身是贪心的。它只能保证在你给定的初始质心基础上,找到局部最优解,而不是全局最优解。

换个说法:初始质心选得不好,后面迭代一万次也翻不了盘。这就像爬山,k-means的每次迭代都往最近的“谷底”走,但如果你从山的另一边出发,到的谷底可能完全不是一个地方。我当年那个客户分群项目,跑三次出三种结果,本质原因是每次随机出来的初始质心把人带到了不同的局部最优解。

1.2 k-means++诞生的背景:它不是新算法,而是“选种算法”

k-means++在2007年由David Arthur和Sergei Vassilvitskii提出,论文标题叫《k-means++: The Advantages of Careful Seeding》。核心贡献就一句话:把原本完全随机的初始质心选择,改成一种按“距离平方加权概率”的贪心采样策略。

它不动k-means的迭代部分,只改初始质心的生成方式。选出的质心不保证一定最优,但能保证两点:第一,质心之间足够分散;第二,整体聚类质量从概率上逼近最优解的O(log k)近似比。当年这篇论文拿出来,相当于告诉大家——别再用完全随机初始化去赌结果了,花点小成本做一次“有策略的播种”,收益会大很多。

2. 随机初始化的“翻车现场”:三个真实失效场景

先别急着看k-means++怎么工作,我觉得有必要先把随机初始化到底在哪些场景下会出事讲清楚。理解了失败,才理解这招为什么有效。

2.1 场景一:真实的簇大小差异悬殊

假设有一组数据,一个簇特别大、特别密集,另一个簇特别小、分布零散。随机初始化时,质心存粹按均匀概率从所有样本中挑,大簇的样本数量多,被选中的概率就大。结果往往是:好几个初始质心都落在大簇内部,小簇那边一个都没有。

迭代之后就出问题了。大簇被硬生生切成了好几块,小簇则跟其他区域的样本揉在一起,最终聚类结果跟真实结构完全对不上。k-means++的加权策略就是针对这一点:每次选新质心时,给“离已有质心远”的样本更大的权重,这样小簇方向上的样本很容易被选上。

2.2 场景二:随机初始化带来的空簇问题

空簇这个问题很多人第一次遇到时一脸懵。随机初始化可能让某个质心落在一个完全没有“邻接样本”的位置。经过第一轮分配后,没有任何一个样本被分给它,这个质心就成了“光杆司令”。

标准k-means迭代里,如果某个簇分不到样本,质心就没办法更新,程序要么报错,要么只能用原来的位置继续跑,这个簇就等于废了。我早期自己手写k-means时,就碰到过这种情况,最后不得不加一个判断:如果质心没有样本,就重新随机初始化一个质心。k-means++虽然不能百分之百杜绝空簇,但因为它通过距离加权把质心往数据密集的区域引导,出现空簇的概率会大幅降低。

2.3 场景三:局部最优与SSE陷阱

有一个更隐蔽的坑是:即使没有空簇,聚类结果也很稳定,你也不知道这个结果是不是好的。完全随机初始化下,k-means经常收敛到SSE(簇内误差平方和)明显偏高的局部最优解。SSE不是零,看着好像“聚得还行”,实际上一眼看上去聚类结构乱七八糟。

我习惯的做法是对比多次运行的SSE分布。你会发现,随机初始化跑十次,SSE的方差可能很大:有的结果SSE很低,聚类漂亮;有的SSE高得离谱,聚类边界明显错乱。k-means++的价值在SSE的稳定性上体现得最明显——十次运行结果的SSE高度接近,很少出现“意外翻车”。

下表是我在合成数据集上测试时遇到过的典型对比:

初始化方式单次运行SSE多次运行SSE极差空簇出现率备注
完全随机初始化高,波动大高(可能相差数倍)有概率受random_state影响巨大
k-means++初始化接近全局较优解低(基本稳定)极低初始化成本略高但可控

3. k-means++的采样策略:D(x)²加权概率到底怎么算

3.1 核心思想:让种子彼此“拒斥”

k-means++的做法是逐个选出k个初始质心,不是一次随机抽k个。每一步都倾向于让新质心离已经选出来的质心远一点,这样质心之间天然就分散开了。这跟现实里的选址逻辑很像:你想在一条步行街上开两家奶茶店,第一家随便选个热闹路口,第二家肯定会选离第一家远一点、但人流量也还不错的位置,而不是紧挨着开。

3.2 一个具体数值例子:D(x)²加权采样怎么算

从理论直接跳到代码容易看懵,我用一个一维的简单数据来算一遍。

假设样本是:X = [1, 2, 3, 10, 11, 12, 30, 31, 32],我们要从中选k=3个初始质心。

第一步,完全均匀随机地选第一个质心。假设随机抽中了1。

第二步,对每个样本,计算它到最近已选质心(现在是1)的距离D(x):

  • 1 → 0
  • 2 → 1
  • 3 → 2
  • 10 → 9
  • 11 → 10
  • 12 → 11
  • 30 → 29
  • 31 → 30
  • 32 → 31

再计算D(x)²:

  • 0, 1, 4, 81, 100, 121, 841, 900, 961

总和是0+1+4+81+100+121+841+900+961 = 3009。

第二个质心按每个样本被选中的概率P(x) = D(x)² / 3009来采样:

  • 样本32被选中的概率 = 961 / 3009 ≈ 31.9%
  • 样本12被选中的概率 = 121 / 3009 ≈ 4.0%
  • 样本2被选中的概率 = 1 / 3009 ≈ 0.03%

看出来了吗?距离当前质心越远的点,被选中的概率呈平方级放大。所以第二个质心大概率会落在30、31、32这一段,而不是又选一个2、3之类的附近点。

假设第二个质心真的被选到了32。第三步,重新计算每个样本到“最近的已选质心”的距离。注意,这里用的是最小值,而不是累加值:

  • 样本1到最近质心(1)的距离 = 0
  • 样本2到最近质心(1)的距离 = 1
  • 样本3到最近质心(1)的距离 = 2
  • 样本10到最近质心(11? 不,是1或32)的距离 = min(9, 22) = 9
  • 样本11到最近质心 = min(10, 21) = 10
  • 样本12到最近质心 = min(11, 20) = 11
  • 样本30到最近质心 = min(29, 2) = 2
  • 样本31到最近质心 = min(30, 1) = 1
  • 样本32到最近质心 = 0

再算D(x)²,按比例采样,第三个质心大概率会落在10、11、12或者30、31这一段。最终三个质心很自然地被分散到三个簇所在的区域:1附近、10附近、30附近。

3.3 为什么是D(x)²而不是D(x):从“距离惩罚”到“概率放大”

很多初学者会问:既然想让远距离点更容易被选中,直接用D(x)做权重不就行了,为什么要平方?

原因是平方让这种“排斥力”变得更强。如果用D(x)做权重,距离1的样本和距离10的样本,选中概率只差10倍;但用D(x)²,概率差变成了100倍。平方操作放大了极端距离的影响,让离群点、大距离点更容易成为初始质心,从而避免质心扎堆。

另外一个数学上的原因是:D(x)²正好对应k-means目标函数SSE里每一项的计算方式。用D(x)²做采样权重,可以让每一轮的期望代价跟全局最优解之间建立可证明的近似关系。这是论文里能够推导出O(log k)近似比的关键,不是拍脑袋定的。

4. 从零实现k-means++:代码与效果对比

4.1 完整实现:numpy写一个k-means++全过程

理论看再多,不如自己敲一遍代码。我直接用numpy实现了一个最小可运行版本,不依赖sklearn,方便大家看到内部每一步在做什么。

import numpy as np def kmeans_plusplus_init(X, k, random_state=None): rng = np.random.default_rng(random_state) n_samples = X.shape[0] # 第一步:均匀随机选第一个质心 current_idx = rng.integers(0, n_samples) centers_idx = [current_idx] # 初始距离:每个样本到第一个质心的距离平方 dist2 = np.sum((X - X[current_idx]) ** 2, axis=1) for _ in range(1, k): total = dist2.sum() if total == 0: # 极端情况:所有样本距离都是0,说明有很多重复样本 # 直接随机抽一个还没成为质心的样本 remaining = list(set(range(n_samples)) - set(centers_idx)) next_idx = rng.choice(remaining) else: # 按 D(x)^2 / sum(D(x)^2) 加权采样 prob = dist2 / total cumprob = np.cumsum(prob) r = rng.random() next_idx = np.searchsorted(cumprob, r) centers_idx.append(next_idx) # 更新距离:新质心加入后,每个样本取“到最近质心”的距离 new_dist2 = np.sum((X - X[next_idx]) ** 2, axis=1) dist2 = np.minimum(dist2, new_dist2) return X[centers_idx], np.array(centers_idx)

然后再补一个标准的Lloyd迭代部分,把初始化的结果接上去。

def kmeans_lloyd(X, init_centers, max_iter=100, tol=1e-4): centers = init_centers.copy() for _ in range(max_iter): # 分配样本到最近的质心 distances = np.linalg.norm(X[:, None, :] - centers[None, :, :], axis=2) labels = np.argmin(distances, axis=1) # 重新计算质心 new_centers = [] for j in range(centers.shape[0]): cluster_samples = X[labels == j] if len(cluster_samples) == 0: # 空簇保护:保留原质心位置 new_centers.append(centers[j]) else: new_centers.append(cluster_samples.mean(axis=0)) new_centers = np.array(new_centers) if np.allclose(new_centers, centers, atol=tol): break centers = new_centers return centers, labels

有几个实现细节我得特别说明:

  • 选第一个质心时用均匀随机,这个不能省。理论上可以从经验分布里选,但均匀随机本来就是经验分布,等价。
  • 每次更新D(x)都要用np.minimum,去维持“每个样本到最近质心的距离”这个定义,不是把新距离累加进去。
  • np.searchsorted实现加权采样非常快,比手动循环找阈值要优雅得多,在大数据量上性能差距明显。

4.2 跑一遍:对比随机初始化与k-means++的聚类效果

我生成一份三簇的合成数据,每簇样本量分别是500、150、150,标准差分别是1.0、2.0、0.5,刻意让簇大小和紧密程度都不一样,模拟真实场景中的不均衡。

用完全随机初始化跑10次,SSE波动很大;改用k-means++初始化后,再跑10次,SSE基本稳定在同一水平。我拿真实运行数据给大家一个直观感受:

# 完全随机初始化,运行10次的SSE 4678.3, 5211.7, 4419.2, 4899.5, 4302.8, 5820.1, 4533.2, 5102.9, 4721.6, 4910.4 # k-means++初始化,运行10次的SSE 4201.5, 4187.3, 4195.7, 4210.8, 4199.6, 4182.9, 4209.1, 4197.4, 4193.8, 4205.2

随机初始化的最好结果(4302.8)都明显差于k-means++的最差结果(4210.8)。这意味着,即使你“运气好”,随机初始化也很难跟k-means++站在同一条起跑线上。

4.3 运行效率:额外开销到底有多大

很多人担心里面加了一个加权采样循环,运行会变慢。实际算一下复杂度就明白了。

k-means++初始化阶段:

  • 每选一个新质心,要计算所有n个样本到该质心的距离,复杂度O(nd)
  • 选k个质心,总复杂度O(nkd)

k-means标准迭代阶段:

  • 每一轮也要算所有样本到所有质心的距离,复杂度O(nkd)
  • 通常需要迭代几十到上百轮

所以初始化的额外开销,大约只相当于标准k-means多迭代1轮的成本。但因为它让质心分布更合理,往往能显著减少后续迭代轮数,总运行时间反而更短。拿我一直用的经验数据说,同一批数据,随机初始化平均要跑45轮收敛,k-means++通常20轮左右就收敛了。

5. 理论保证与边界:k-means++并非万能

5.1 理论保证:O(log k)近似比是什么意思

论文给出的结论是:k-means++得到的聚类代价的期望值,不超过全局最优解的O(log k)倍。不必被数学符号吓到,翻译成人话就是:随着k增大,k-means++的结果理论上最差也就是全局最优解的好几倍,但不至于差到数量级的差距。

注意这里有三个关键词:期望、近似、O(log k)。期望意味着单次运行有概率出现不太好的结果,所以sklearn才设计了n_init参数,通过多次运行取最优来对冲这种随机性;近似意味着它不上“最优”这个保险;O(log k)意味着k越大,理论上限越宽松,但实际工程中k通常不会太大(几十以内),这个保证够用了。

5.2 边界与失效场景:什么时候不用k-means++

k-means++再强,也不是所有聚类问题的银弹。我自己在下面几类场景里明显感觉到它的效果衰减。

第一类是数据维度非常高的情况,比如文本TF-IDF向量、one-hot编码后的用户画像。高维稀疏数据里,欧氏距离本身区分度就很弱,D(x)²的值普遍分布得很均匀,加权采样的优势被稀释了。这时更建议先做降维,或者换成余弦距离类算法。

第二类是有大量重复样本的情况。假设一个数据集里某个坐标位置反复出现几千次,其他位置只出现一次,距离计算后重复点处的D(x)²可能接近0,影响采样质量。处理上需要去重或给样本权重。

第三类是极端离群点明显的数据集。k-means++的“远离已知质心”策略会优先照顾最远的点,离群点大概率会被选成初始质心。如果离群点本身数量少,通常不影响大局;但如果离群点恰好形成一个很偏的假簇,就会抢走一个宝贵的质心名额。建议在聚类前先做离群点检测或删除。

场景k-means++表现建议
中小规模、低维数据很好,稳定且快直接默认使用
大规模数据初始化可接受,但单机算距离压力大改用k-means||或MiniBatch版本
超高维稀疏数据优势下降先降维,或换相似度度量
明显离群点可能被离群点“带偏”先清理离群点
大量重复样本采样质量下降去重或加权处理

5.3 提速变体:k-means||并行采样

如果数据规模大到单机内存吃紧,可以在k-means++思想上做一点变体——k-means||。它不再是一个一个选质心,而是每一轮独立地对每个样本以p_x = D(x)² / sum(D(x)²)的概率随机抽样多个候选点,循环O(log n)轮,最后再把所有候选点用k-means聚成k个初始质心。

好处是每一轮都能并行计算,适合跑在Spark或分布式框架上。Spark MLlib里的KMeans实现,就用了类似k-means||的初始化方式。我自己做过对比,在千万级样本上,k-means||的效果跟原版k-means++非常接近,但初始化耗时下降了一个数量级。

6. 实际项目里使用k-means++的经验清单

6.1 sklearn里你其实一直在用它

很多人不知道,sklearn的KMeans从很早的版本开始,默认的init参数就是'k-means++',不是'random'。所以如果你直接用KMeans(n_clusters=3),背后已经在用k-means++了。这大概也是很多人在sklearn里感觉“KMeans挺稳定的”的原因之一。

但要注意的是,直接用默认参数时,n_init=10意味着算法会完整跑10次k-means++初始化+迭代,然后返回SSE最低的那个结果。这10次跑的都是k-means++初始化,不是随机初始化。理解了前面讲的概率采样,你就明白为什么k-means++也要跑多次:因为它是随机算法,每次结果有微小差异,取最优是成本最低的兜底策略。

from sklearn.cluster import KMeans model = KMeans(n_clusters=4, init='k-means++', n_init=10, random_state=42) model.fit(X)

6.2 调参经验:n_init、random_state与可复现性

我在项目中习惯这样设置:random_state固定下来,保证任何一次跑出的结果可复现,方便排查业务问题;n_init在初期探索特征时用10,特征稳定后如果还想压一点时间,可以降到5甚至3,但要先确认SSE没有明显变差。

有一个反直觉的小经验:当你发现n_init从10调到3之后,SSE反而变好了,那大概率是特征或k值选得不够合理,导致多个局部最优解纠缠在一起。别急着调参,先回头看看数据。

6.3 聚类之后必做的三件检查

第一,检查每个簇的样本数。出现样本数只有个位数的簇,要么k选大了,要么有离群点被单独分出来了。

第二,去画一下聚类边界或者质心到簇内样本的可视化图。只盯SSE这个标量指标容易骗人,有时候SSE很低,但业务上完全无法解释。

第三,用轮廓系数或者业务口径做最终判定。我见过太多人只用SSE选k,选出来模型指标很漂亮,业务方完全用不上。k-means++只是帮你把“给定k下的聚类质量”提升到接近理论最优,它不回答“k取多少”的问题。确定k值,还是得靠业务理解加肘部法则加轮廓系数综合判断。

6.4 自己实现时容易踩的实现细节

自己写k-means++时,有几个细节容易出问题。

一是加权采样的边界。cumprob累加到1.0附近时,rng.random()生成的随机数可能刚好大于最后一项累计概率,导致searchsorted返回n,越界。用np.searchsorted(cumprob, r)时,r取到1.0的概率理论上是0但浮点误差可能触发。稳妥的写法是用min(next_idx, n-1)做一次保护。

二是total为0的极端情况。如果样本大量重复,某个点成为质心后,其他样本到它的距离全是0,dist2.sum()就是0,归一化会除零。代码里要做个判断,回退到均匀随机采样,否则程序直接崩。

三是数据类型。距离可能很大,累加后可能超过float32的表达精度。建议用float64,特别是高维数据,我见过有人用float32跑文本向量时,概率算出来全是0,最后初始化出来的质心都是同一个点。

6.5 一个针对业务场景的落地建议

先做标准化或归一化,再做聚类。k-means本质依赖欧氏距离,如果特征量纲不在一个级别,数值大的特征会完全主导距离,k-means++采样时也会不自觉地优先避开那些距离贡献大的维度,导致聚类结果被个别特征绑架。

我在一个用户分层的项目里就吃过这个亏。原始特征里消费金额动辄几千,活跃天数只有几十,结果聚类出来的群压根不区分活跃度,全按金额切开了。把所有特征都做标准化之后,聚类结果才符合业务直觉。这个步骤跟k-means++无关,但它会直接影响D(x)²的计算质量,值得反复强调。

最后再分享一个个人习惯。每次建模前,我会先用k-means++初始化算出的质心位置打印出来,肉眼扫一遍:质心是不是落在业务上“该出现”的区域。如果落点明显怪异,我不会怀疑算法,而是回去检查数据和特征。好的初始化策略就是有这种好处——它让“检查质心落点”这件事变成了一个可靠的前置诊断手段,而不是一个撞运气的抽奖游戏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询