ARTICLE DETAIL

资讯详情

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

Elkan k-means:用三角不等式加速聚类,原理与实战

Elkan k-means:用三角不等式加速聚类,原理与实战 做聚类的同学应该都接触过 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_iter100): # 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, axis1) # 更新质心 new_centers np.array([X[labels k].mean(axis0) for k in range(len(centers))]) if np.allclose(new_centers, centers, rtol1e-6): break centers new_centers return labels, centers这段代码简单直接但每一步都在做 N×K 次距离运算。样本量一大内存和计算量就会立刻成为瓶颈。3.2 Elkan 版核心循环Elkan 版的核心循环需要额外维护 u 数组和 l 矩阵并显式计算质心两两之间的距离矩阵。核心代码如下def elkan_kmeans(X, centers, max_iter100): 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, axis1) 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(axis0) for k in range(K)]) centers new_centers # 修正上下界 moves np.linalg.norm(centers - old_centers, axis1) 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 相距 100x 到 c1 的距离是 3那么 d(x, c2) 至少是 97这在欧氏距离下没有任何问题。可一旦全部换成平方距离d_squared(c1, c2) 10000d_squared(x, c1) 9完全无法推出 d_squared(x, c2) 9409 这类结论。上界、下界、规则一、规则二全部建立在距离的三角不等式之上因此 Elkan 实现中必须使用真实欧氏距离。不过这里有个性能细节值得注意欧氏距离需要开方而开方本身也有开销。这也是为什么 Elkan 算法在部分场景下收益没有理论预期那么高后面第五节我会展开讲。3.4 实验结果裁剪比例与传统实现的时间对比我用自己的测试数据跑了一版对比。生成 20 万条二维样本真正聚成 12 个簇K 设为 12迭代 20 轮。环境是普通笔记本 CPU。算法平均每轮实际计算距离次数每轮计算量下降比例总耗时朴素 k-means200000 × 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 之后默认algorithmlloyd而 1.3 之前默认auto会自动根据数据密集型选择。如果你明确想用 Elkan直接显式传algorithmelkan最稳妥。其他关键参数建议保持initk-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_samples200000, centers12, n_features2, random_state42) results {} for algo in [lloyd, elkan]: start time.perf_counter() model KMeans(n_clusters12, initk-means, n_init5, algorithmalgo, random_state0) model.fit(X) elapsed time.perf_counter() - start results[algo] elapsed print(falgorithm{algo}, time{elapsed:.3f}s, inertia{model.inertia_:.3f}) print(fspeedup: {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 算法看着公式多真正落地后你会发现最核心的就一句话——能用加减法判断的事绝不用真正的距离计算。
返回列表