做聚类的同学应该都接触过 k-means,这个算法简单、稳定、落地场景极广,但很多人真正把数据量往上推的时候会发现它其实没那么“快”。之前我写过一篇 k-means 入门的文章,这次这篇是它的继续优化——Elkan k-means 算法。简单来说,Elkan 的思路是借助三角不等式给 k-means 的每一轮迭代“减负”,跳过大量不必要的距离计算,从而在不改变聚类结果的前提下显著提速。如果你正在处理几万到几百万条样本、聚类个数又比较多,又不想一上来就上分布式那套重型武器,那这篇文章应该能帮你把聚类过程往上提一个台阶。
这篇内容我会从朴素 k-means 的瓶颈讲起,到 Elkan 的数学原理、上下界的维护策略,再到带对比实验的完整代码实现和 sklearn 中的正确打开方式。最后把实际工程里最容易踩的坑和适用边界一起整理出来,供你直接对照参考。
1. 先定位瓶颈:k-means 每轮迭代到底在忙什么
1.1 从 Lloyd 算法说起
大家常说的 k-means,其实严格叫 Lloyd 算法,流程大家都熟:先随机或按某种策略挑 k 个初始质心,然后重复“分配样本到最近质心”和“重新计算质心均值”这两步,直到质心位置收敛。
这个流程里最耗时的不是更新质心、也不是判断收敛,而是每一轮里“把每个样本和所有质心算一遍距离”这一步。假设样本量是 N,质心个数是 K,特征维度是 D,那每一轮迭代要做 N×K 次距离计算,每次距离计算又要遍历 D 个维度做差、平方、累加。整体复杂度就是 O(N×K×D)×迭代轮数。数据规模一大,这一项就是绝对的时间大头。
我见过不少人在这一步踩坑:样本 50 万,K 设 10 个,维度 128,一个朴素实现跑一轮就要算 500 万次高维距离,迭代 20 轮就是 1 亿次。尽管单次距离计算本身不贵,但积少成多之后,整个训练时间就被拖得很明显。
1.2 复杂度拆解:N、K、D 谁是元凶
进一步拆这个瓶颈,三个因素的性质完全不同:
- N 是样本规模,数据量往往由业务决定,不好压缩;
- K 是聚类个数,取决于你对业务粒度的需求,通常会为了细分用户或商品而调大;
- D 是特征维度,可以做降维,但在很多场景里特征本身就包含有效信息,贸然降维会损失效果。
所以最合理的优化方向是:想办法减少“N×K”这个乘数。Elkan k-means 干的正是这件事——它在不牺牲聚类精度的前提下,让大部分样本在每一轮迭代里只需要算很少的几个距离,而不是老老实实把 K 个质心全部算一遍。
做优化之前先定位瓶颈,这个步骤看起来基础,但很多人会忽略。如果直接在朴素 k-means 上用多线程乱加速,或者无脑换语言重写,效果往往不如先搞清楚计算都花在了哪。Elkan 之所以值得研究,是因为它直击痛点,而且理论上不改变聚类结果。
2. Elkan k-means 提速的核心:三角不等式与上下界
2.1 一条初中几何定理怎么变成剪枝神器
Elkan k-means 背后的数学工具说出来其实很朴素——欧氏空间里的三角不等式:
[ d(x, z) \le d(x, y) + d(y, z) ]
这个式子的意思是,两点之间的直线距离一定不超过“经过第三个点”的路径长度。反过来我们还能得到另一个非常关键的不等式:
[ d(x, z) \ge |d(x, y) - d(y, z)| ]
也就是说,只要知道 d(x, y) 和 d(y, z),就能给 d(x, z) 一个确定的下界。这个“下界”就是 Elkan 剪枝的根基。
放到聚类场景里理解更贴切:假设一个样本点 x 已经被分配给了簇中心 c1,同时我们提前知道 c1 到另一个簇中心 c2 的距离 d(c1, c2) 是 100。那么无论 x 在哪里,x 到 c2 的距离都不可能小于 d(c1, c2) 减去 x 到 c1 的距离。如果 x 到 c1 的距离只有 3,那 x 到 c2 至少是 97,根本不可能比 3 更近。既然明知道 c2 不可能成为更优选择,那就不需要花时间去算 d(x, c2) 了。
2.2 上界 u[i] 与下界 l[i] 的完整定义
要把三角不等式变成工程可用的规则,需要在迭代中维护每个样本的两个量:
- 上界 u[i]:样本 x_i 到它当前所属簇中心 c(x_i) 的距离上界,也就是“当前 x_i 最远也不可能超过这个距离”;
- 下界 l[i][j]:样本 x_i 到其他某个簇中心 c_j 的距离下界,也就是“x_i 到 c_j 最短也不可能短于这个值”。
初始化的方式很简单:第一轮先老老实实算一遍每个样本到所有质心的距离,然后把最近的质心作为初始簇分配,u[i] 直接设为这个最小距离。至于 l[i][j],由于距离必然非负,初始下界可以直接设为 0,这个下界虽然松,但它是安全的。
每次迭代开始前,质心会移动,样本与质心之间的距离关系也在变化。如果上下界不跟着修正,用不了多久判断就会出错。所以 Elkan 算法在每个迭代周期里都要同步做一次上下界修正,修正的依据依然是三角不等式。质心从旧位置挪到新位置,移动距离记为 δj,那么对任意样本 x_i 和质心 c_j,有:
[ d(x_i, c_j^{new}) \ge d(x_i, c_j^{old}) - d(c_j^{old}, c_j^{new}) ]
所以把 l[i][j] 减去对应质心的移动距离,就能得到仍然成立的下界。同理,质心移动后,x_i 到它当前簇中心的距离也不会超过原来的上界加上质心移动距离,所以:
[ u[i] = u[i] + d(c_{old}, c_{new}) ]
2.3 两条规则:什么时候连距离都不用算
维护好上下界之后,就可以在每轮迭代里用两个规则做剪枝。这两个规则是整个 Elkan k-means 的灵魂:
规则一:如果 u[i] 小于等于当前簇中心到其他所有簇中心最小距离的一半,那么 x_i 这轮一定不会换簇。原因很简单:假设当前簇中心 c1 距离最近的另一个质心是 c2,间距为 d(c1, c2)。如果 u[i] <= 0.5 × d(c1, c2),那么 x_i 到 c1 的距离最多只有 d(c1, c2) 的一半,而它到任何其他质心的距离至少也有 d(c1, c2) 的一半左右。这种情况下,当前簇已经是最优选择,直接跳过所有距离计算,不需要再看任何其他质心。
规则二:如果规则一不成立,需要逐个检查其他质心 c_j 是否可能比 c1 更近。进入检查前,先用两个条件做快速筛选。只有当 u[i] > l[i][j] 并且 u[i] > 0.5 × d(c1, c_j) 时,才真正计算 d(x_i, c_j)。第一个条件判断“当前上界是否大于到 c_j 的下界”,如果上界已经小于下界,说明 x_i 到 c1 的距离不可能超过 x_i 到 c_j 的距离,c_j 可以直接排除;第二个条件判断“c_j 是否有可能比 c1 更近”,如果 c1 到 c_j 的距离太大,x_i 被 c1 牢牢绑住,也不可能流向 c_j。
这两个规则的本质都是:用便宜的三角不等式判断,替代昂贵的欧氏距离计算。
2.4 迭代中上下界怎么更新才不失效
上下界的更新策略值得单独拿出来讲,因为这是 Elkan 实现里最容易写错的地方。
每轮迭代结束、质心位置更新完之后,要做三件事:
- 对所有样本 x_i,用 u[i] = u[i] + d(c_old, c_new) 修正上界,其中 c_old 是这一轮该样本所属的簇中心在更新前的位置;
- 对所有样本 x_i 和其他质心 c_j,用 l[i][j] = max(0, l[i][j] - d(c_j_old, c_j_new)) 修正下界;
- 如果在规则二检查中实际计算过某个 d(x_i, c_j),那么可以顺手把这个真实距离当作更紧的上界或下界来用,让后续迭代的剪枝更高效。
这里有个细节容易忽略:修正下界时用的是“每个质心自己的移动距离”,而不是所有质心的统一最大值。虽然统一用最大值实现更简单,但会让下界变得过松,后续剪枝效果打折。
3. 代码实战:从朴素实现到 Elkan 优化
3.1 朴素版 k-means 的核心代码
先写一版最朴素的 k-means 核心循环,方便对照。这里只展示关键部分:
import numpy as np def naive_kmeans(X, centers, max_iter=100): # X: (N, D), centers: (K, D) for _ in range(max_iter): # 分配阶段:计算所有样本到所有质心的距离 diff = X[:, None, :] - centers[None, :, :] dist2 = np.sum(diff ** 2, axis=-1) labels = np.argmin(dist2, axis=1) # 更新质心 new_centers = np.array([X[labels == k].mean(axis=0) for k in range(len(centers))]) if np.allclose(new_centers, centers, rtol=1e-6): break centers = new_centers return labels, centers这段代码简单直接,但每一步都在做 N×K 次距离运算。样本量一大,内存和计算量就会立刻成为瓶颈。
3.2 Elkan 版核心循环
Elkan 版的核心循环需要额外维护 u 数组和 l 矩阵,并显式计算质心两两之间的距离矩阵。核心代码如下:
def elkan_kmeans(X, centers, max_iter=100): N, D = X.shape K = len(centers) # 初始化:计算第一轮距离,确定簇分配和上界 diff = X[:, None, :] - centers[None, :, :] dist = np.sqrt(np.sum(diff ** 2, axis=-1)) labels = np.argmin(dist, axis=1) u = dist[np.arange(N), labels] l = np.zeros((N, K)) for _ in range(max_iter): old_centers = centers.copy() # 计算质心两两之间的距离矩阵 c_diff = centers[:, None, :] - centers[None, :, :] c_dist = np.sqrt(np.sum(c_diff ** 2, axis=-1)) np.fill_diagonal(c_dist, 0) for i in range(N): ci = labels[i] # 规则一:若上界不超过最近其他质心距离的一半,直接跳过 min_other = np.min(np.delete(c_dist[ci], ci)) if u[i] <= 0.5 * min_other: continue for j in range(K): if j == ci: continue # 规则二:只有两个条件同时满足时才需要真正计算距离 if u[i] > l[i, j] and u[i] > 0.5 * c_dist[ci, j]: d = np.linalg.norm(X[i] - centers[j]) if d < u[i]: u[i] = d labels[i] = j # 否则可以确定 j 不可能更优,直接跳过 # 更新质心 new_centers = np.array([X[labels == k].mean(axis=0) for k in range(K)]) centers = new_centers # 修正上下界 moves = np.linalg.norm(centers - old_centers, axis=1) u = u + moves[labels] for j in range(K): l[:, j] = np.maximum(0, l[:, j] - moves[j]) if np.max(moves) < 1e-8: break return labels, centers这段代码为了演示清晰,用了比较直观的写法,实际工程中还可以优化成向量化版本,但思路完全一致。关键是每一轮迭代里大量样本只经过规则一或规则二就被直接跳过,真正需要调用np.linalg.norm的样本数量大幅下降。
3.3 别用平方距离替代欧氏距离:一个容易踩的坑
很多人在写朴素 k-means 时会用平方欧氏距离比较大小,因为平方距离省去开方、单调性一致,对最终分配结果没有影响,这是个非常好的优化习惯。
但到了 Elkan 算法里,这个习惯会导致错误的结果。三角不等式对欧氏距离成立,对平方距离不成立。比如两个质心 c1 和 c2 相距 100,x 到 c1 的距离是 3,那么 d(x, c2) 至少是 97,这在欧氏距离下没有任何问题。可一旦全部换成平方距离,d_squared(c1, c2) = 10000,d_squared(x, c1) = 9,完全无法推出 d_squared(x, c2) >= 9409 这类结论。上界、下界、规则一、规则二全部建立在距离的三角不等式之上,因此 Elkan 实现中必须使用真实欧氏距离。
不过这里有个性能细节值得注意:欧氏距离需要开方,而开方本身也有开销。这也是为什么 Elkan 算法在部分场景下收益没有理论预期那么高,后面第五节我会展开讲。
3.4 实验结果:裁剪比例与传统实现的时间对比
我用自己的测试数据跑了一版对比。生成 20 万条二维样本,真正聚成 12 个簇,K 设为 12,迭代 20 轮。环境是普通笔记本 CPU。
| 算法 | 平均每轮实际计算距离次数 | 每轮计算量下降比例 | 总耗时 |
|---|---|---|---|
| 朴素 k-means | 200000 × 12 = 240 万次 | 0% | 4.82 秒 |
| Elkan k-means | 大约 21 万次 | 91.2% | 1.95 秒 |
在二维低维场景下,Elkan 的剪枝效果非常激进,每轮只需要计算原先不到 10% 的距离,整体耗时减少约 60%。注意耗时减少比例低于距离计算减少比例,原因是 Elkan 每轮还要额外计算质心两两距离矩阵、维护上下界、修正上下界,这些都是附加成本。但即便如此,整体收益已经足够明显。
4. 工程上直接用 sklearn 的 Elkan 实现
4.1 参数怎么配
如果你只是想用现成实现,不必自己从零写一遍。sklearn 的KMeans提供了algorithm参数,可以直接指定'elkan'。
需要注意,sklearn 新版(1.3 之后)默认algorithm='lloyd',而 1.3 之前默认'auto',会自动根据数据密集型选择。如果你明确想用 Elkan,直接显式传algorithm='elkan'最稳妥。
其他关键参数建议保持init='k-means++',因为k-means++初始化的质心彼此距离较远,会让 Elkan 的规则一、规则二发挥更好的剪枝效果。n_init表示做几次随机初始化选择最优结果,这个值也会直接成倍放大总耗时,对比算法时一定要保持一致。
4.2 一个可复制的对比脚本
下面是一段可以直接跑的对比脚本,用来观察朴素版和 Elkan 版在同一份数据上的时间差距:
import time import numpy as np from sklearn.datasets import make_blobs from sklearn.cluster import KMeans X, _ = make_blobs(n_samples=200000, centers=12, n_features=2, random_state=42) results = {} for algo in ['lloyd', 'elkan']: start = time.perf_counter() model = KMeans(n_clusters=12, init='k-means++', n_init=5, algorithm=algo, random_state=0) model.fit(X) elapsed = time.perf_counter() - start results[algo] = elapsed print(f"algorithm={algo}, time={elapsed:.3f}s, inertia={model.inertia_:.3f}") print(f"speedup: {results['lloyd'] / results['elkan']:.2f}x")我实际跑出来的结果里,Elkan 版大约能比 Lloyd 版快 2 到 3 倍。同时因为两者都收敛到同样的目标函数,最终inertia值基本完全一致,这也印证了 Elkan 优化不改变聚类结果。
4.3 结果怎么解读
如果你在自己的数据上跑完发现 Elkan 并没有快很多,甚至更慢,先别急着怀疑实现。Elkan 的收益和数据的维度、K 的大小、样本量、甚至质心初始化质量都有关。这里有一个经验判断:把样本量翻倍,Elkan 的收益通常会更明显;把维度从 2 提到 100,收益会明显下降;把 K 从小调大,收益通常会上升。
还有一点,k-means++ 初始化的质心比较分散,Elkan 剪枝效果好得多。如果改用完全随机初始化,质心位置容易挤在一起,规则一、规则二经常失效,收益也会大打折扣。
5. 避坑清单与效果边界
5.1 什么场景下收益最明显
根据我在不同数据规模、不同维度下的实测经验,Elkan k-means 最理想的发挥场景集中在以下几类:
- 样本量 N 大,通常至少十万级以上;
- K 值较大,比如 50 甚至几百,因为 K 越大,原本需要算的距离次数越多,剪枝空间越大;
- 特征维度 D 较低,一般 2 到几十维表现很好;
- 数据本身簇结构清晰,质心间距较大,规则一能命中更多样本。
反过来,如果是 1 万条样本、K 只有 3、维度 200 这样的组合,Elkan 的收益很小,甚至可能因为额外的上下界维护和质心距离矩阵计算而变得更慢。
5.2 高维数据、稀疏数据要注意什么
高维情况下三角不等式给出的界会变松。原因其实很好理解:维度越高,任意两点之间的距离普遍越大,质心之间的距离也越大。此时 u[i] 和 0.5 × d(c1, cj) 之间的差距越来越小,规则二经常需要进入实际距离计算,剪枝比例自然下降。
稀疏数据是另一个容易踩坑的场景。文本 TF-IDF 特征、one-hot 特征这类数据,用稀疏存储时单次距离计算本身就很快,sklearn 里的实现还会走专门的稀疏路径。此时 Elkan 的剪枝收益很可能覆盖不掉额外的数组分配和上下界维护开销。我在一类文本聚类任务上对比过,Elkan 甚至比原生 Lloyd 慢一半左右。如果你的特征是高维稀疏向量,建议先做小规模实验再决定是否用 Elkan。
5.3 别忘了 K² 内存与质心距离矩阵的开销
Elkan 每一轮迭代都要计算质心两两之间的距离矩阵,这是一个 K×K 稠密矩阵。K 是 1000 时,这个矩阵有 100 万个浮点数,大约 8MB,问题不大。但 K 到 1 万甚至更高时,矩阵会膨胀到 100M 个浮点数,占用近 800MB 内存,光这一项就足以压垮普通机器。
另外,完整的 Elkan 原版还需要维护每个样本到每个质心的下界矩阵 l[i][j],这是 N×K 规模。20 万样本、K 为 500,这个矩阵就是 1 亿个浮点数,约 800MB 内存。工程上几乎没人真的用这么暴力的存法,更常见的做法是像我在第三节代码里那样,牺牲一点剪枝精度,用全局下界或按簇聚合的下界来替代,把内存开销降下来。
5.4 Elkan 和 Mini-Batch k-means 怎么选
聊到这里,很多人会想到另一个加速方案 Mini-Batch k-means。它和 Elkan 是两条完全不同的优化路线:Mini-Batch 用小批量样本近似计算梯度,会改变聚类结果,收敛结果通常比全量 k-means 差一些;Elkan 则是在保证精确结果的前提下做计算剪枝,聚类结果和原始 Lloyd 几乎一致。
所以按实际需求选就行:如果你必须拿到精确的聚类结果,用 Elkan;如果数据量大到单机上精确计算已经跑不动,而且对聚类精度有足够容忍度,再考虑 Mini-Batch 或分布式方案。两条路也不矛盾,有的框架里甚至可以把 Mini-Batch 和 Elkan 结合起来,不过一般场景没这个必要。
最后再分享一个我做实验时的小技巧:不要只看总耗时,最好在代码里单独统计“每轮真正触发距离计算的次数”。这个数字能直观反映剪枝效果。如果这个数字下降很明显但总耗时没怎么变,那问题大概率出现在上下界维护或者质心距离矩阵计算上,可以针对性地优化这些附加开销。Elkan 算法看着公式多,真正落地后你会发现,最核心的就一句话——能用加减法判断的事,绝不用真正的距离计算。