K-Means聚类避坑指南:原理、调参与大规模实践

发布时间:2026/10/26 6:15:59
K-Means聚类避坑指南:原理、调参与大规模实践

有人拿一份用户行为数据来找我,说想看看能不能分成几类人群。我打开脚本一看,K-Means 算法的 K 设成了 5,特征里既有一笔笔金额(几千到几十万)又有访问次数(0 到 20),中间没做任何标准化,跑出来的簇大小是 [9985, 12, 2, 1, 0]。这就是 K-Means 算法最典型的翻车现场——它不报错、不警告、不抛异常,安安静静地给你一个完全没意义的结果,然后你还得拿着这个结果去开会。K-Means 是很多人入门机器学习接触的第一个无监督算法,代码短、概念简单、sklearn三行就能跑通,正因为门槛低,大量项目里它被当成一个“随手调一下就完事”的工具,最后真正能用的聚类结果少得可怜。这篇文章我打算把这个算法从头拆一遍:它到底在优化什么、手写一遍核心迭代长什么样、K 值该怎么定、初始化和量纲这两个坑为什么几乎所有新手都会踩、结果不对劲的时候按什么顺序排查,以及数据量上到千万级之后该怎么换打法。不管你是刚学完课程想找点实战感觉,还是已经在项目里用过几次但总觉得结果“说不清道不明”,下面的内容应该都能对上你的场景。

1. 先搞清楚 K-Means 在优化什么,再谈调参

1.1 从“把样本分堆”到最小化簇内平方和

很多人对 K-Means 的理解停留在“它会把相近的点分到一组”,这句话没错,但太模糊了。真正决定它行为的,是一个明确的目标函数:

J = Σ(k=1..K) Σ(x ∈ C_k) || x - μ_k ||²

其中C_k是第 k 个簇的样本集合,μ_k是这个簇的质心(所有样本的算术平均)。这个量在sklearn里叫inertia_,中文常译作“簇内平方和”或“惯性”。算法的全部工作,就是找一组划分方式,让这个 J 尽可能小。

为什么是平方而不是绝对值?这里有个容易被忽略的设计动机。如果你把目标换成簇内曼哈顿距离之和(也就是各点到中心点的 L1 距离之和),那最优的“中心”就不再是均值,而是中位数,对应的算法一般叫 K-Medians。均值有个非常好的性质:它让平方和最小,而且求导之后形式极其干净——μ_k = (1/|C_k|) Σ x,一步就能算出来,不需要任何迭代求解。这就是 K-Means 能跑得这么快、能在几十万样本上秒级收敛的根本原因。换句话说,平方误差不是随便选的,它是为了换取“更新步骤有闭式解”这个巨大便利。

代价也很明显:平方会把大距离放大。一个离群点对目标函数的贡献是距离的平方,所以 K-Means 对异常值极其敏感。我在做一个设备传感器数据的项目时就吃过这个亏,某几个传感器故障期间上报了明显偏离量级的读数,结果这几个点硬生生把两个正常簇的质心拽偏了,导致后续规则完全对不上。处理方式要么是先做异常值剔除,要么改用 K-Medians 或者带截断的损失。

1.2 一次迭代里究竟发生了哪两件事

K-Means 的每一轮迭代只做两件事,交替进行,这个结构值得掰开讲清楚。

第一件事是分配:固定当前所有质心,把每个样本分配给离它最近的那个质心。第二件事是更新:固定当前的分配关系,把每个质心重新计算为其所属样本的均值。这两步交替执行,直到分配不再变化或者达到迭代上限。

用一个生活化的场景来理解:假设你要在城市里设 K 个快递站点。第一步,先随便定几个站点位置,然后让每个居民选择离自己最近的站点(这就是分配);第二步,每个站点根据自己负责的居民的实际住址,把站点搬到这些住址的几何中心(这就是更新)。搬完之后居民的最优选择可能变了,于是再重新分配,再搬,反复几轮,站点位置和负责范围会逐渐稳定下来。

这个交替过程在数学上属于坐标下降,也可以看成是 EM 算法在“硬分配”假设下的一个特例——E 步是分配,M 步是更新。理解这一点很有用,因为它直接解释了两个现象:一是每一步都不会让目标函数变大(分配时每个点选了最近的质心,更新时质心取均值让簇内平方和最小),二是它只能保证收敛到某个局部稳定点,不能保证是全局最优。

1.3 为什么它一定收敛,却不一定收敛到最优解

收敛性这件事,很多人是背结论,其实推理链条很短:目标函数 J 有下界(最小是 0),而每一步操作都保证 J 单调不增,一个单调递减且有下界的序列必然收敛。所以 K-Means 在有限步内一定会停下来,不会无限震荡。实际实现里再加一个“标签不再变化就提前退出”的判据,通常几十轮之内就结束了。

但收敛不等于收敛到最优。J 是一个关于划分方案的非凸函数,局部极小值非常多。同一个数据集,换一组初始质心,完全可能收敛到两个质量差别很大的结果。这不是实现 bug,是算法本身的特性。这也解释了为什么sklearn的KMeans默认要做多次初始化(n_init),每次用不同的随机种子跑一遍,最后保留 inertia 最小的那一组。它就是在用“多试几次”这种最朴素的办法来对抗局部最优。

我在实际项目里的经验是:如果你的数据簇结构比较清晰(簇之间间隔明显),多次初始化得到的结果几乎一致,inertia 的波动在千分之几以内;如果你发现 10 次初始化的 inertia 差别能到 20% 以上,那基本可以断定数据本身的簇结构很弱,或者 K 值选得不合适,这时候纠结“哪次结果才是对的”意义不大,应该回头去看数据。

2. 手写一版 K-Means:把每一步摊开看

2.1 核心迭代的完整实现

调包之前先手写一遍,是我一直推荐的学习路径。不是为了以后都不用库,而是为了在结果出问题时,你知道该看哪个中间变量。下面这版实现去掉了所有工程优化,只保留逻辑,方便对照理解:

import numpy as np def kmeans(X, k, n_init=10, max_iter=300, tol=1e-4, seed=42): rng = np.random.default_rng(seed) n_samples, n_features = X.shape best = {"inertia": np.inf, "labels": None, "centers": None} for _ in range(n_init): # 初始化:随机挑 k 个样本作为初始质心 idx = rng.choice(n_samples, k, replace=False) centers = X[idx].copy() labels = np.full(n_samples, -1, dtype=np.int32) prev_inertia = np.inf for it in range(max_iter): # 分配步骤:用展开式算距离,避免构造 n*k*d 的大张量 dist = ( (X ** 2).sum(axis=1)[:, None] - 2 * X @ centers.T + (centers ** 2).sum(axis=1)[None, :] ) new_labels = dist.argmin(axis=1) # 更新步骤:按簇求均值 for j in range(k): mask = new_labels == j if mask.any(): centers[j] = X[mask].mean(axis=0) else: # 空簇:把质心挪到离现有质心最远的样本上 far = dist.min(axis=1).argmax() centers[j] = X[far] inertia = dist[np.arange(n_samples), new_labels].sum() if np.array_equal(new_labels, labels): break if abs(prev_inertia - inertia) <= tol * max(prev_inertia, 1e-12): labels = new_labels break labels = new_labels prev_inertia = inertia if inertia < best["inertia"]: best = {"inertia": inertia, "labels": labels, "centers": centers} return best

这段代码里有三个地方值得单独说明:距离的展开式写法、空簇的处理、以及收敛判据的选择。下面逐个拆。

2.2 距离计算的展开式与内存代价

最直观的距离写法是((X[:, None, :] - centers[None, :, :]) ** 2).sum(-1),逻辑清晰,但是内存占用是n × k × d。假设你有 100 万样本、20 个簇、50 维特征,这一个临时张量就是 10 亿个浮点数,按 8 字节算要 8 GB,机器直接爆掉。这也是很多人拿 K-Means 跑稍大数据集时莫名其妙内存溢出的罪魁祸首。

展开成||x||² - 2x·μ + ||μ||²之后,只需要算一个n × k的矩阵乘法结果,内存降了两个数量级,速度还更快(矩阵乘法能走 BLAS 优化)。代价是浮点误差可能让距离出现极小的负数,取argmin时一般没影响,但如果要做阈值判断记得先裁到 0。

提示:如果你的特征维度很高(比如几百上千维的嵌入向量),即使用了展开式,n × k的矩阵可能也不小。这种情况下分块计算(每次处理几万个样本)比一次性算完更稳妥。

2.3 空簇不是理论问题,是每天都会遇到的事

空簇指的是某一轮更新之后,某个簇一个样本都没分到。很多人以为这是小概率事件,实际上只要你的 K 设得偏大,或者初始质心里有几个挨得特别近,第一轮就可能出现空簇。这时候X[mask].mean(axis=0)会返回nan,然后整个距离矩阵全变nan,argmin的结果变成 0,最后所有样本被塞进第一个簇——表面上程序跑完了,结果一团糟。

处理空簇有几种常见策略:一是像上面代码那样,把空簇的质心重新放到“离现有所有质心最远的那个样本”上,让这个簇去接管最难分的区域;二是直接在整个样本集里随机挑一个点重新初始化;三是干脆把 K 减一。sklearn采用的基本是第一种思路。

我个人更倾向于第一种,因为它有一定的确定性,重跑结果不会因为随机挑点而抖动。但要注意,如果空簇在你调整之后仍然反复出现,那说明 K 值确实设大了,硬撑没有意义。

2.4 收敛判据该看什么

判断“是否收敛”有三种常见写法,各有适用场景。看标签是否完全不变,是最严格也最贴近定义的方式,缺点是当数据里有几个边界样本在两簇之间反复横跳时,可能永远等不到完全不变,只能跑满max_iter。看 inertia 的相对变化是否小于阈值,是最实用的方式,sklearn的tol参数就是这个含义(默认 1e-4)。还有一种偷懒的做法是直接固定迭代次数。

tol的取值有讲究。设成 1e-4 是经验值,通常能在精度和速度之间取得平衡。有人为了“更精确”改成 1e-8,结果发现迭代次数涨了三倍而 inertia 只降了 0.01%,纯属浪费时间。反过来,如果你的数据规模特别大,单轮迭代本身就很贵,把tol放宽到 1e-3 甚至 1e-2 也完全可以接受,因为 K-Means 的 inertia 曲线在前面几轮下降最猛,后面基本都是微调。

3. K 值这件事,肘部图和轮廓系数都不是标准答案

3.1 惯性下降曲线的读法与它的局限

肘部法几乎是每篇教程都会提的第一种 K 值选择方法。做法很简单:让 K 从 1 开始递增,每个 K 跑一次聚类,记录 inertia,然后画一条曲线,找那个“下降速度突然变缓”的拐点。背后的逻辑是,当 K 小于真实簇数时,增加一个簇能显著降低簇内平方和;当 K 超过真实簇数后,多出来的簇只是把原本完整的簇切开,收益越来越小。

这个方法的问题在于,它经常给不出一个明确的拐点。我见过不少真实数据集的 inertia 曲线是平滑下滑的,从 K=2 到 K=10 完全没有明显的“肘”,你盯着看半天也选不出来。更麻烦的是,inertia 随 K 单调下降,K 等于样本数时 inertia 直接是 0,所以它本身不能作为“越大越好”或者“越小越好”的评分。

我的实际做法是把 inertia 曲线和下降率一起看:计算(inertia[k-1] - inertia[k]) / inertia[k-1],找出下降率跌破某个阈值(比如 10%)的位置,再结合后面的业务需求来定。这比盯着图找拐点要靠谱一些。

3.2 轮廓系数怎么算,什么时候会骗人

轮廓系数是另一个常用指标,定义是这样的:对每个样本 i,记a(i)为它到同簇其他样本的平均距离,b(i)为它到最近的其他簇所有样本的平均距离,那么s(i) = (b(i) - a(i)) / max(a(i), b(i)),取值范围在 -1 到 1 之间。全部样本的均值就是整体轮廓系数。越接近 1 说明簇内紧、簇间远,越接近 0 说明簇之间有重叠,负数说明这个样本可能被分错了。

它的计算复杂度是 O(n²),因为要算所有样本两两之间的距离。样本量上万之后就很吃力了,实践中一般是随机采样一部分(比如 5000 到 10000 个)来估算。

轮廓系数最容易被误用的地方是:它在“球形、大小相近、密度相近”的簇结构上表现最好,而这恰好是 K-Means 本身擅长的场景。换句话说,它和 K-Means 的假设是高度一致的,两者会互相“印证”一个可能是错的结论。如果你的数据是长条形、环形或者密度差异极大的簇,轮廓系数很可能给出一个很低的分数,但这不代表你的聚类没用,只是评价指标和数据形状不匹配。

还有一个细节:轮廓系数对 K 的判断偏向于较小的值。因为 K 越大,每个簇越小,a(i)自然越小,但b(i)未必同步变大,最后平均下来经常是 K=2 或 K=3 得分最高。所以当你用轮廓系数选出 K=2,而业务上明明知道应该有三四类人群时,不要盲从指标。

3.3 用业务可解释性反推 K

前面两种方法都是数据驱动的,但真正拍板的时候,业务约束往往更有分量。举个实际场景:做用户分层,运营团队能同时维护的差异化策略数量是有限的,如果他们说最多做四档,那 K 就定在 4,剩下要做的是让这 4 个簇尽可能可解释,而不是继续在 5、6、7 里面挑一个指标最优的。

还有一种反向操作我觉得挺实用:先跑一个偏大的 K(比如 10),然后把每个簇的中心打印出来,看哪些簇在业务含义上高度相似,手动合并。这个过程逼着你去理解每一维特征的实际意义,比盯着指标数字有用得多。我在做商品分群的时候就是用这个方法,先分成 12 类,人工看了一遍发现其中 4 类本质上是同一批商品的不同价格带,合并成 1 类之后变成 9 类,可解释性反而上去了。

4. 初始化和量纲:两个最常见的翻车点

4.1 随机初始化为什么必须换掉,K-Means++ 做了什么

最原始的 K-Means 是从样本里完全随机地挑 K 个点当初始质心。这个做法有个致命缺陷:如果挑出来的几个点恰好挤在同一个自然簇里,那么其他簇可能永远分不到足够的样本,最终结果严重偏离。

K-Means++ 的思路是让初始质心的选择“彼此尽量远”。具体过程是:第一个质心随机选;之后每个新质心,按照每个样本到“当前已有质心集合的最近距离的平方”作为权重来概率抽样。距离已有质心越远的点,被选中的概率越大。这个平方很关键——如果不平方,选点的分散程度不够;平方之后,远离现有质心的点获得明显更高的权重。

D² 采样(也就是基于平方距离的采样)在理论上能给出 O(log K) 的近似比保证,实践中也确实比纯随机稳定得多。sklearn的默认初始化就是k-means++,一般没必要改回random。但有个例外:如果你需要严格控制每次运行的随机性以便复现,那随机种子必须固定,而 K-Means++ 内部本身也用到了随机数,所以固定random_state依然是必要的。

4.2 n_init 到底设几次,随机种子怎么管

n_init是重复初始化的次数,最后取 inertia 最小的那组结果。老版本sklearn默认是 10,新版本在初始化方法为k-means++时改成了'auto'(实际执行 1 次),理由是 K-Means++ 已经把初始化的质量提上来了。

这个默认值的改变让不少人踩过坑:升级库版本之后,同一个脚本跑出来的聚类结果变了。如果你对稳定性要求高,我的建议是显式地把n_init设成 10 到 20,同时固定random_state。多跑几次的代价通常可以接受(K-Means 单次本来就快),换来的是结果的可复现和更低的局部最优风险。

复现性还有个小细节值得强调:比较两次运行的聚类结果,不能直接比labels_数组。因为簇的编号是任意的,第一次跑出来的“簇 0”完全可能是第二次跑出来的“簇 2”。正确做法是比较划分结构,常用调整兰德指数(ARI)或者直接用匈牙利算法做标签对齐。我见过有人因为两次labels_不一样就断定“算法不稳定”,实际上两次的划分是完全一致的,只是编号换了位置。

4.3 标准化、归一化的选择,以及不该做标准化的场景

回到文章开头那个翻车的例子:金额的量级是万级,访问次数是十级,欧氏距离里金额这一维几乎完全主导了结果,访问次数的影响被压缩到可以忽略。这不是算法的问题,是你喂给它的距离度量本身没有意义。

标准做法是做 Z-score 标准化(StandardScaler),把每一维变成均值 0、方差 1。这样各维在距离计算中的权重就拉平了。另一种是 Min-Max 归一化到 [0,1],它保留了原始分布的形状,但对异常值更敏感(一个极端值会把正常样本全挤到很窄的区间里)。还有一种在稀疏计数特征上常用的 L2 归一化,把每个样本向量缩放到单位长度。

但标准化不是无脑操作,有些场景反而不能做。比如你特意希望某些维度权重更大——做商品聚类时,价格的重要性确实高于包装尺寸,这时候强行标准化反而抹掉了业务判断。再比如所有特征本来就是同一个物理量纲(比如都是同一类传感器的读数),标准化会破坏它们之间的相对关系。还有一种情况是特征本身已经是比率或者百分比,量纲统一,标准化的收益就不大了。

我的判断标准是:先问自己“两倍的 A 和两倍的 B,在业务上是不是等价的距离”。如果不等价,就需要调整权重;如果完全不知道该怎么权衡,那就标准化,至少比让某一维独裁要好。

4.4 类别特征的 One-Hot 之后,欧氏距离已经失真

把类别特征做 One-Hot 编码然后直接丢进 K-Means,是个非常普遍但效果很差的组合。原因很直接:One-Hot 之后,两个“完全不同”的类别之间的距离是 √2,而两个“相同”类别的距离是 0,看起来没问题;但当类别数很多时,One-Hot 向量会变得极其稀疏,欧氏距离在高维稀疏空间里的区分度急剧下降,不同样本之间的距离趋于接近,聚类的意义就消失了。

更麻烦的是,One-Hot 编码隐含了一个错误假设:任意两个不同类别之间的距离相等。但“红色”和“粉红”显然比“红色”和“黑色”更接近,One-Hot 表达不了这种关系。

处理方式取决于你的具体场景。如果类别特征只有一两个且类别数不多,One-Hot 之后配合其他连续特征一起标准化,勉强能用。如果类别特征占比很大,就该考虑 K-Prototypes 这类专门处理混合类型的方法,或者先把类别特征嵌入到低维稠密向量再聚类。还有一种做法是对类别特征做目标编码或者频次编码,把它转成数值型,但要小心引入标签泄漏。

5. 跑出来的结果不对劲?按这条链路排查

5.1 症状一:每次跑结果都不一样

这是最常被问到的问题,也是最好排查的。先确认random_state有没有设。没设的话,初始化和 K-Means++ 的采样每次都不同,结果自然会变。设了还变,就要看是不是在别的环节引入了随机性,比如数据采样、特征筛选、或者用了多线程导致的浮点累加顺序差异(这个影响通常很小,不至于改变簇结构)。

如果确认随机种子固定了,结果依然每次不同,那基本可以断定你的数据里有大量“边界样本”——它们到两个簇的距离几乎相等,浮点误差的微小差异就能让分配结果翻转。这种情况下,结果不稳定反映的是数据本身簇结构不清晰,而不是代码有问题。验证方法是算一下 ARI,如果两次运行的 ARI 在 0.95 以上,说明划分基本一致,只是少数边界点在动,可以接受。

5.2 症状二:簇大小极端失衡,或者出现明显不该分开的簇

簇大小分布是判断聚类质量的第一手信号。正常的聚类结果,簇大小可以有差异,但一般不会出现某个簇只有个位数样本、而另一个簇占了 90% 的情况。

出现这种分布,优先怀疑三件事:特征量纲没处理(某一维主导距离)、K 设得过大(多余的中心在抢少量样本)、以及异常值(少数极端点自成一群)。排查顺序我建议反过来:先看那个只有几个样本的簇,把里面的样本原始数据打出来,大概率能一眼看出它们是异常值。如果确实是异常值,要么剔除,要么用对异常值更鲁棒的算法。如果不是异常值,再回去检查特征分布。

还有一种隐蔽情况:数据里存在“天然分裂的大簇”。比如用户里有 80% 是低频用户,他们的行为高度同质,K-Means 会把这 80% 全塞进一个簇,剩下 20% 再细分。这在数学上没错,但业务上往往不是我们想要的。解决办法要么是先对大类做子采样,要么是用分层聚类再合并。

5.3 症状三:标准化之后效果反而变差了

这个症状比较反直觉,但确实存在。原因通常是:某些维度的信息量本来就该占更大权重。举一个我遇到过的场景——做文档聚类,特征是词频。文档长度差异极大,短文档的词频整体偏低,长文档整体偏高。第一反应是做标准化把量级拉平,结果反而把“文档长度”这个重要信号抹掉了,聚类结果全是按主题混在一起的短文档。

后来改成用 TF-IDF 加权再加 L2 归一化,效果才正常。这个例子说明,标准化的前提是“各维度的量纲差异是人为造成的,不是信息本身”。如果量纲差异本身就携带信息,标准化就是在扔信息。

5.4 症状四:训练时看着挺好,新数据一来全乱

K-Means 是“直推式”的,它只对训练时见过的样本负责。新样本来了之后,标准做法是找离它最近的已有质心,把它分配到那个簇。这里有个陷阱:如果新数据的分布和训练数据有偏移(比如促销期间的用户行为),那么所有新样本可能都落在某几个簇里,另外几个簇完全接不到新样本。

更严重的情况是,有人直接把新数据加入原数据集重跑一遍 K-Means,结果发现簇的含义完全变了,和历史分析对不上。这时候要做的是固定旧的质心,只做分配,而不是重新训练。如果确实需要更新模型,就要接受“历史簇标签和图谱需要重新映射”这个成本,并且用 ARI 之类的指标量化一下新旧结果的差异。

为了提前发现这个问题,我习惯在训练完之后留一份验证集,把它的样本按最近质心分配,然后统计每个簇的占比。如果验证集上的簇占比和训练集差异很大(比如某簇在训练集占 30%,在验证集占 3%),说明模型对新数据的泛化能力不行,需要回头看看数据分布是不是在时间上发生了变化。

6. 数据量上到千万级之后,K-Means 要换打法

6.1 MiniBatchKMeans 的批大小与提前停止

标准 K-Means 每一轮迭代都要遍历全部样本,样本量到几百万之后,单轮就要几十秒,跑满 300 轮就是几个小时。MiniBatchKMeans 的思路是每轮只随机抽一小批样本参与更新,用指数加权的方式更新质心,速度能快一个数量级。

用的时候有几个参数值得注意。batch_size默认是 1024,如果你的数据有几十万样本、K 是几十个,1024 可能偏小,质心会抖动得比较厉害,建议调到 4096 或 10240。max_no_improvement控制连续多少批没有改善就提前停止,默认 10,对大数据可以适当调大,但别调太大,否则失去了早期的加速意义。另外n_init在这里的收益比标准 K-Means 小得多,因为每批样本本身就是随机的,重复初始化带来的额外成本未必值得。

有一点必须清楚:MiniBatchKMeans 的结果和标准 K-Means 不完全等价,inertia 通常会略高一点,簇边界也更模糊。如果后续要用簇中心做严格的距离阈值判断,这个差异需要评估。我的做法是用 MiniBatch 快速找到大致结构,再用标准 K-Means 在这个结果的基础上做少量迭代精修(把 MiniBatch 的质心作为初始值)。

6.2 先降维再聚类,什么时候值得做

高维数据做 K-Means 有两个问题:距离计算慢,以及“维度灾难”导致欧氏距离失去区分度。经验上,特征维度超过 50 之后,欧氏距离的可靠性就开始下降了,因为高维空间里任意两点之间的距离趋于接近。

降维最常用的是 PCA,先降到 10 到 30 维再聚类。这里有个关键细节:降维之后再聚类,得到的质心是在降维空间里的,做业务解释的时候需要反变换回原始空间,或者至少把主成分的载荷看一遍,知道每个成分大致对应什么。

也有人用 t-SNE 或 UMAP 做降维再聚类,我个人不太推荐把它们的输出直接喂给 K-Means。这两种方法主要是为了可视化,它们对局部结构的保持很好,但全局距离关系可能被扭曲,聚出来的簇在原始空间里未必成立。用它们做结果展示可以,做聚类输入要慎重。

6.3 近似最近邻加速,只在特定条件下有意义

有些实现会用球树(Ball Tree)或者 KD 树来加速最近邻搜索,理论上能把单轮分配从 O(nkd) 降到 O(n log n)。但在 K-Means 这个场景里,加速效果往往不明显,原因有两个:一是簇的数量 K 通常不大(几十以内),暴力计算的常数因子很小;二是 KD 树在高维(超过 20 维)下性能会急剧退化,退化成近似暴力搜索。

真正能带来数量级提升的是向量化 + 分块 + 多线程,而不是换数据结构。如果确实需要处理超大规模数据,更实际的路径是先把数据做初步聚合(比如对相似样本做加权),或者用近似最近邻库来做分配步骤。不过这些都属于工程优化的范畴,算法层面的改动不大,评估收益的时候要把实现复杂度也算进去。

7. 聚类结果怎么用:从标签到可执行的动作

7.1 簇编号没有顺序,“簇 0”不是第一名

这个坑我见过太多次了。有人做完聚类,发现“簇 0”的样本数最多,就认为它是最重要的一类;或者把簇编号直接当成等级标签,写进了业务规则。实际上 K-Means 输出的簇编号完全是任意的,取决于初始化时哪个质心先被选中。

更麻烦的是,同一个模型在不同环境下重跑,编号可能变。所以任何把簇编号写死进代码或报表的做法都是危险的。正确做法是给每个簇定义一个基于质心特征的可读名称,比如“高频高客单”“低频低客单”,然后在输出时做映射。这个映射关系最好也存下来,方便后续追踪。

如果需要跨批次比较,一定要用划分结构层面的指标(ARI、NMI)而不是编号。我习惯在每次模型更新后跑一个对齐脚本,用匈牙利算法把新簇映射到旧簇上,输出一张对照表,看看哪些簇是稳定的、哪些是新出现的。这张表在汇报的时候特别有用。

7.2 用簇中心做画像与规则提取

质心是 K-Means 最有价值的产出之一,它代表了这一簇的“平均画像”。把每个簇的质心按特征维度打印出来,你会得到一张非常直观的表格:

簇平均金额平均访问次数平均间隔天数样本占比
A320018.23.112%
B8504.715.643%
C21006.12.28%
D1201.242.337%

有了这张表,业务侧就能直接读懂每类人群的特征,进而设计对应的策略。这里有个操作细节:如果你做过标准化,质心的数值是标准化空间的,必须反变换回原始单位才有意义。反变换公式是原始值 = 标准化值 × 标准差 + 均值,sklearn的scaler.inverse_transform()可以直接用。

除了均值,还建议看一下每个簇内部的分布。两个簇的平均金额都是 1000,但一个是所有人都接近 1000,另一个是一半人 100、一半人 1900,业务含义完全不同。计算每个簇在各维上的标准差或者分位数,能帮你发现这种“平均掩盖差异”的情况。

7.3 拿去做向量量化、去重与异常检测

除了常规的分群,K-Means 在工程侧还有几个非常实用的用法。

向量量化是图像压缩里的经典应用:把每个像素的颜色(三维 RGB)用 K-Means 聚成 64 个代表色,然后用簇编号代替原始颜色,存储空间直接降到原来的 1/3,视觉上损失很小。这个思路现在也广泛用于嵌入向量的压缩,把高维浮点向量替换成簇编号,检索时可以先粗筛再精排。

近重复检测的用法是:先把所有条目编码成向量,跑 K-Means 聚成较多的小簇,然后在簇内部做两两相似度比较。这样比较次数从 n² 降到 Σ(n_i²),通常能降一到两个数量级。关键是 K 要设得足够大,让每个簇内的样本数控制在几十到几百之间。我处理过一批几十万条的文本去重,用这个方法把全量比对压缩到了可接受的时间范围内。

异常检测的用法稍微绕一点:先聚类,然后计算每个样本到其所属质心的距离,把距离特别大的样本挑出来。这里的假设是“正常样本会紧密围绕在簇中心附近,异常样本会偏离”。这个方法对“成团出现的异常”无能为力(它们会自成一个簇),但对零散的离群点很有效。阈值可以按距离分布的分位数来定,比如取 99% 分位以上。

最后分享一个我在实际项目里反复验证的小技巧:不要指望一次聚类就得到最终结果。比较有效的工作流是先粗聚一遍看结构,然后针对每个簇单独分析,必要时对某个簇做二次聚类。聚类本身是个探索工具,它的价值在于帮你发现数据里的结构,而不是一锤定音地给出结论。散点图上那些挤在一起、看不出分界的区域,往往才是信息量最大的地方。