☰
K-Means聚类算法全流程:原理、K值选择、特征工程与落地排查
2026/10/26 6:15:49 网站建设 项目流程

1. 先把K-Means放回它该在的位置

1.1 一个真实需求把我拉回了这个老算法

前阵子接手一个用户运营侧的需求,手上有一份几十万行的用户行为宽表,字段包括近90天登录次数、下单频次、客单价、优惠券使用率、活跃天数占比等等,一共二十多个数值列。业务方的诉求很朴素:把用户分成几拨人,每拨人要有明显区别,好让运营同学针对性地配活动。没有标签,没有历史分类,也没有明确的判定标准,典型的无监督场景。

这种需求我第一反应不是上深度聚类模型,而是先跑一版K-Means算法看看数据长什么样。原因很实在:它快、可解释、结果好交付。业务方听到"这是第3类用户,平均客单价是其他类的2.4倍"能立刻理解,而听到"这是第3类用户,这是它的128维嵌入向量"只会一脸茫然。K-Means算法在这个场景里扮演的角色不是"最先进的模型",而是"最快把数据形状摸清楚的探针"。

很多同行对K-Means算法有偏见,觉得它太基础、太朴素,写进简历都嫌不够看。但我这些年做下来的体会恰恰相反:K-Means是那种你越懂它、越知道它的边界在哪,就越舍不得扔的工具。它在特征工程阶段做分箱、在推荐系统里做向量量化、在图像处理里做主色提取、在日志分析里做模式归并,这些"脏活累活"它干得又快又稳。真正的问题从来不是算法老不老,而是用的人有没有搞清楚它的假设前提。

这篇内容我打算把K-Means算法从原理到落地完整过一遍,包括我自己踩过的坑、参数怎么定、K值怎么选、结果怎么解读、出问题怎么排查。适合两类人看:一类是刚接触聚类、需要一份能直接照着跑的操作指南的同学;另一类是用了很久但总觉得结果"差点意思"、想知道问题出在哪的同学。

1.2 K-Means到底在优化一件什么事

抛开公式,K-Means算法的核心诉求可以概括成一句话:让同一簇内的点尽量挤在一起,不同簇的点尽量分开。而它选择用"点到簇中心的距离平方和"来量化"挤不挤",这个量专业上叫簇内平方和,也叫惯性(inertia)或者误差平方和(SSE)。

为什么用平方而不是绝对值?这个选择背后有实际考量。平方和是可导的,求导之后能得到一个非常干净的解析解——簇中心的最优位置就是簇内所有点的均值。如果用绝对值(曼哈顿距离),最优中心就变成了中位数,迭代过程也得更复杂。用平方换来的代价是对异常值极其敏感,一个离群点能把整个簇心拽偏一大截,这个问题后面会专门讲怎么处理。

把这套逻辑拆成可执行的步骤,其实只有两步在循环:

  1. 分配:把每个样本点分配给距离它最近的那个簇中心。
  2. 更新:把每个簇的中心移动到该簇所有点的均值位置。

重复这两步,直到中心不再移动,或者移动幅度小于一个阈值。这就是经典的 Lloyd 迭代,1957年提出来的东西,到今天依然是绝大多数库的默认实现。

注意:这两步交替执行时,目标函数是单调不增的。也就是说每一轮迭代之后,簇内平方和要么变小,要么不变,绝不会变大。但它保证的是收敛到局部最优,不是全局最优。这个性质决定了K-Means算法对初始化极度敏感,后面讲K-Means++的时候会展开。

1.3 它明确不适合干的几件事

愿意花时间讲"不该用它"的场景,比讲"怎么用它"更有价值。K-Means算法有几个硬性假设,一旦数据不满足,结果就是垃圾,而你很可能还看不出问题。

假设一:簇是球形的、大小相近的。K-Means用欧氏距离划分空间,本质上是把特征空间切成沃罗诺伊多边形,每个簇的形状被强行限定成凸的、近似球形的。如果真实数据是两条弯月形交织在一起(经典的双月数据集),K-Means必然把它们拦腰截断。这时候该换 DBSCAN 或者谱聚类。

假设二:每个簇的方差差不多、样本量差不多。如果数据里有一个特别大的簇和一个特别小的簇,大簇的边缘会被小簇"吸走",因为目标函数是全局平方和,大簇贡献的误差项多,算法倾向于牺牲大簇边缘来降低小簇的误差。这个现象在密度差异大的数据上非常明显。

假设三:特征之间的距离有意义。这条最容易被忽视。把性别编码成0/1,把城市ID编码成1到300,然后丢进K-Means——得到的距离完全是人为构造的,没有任何物理含义。类别型特征要么先做独热编码再做降维,要么改用 K-Prototypes 这类专门处理混合类型的算法。

假设四:各特征量纲可比。收入用元做单位是五位数,年龄用岁做单位是两位数,不标准化直接跑,距离几乎完全由收入决定,年龄这个维度等于没参与。这是新手最容易犯的错,也是排查"聚类结果看起来只按某一个字段分"时的第一怀疑对象。

2. 把原理拆到能自己手算的程度

2.1 目标函数与它的解析解

设样本集为 $x_1, ..., x_n$,要分成 $K$ 个簇,簇中心记为 $\mu_1, ..., \mu_K$。目标函数写成:

$$J = \sum_{i=1}^{n} \sum_{k=1}^{K} r_{ik} |x_i - \mu_k|^2$$

其中 $r_{ik}$ 是一个指示变量,样本 $i$ 属于簇 $k$ 时为1,否则为0。这个式子的含义就是:每个点到它所属簇中心的欧氏距离平方,全部加起来。

固定 $\mu$ 求 $r$:对每个样本,选距离最近的中心,这一步没有近似,是精确最优。固定 $r$ 求 $\mu$:对每个簇,$J$ 关于 $\mu_k$ 求偏导并令其为零,得到 $\mu_k = \frac{1}{|C_k|}\sum_{i \in C_k} x_i$,也就是簇内均值。这一步同样没有近似,是精确最优。

问题出在交替执行上。每一步都是对当前变量集的全局最优,但联合优化是 NP-hard 的。所以 K-Means算法给出的解只能是局部最优,而且不同的初始中心会收敛到不同的局部最优。这就是为什么同一个数据集跑两次结果可能不一样。

2.2 初始化的分量为什么这么重

原始版本的K-Means是随机从样本里挑K个点作为初始中心。这个做法在K比较小、数据分布比较均匀的时候还算能用,但一旦碰到下面几种情况就彻底翻车:

  • 随机挑中的几个点恰好都挤在同一个密集区域,导致另一个真实存在的簇完全没有中心,最后被强行拆分。
  • 随机挑中的点里有一个是极端离群点,它自己独占一个簇,剩下K-1个中心去分原本的K个簇,结果全乱。
  • 有重复样本被同时选中多次,等于浪费了中心名额。

2007年提出的 K-Means++ 就是专门解决这个问题的。它的思路很聪明:第一个中心随机选,之后的每个中心,选择概率与该点到"已有最近中心"的距离平方成正比。也就是说,离现有中心越远的点,越有可能被选为下一个中心。这样选出来的初始中心天然是分散的。

我用一组模拟数据做过粗略对比:在明显分成4团、且其中两团距离很近的数据上,纯随机初始化跑20次,有大约6次收敛到了明显错误的解(把相邻两团合并、把另一团劈成两半);换成 K-Means++ 之后,20次全部收敛到同一组解。代价是初始化阶段要计算距离矩阵,多花一点时间,但相对于整个迭代过程完全值得。

实操建议:sklearn 的 KMeans 默认就是init='k-means++',不要手贱改成'random'。如果非要对比实验,记得把n_init调大,并且固定random_state,否则你比较的其实是随机性,不是初始化策略。

2.3 距离度量决定了你能看到什么

默认是欧氏距离,但K-Means算法其实可以换度量,只是换了之后"更新中心用均值"这个结论就不一定成立了。

用余弦相似度时,中心更新应该改成归一化后的均值方向,本质上是在球面上做聚类,适合文本TF-IDF向量、用户兴趣向量这类只关心方向不关心模长的场景。sklearn 里没有直接的metric='cosine'选项,标准做法是先把向量做 L2 归一化,再用欧氏距离——数学上归一化向量的欧氏距离与余弦相似度单调对应,效果等价,而且能继续用高效的向量化实现。

用曼哈顿距离时,中心应该用中位数(K-Medians),对异常值更稳,但每次更新要排序,速度慢一些。

用相关系数时,等价于先把每条样本做 z-score 标准化,再跑普通K-Means。

这里有个容易忽略的点:换度量不只是换个函数,它同时改变了簇的形状假设。欧氏距离对应球形簇,余弦对应锥形(同一方向的所有模长都算一类),曼哈顿对应菱形。选度量之前先想清楚"我认为什么样的样本应该属于同一类",这比调参重要得多。

2.4 复杂度与规模天花板

单次迭代的复杂度是 $O(n \cdot K \cdot d)$,$n$ 是样本数,$K$ 是簇数,$d$ 是维度。总复杂度再乘以迭代次数 $t$,通常 $t$ 在几十到几百之间。Lloyd 算法在实践中的收敛速度比理论最坏情况好很多,一般二三十轮就稳定了。

这个复杂度意味着什么?一百万样本、二十维、K=10,单次迭代大约两亿次浮点运算,现代CPU上不到一秒。十轮迭代也就几秒。所以K-Means在中等规模数据上完全不是瓶颈,真正吃时间的是你为了选K跑的那几十次重复实验。

规模天花板主要来自内存:需要把整个数据集载入内存,还要存距离矩阵(如果暴力实现的话)。超过千万级样本,就该考虑 MiniBatchKMeans 或者先做采样。另外维度超过几百之后,欧氏距离会失效(维度灾难:高维空间中任意两点距离趋于相等),这时候要么先降维,要么换度量。

3. K值到底怎么定:别再只盯着肘部图

3.1 肘部法的正确读法

肘部法的逻辑是:随着K增大,簇内平方和必然单调下降,K等于样本数时降为0。但下降速度会在某个点明显放缓,形成一个"肘部"拐点,这个拐点对应的K就是比较合理的选择。

问题在于,这个"明显放缓"很多时候一点都不明显。我见过太多数据画出来的曲线是一条平滑的弧线,根本没有肘。这时候硬找拐点就是自欺欺人——你看到的拐点很可能只是你希望看到的。

我的做法是:把肘部图和后面几种方法一起看,如果三种方法指向同一个K,那基本可以定;如果指向不同,就以业务可解释性为准。另外一个小技巧是画下降幅度的一阶差分,也就是相邻两个K的SSE差值,柱子突然变矮的位置比曲线拐点更容易辨认。

import numpy as np from sklearn.cluster import KMeans ks = range(2, 11) sse = [] for k in ks: km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42) km.fit(X_scaled) sse.append(km.inertia_) diffs = np.diff(sse) # 相邻K的SSE下降量 ratios = diffs[1:] / diffs[:-1] # 下降速度的衰减比例

ratios这个数组里,某个位置突然从0.7跳到0.95,说明再增加一个簇带来的收益急剧衰减,那个位置往前一个K就是候选。

3.2 轮廓系数和它的两个陷阱

轮廓系数衡量的是"样本与自家族的平均距离"和"与最近邻簇的平均距离"的对比关系:

$$s = \frac{b - a}{\max(a, b)}$$

$a$ 是样本到同簇其他点的平均距离,$b$ 是到最近的其他簇所有点的平均距离。取值在 -1 到 1 之间,越接近1说明分得越开,接近0说明在边界上,负值说明大概率分错了。

它的优点是同时考虑了簇内紧密度和簇间分离度,不像SSE那样只盯着紧密度。缺点有两个必须知道:

陷阱一:计算复杂度是 $O(n^2)$。要算所有样本两两距离。十万样本就是一百亿次距离计算,跑一次几分钟,根本没法用来扫K。标准做法是随机采样一批样本(比如五千到一万)算轮廓系数,用采样估计代替全量。

陷阱二:它偏爱凸的、分离良好的簇。对于密度不均、形状不规则的簇,轮廓系数会给出偏低的分值,即使这个划分在业务上完全合理。反过来,它也可能给一个数学上很漂亮但业务上一文不值的划分打高分。

所以我的用法是:轮廓系数用来排除明显糟糕的K,不用来挑选最优K。比如K=2时轮廓系数0.5,K=3时0.42,K=5时0.21,那K=5基本可以排除;但K=2和K=3之间怎么选,得看业务。

3.3 稳定性检验:一个被低估的笨办法

这个方法我用了很多年,不依赖任何指标,纯粹看结果稳不稳:用不同的随机种子跑二十次,看结果的一致性。

具体怎么做?对每个K,跑二十次K-Means,每次用不同的random_state,拿到二十组标签。然后两两计算调整兰德指数(Adjusted Rand Index,ARI),得到一个二十乘二十的相似度矩阵,取平均值。平均值高于0.9说明这个K下的解非常稳定,低于0.75说明不稳定——不稳定的原因要么是K选错了,要么是数据本身没有清晰的簇结构。

from sklearn.metrics import adjusted_rand_score def stability_score(X, k, runs=20): labels = [] for seed in range(runs): km = KMeans(n_clusters=k, n_init=1, random_state=seed).fit(X) labels.append(km.labels_) scores = [] for i in range(runs): for j in range(i + 1, runs): scores.append(adjusted_rand_score(labels[i], labels[j])) return np.mean(scores), np.std(scores)

注意这里要把n_init设成1,否则每次内部已经帮你做了多次初始化取最优,掩盖了初始化本身带来的波动。

稳定性高但轮廓系数低的K,值得认真看;稳定性低但轮廓系数高的K,多半是运气好,别信。

3.4 业务约束永远排第一

前面说了那么多方法,最后落定K的那一刻,我几乎从来不是看指标决定的。

举个例子:做用户分群,K=7的轮廓系数比K=5高0.03。但运营团队一共就三个小组,分七类根本没人跟进,最后的结果就是七类里有两类没人管、有一类被合并着做。这种情况下K=5哪怕指标差一点,也是正确答案。

再比如做商品图片主色提取,K=8和K=12视觉上差别很小,但下游渲染要用调色板索引,索引位数决定文件大小,这时候K=8或16这种2的幂反而是最优解。

我的判断顺序是:先看业务上能承接几类,再看稳定性筛掉不靠谱的K,最后才用轮廓系数和肘部图做微调。反过来做,很容易得出一个数学漂亮但没法落地的结论。

方法计算成本适用条件主要风险
肘部法低(跑K次)K较小时曲线有明显拐点曲线平滑时靠肉眼猜
轮廓系数高(需抽样)簇近似凸且分离度好偏爱球形簇,复杂度O(n²)
稳定性检验中(K次×20轮)任何场景数据本身无结构时全都低
Gap Statistic高(需生成参考分布)需要客观统计检验参考分布怎么造有讲究
业务约束极低永远适用业务方自己也说不清要几类

4. 从原始表到可用分群:完整实操

4.1 特征清洗:先解决"距离没意义"的问题

拿到宽表之后,我不会立刻标准化然后跑模型。先做三件事。

第一件:处理极端值。K-Means对异常值的敏感程度超出很多人的想象。一个客单价百万级的测试账号,能把整个高价值簇的中心拽到天上去。做法不是简单删除,而是先看分布:对数正态分布的字段(客单价、消费总额、访问时长这类)先做log1p变换,再看分位数,把超过99.5分位的值截断(winsorize)而不是删除。截断保留了样本的其他维度信息,删除就全丢了。

第二件:处理缺失。K-Means不接受缺失值,sklearn会直接报错。填充策略要看字段含义:计数类填0合理,比率类填中位数更合理。但更重要的是加一列缺失指示变量——很多时候"这个字段缺失"本身就是强信号,比如没填手机号的用户和填了的用户在行为模式上往往完全不同。加完指示列再跑聚类,往往能把这类人群单独分出来。

第三件:剔除无意义字段。用户ID、注册日期、订单号这种唯一标识或者近乎唯一的字段必须剔除,它们对距离的贡献纯粹是噪声。低方差字段也要剔除,方差接近0的字段标准化之后会被放大成噪声——这是标准化带来的一个反直觉副作用,一个几乎全是0的二值特征,标准化后那几个1会变成极大的值,反而主导了距离。

import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler def prep_features(df, log_cols, id_cols): df = df.drop(columns=id_cols, errors='ignore').copy() # 长尾分布先取对数,压缩动态范围 for c in log_cols: df[c] = np.log1p(df[c].clip(lower=0)) # 缺失指示列 for c in df.columns: if df[c].isna().any(): df[f'{c}_isna'] = df[c].isna().astype(int) # 截断极端值 for c in df.select_dtypes(include=[np.number]).columns: lo, hi = df[c].quantile([0.005, 0.995]) df[c] = df[c].clip(lo, hi) df = df.fillna(df.median(numeric_only=True)) # 剔除低方差列 std = df.std(numeric_only=True) df = df.drop(columns=std[std < 1e-6].index, errors='ignore') return df

4.2 标准化:一个必须做但做法有讲究的步骤

StandardScaler(减均值除标准差)是最常见的选择,但它不是唯一选择,也不总是最好的。

什么时候用 StandardScaler:特征大致服从正态分布,或者你希望每个维度对距离的贡献相等。这是默认选项。

什么时候用 MinMaxScaler:特征有明确上下界,或者下游需要还原到原始量纲解释。缺点是它对异常值毫无抵抗力,一个极值能把整个区间压缩到很窄的范围里。

什么时候用 RobustScaler:数据里异常值多且你不打算截断。它用中位数和四分位距,比标准差稳健得多。我处理金融类数据时更常用这个。

什么时候干脆不标准化:所有特征天然同量纲,比如都是一组0到1之间的评分。这时候标准化反而会破坏原始权重的含义。另外如果你有明确的业务权重——比如认为消费金额的重要性是活跃天数的两倍——那可以先手工加权再标准化,或者干脆把消费金额复制一份塞进去。

这里有个实操细节很多人不知道:标准化必须在训练集上 fit,然后 transform 全量数据。如果每次来了新数据就重新 fit,簇中心的含义会漂移,历史结果的标签就对不上了。线上场景要保存训练时的 mean 和 scale,新样本用这个固定参数变换。

4.3 手写一版:看清每一步在干什么

用库之前先手写一遍,是我自己的习惯。倒不是要造轮子,而是很多"为什么结果不对"的问题,只有在你自己实现过之后才能一眼看出根因。

import numpy as np def kmeans_pp_init(X, k, rng): n, d = X.shape centers = np.empty((k, d)) centers[0] = X[rng.integers(n)] # 记录每个点到当前最近中心的距离平方 closest = ((X - centers[0]) ** 2).sum(axis=1) for i in range(1, k): probs = closest / closest.sum() idx = rng.choice(n, p=probs) centers[i] = X[idx] new_dist = ((X - centers[i]) ** 2).sum(axis=1) closest = np.minimum(closest, new_dist) return centers def kmeans(X, k, max_iter=300, tol=1e-4, seed=42): rng = np.random.default_rng(seed) centers = kmeans_pp_init(X, k, rng) labels = np.zeros(len(X), dtype=int) for it in range(max_iter): # 分配:用广播算距离矩阵,注意内存 dist = ((X[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2) new_labels = dist.argmin(axis=1) # 更新:空簇处理——把离自己中心最远的点挪过去 for j in range(k): if (new_labels == j).sum() == 0: far = dist[np.arange(len(X)), new_labels].argmax() new_labels[far] = j centers[j] = X[far] for j in range(k): mask = new_labels == j if mask.any(): centers[j] = X[mask].mean(axis=0) shift = np.linalg.norm(centers - (kmeans_pp_init.__defaults__ or centers)) if np.array_equal(new_labels, labels): break labels = new_labels inertia = ((X - centers[labels]) ** 2).sum() return centers, labels, inertia

上面这段代码里有两个地方值得单独说。

空簇处理。如果某一轮迭代之后某个簇一个点都没分到,它的中心就失去了意义。sklearn 内部的处理是把该中心重置,我的做法是把距离自己所属中心最远的那个点抢过来——因为那个点本来就是"最委屈"的,把它扶正最合理。不处理空簇会怎样?那个中心停在上一次的位置不动,下一轮可能有会被吸回来,也可能永远空着,最后你拿到 K 个簇但只有 K-1 个有内容。

收敛判定。我上面用标签是否完全不变来判断,比判断中心位移更严格,代价是多跑一轮。也可以用中心位移小于 tol 来判断,会早一点停。差别不大,但要注意 tol 设太大(比如0.01)会提前收敛,导致实际上还没稳定就停了。

4.4 生产环境用 sklearn 时的参数清单

真跑业务我肯定用 sklearn,自己写的那版只能验证理解。参数看着多,真正影响结果的只有几个:

from sklearn.cluster import KMeans km = KMeans( n_clusters=5, init='k-means++', # 别改 n_init=20, # 大数据集可降到10 max_iter=300, # 一般30轮内收敛,给300是保险 tol=1e-4, # 默认值够用 algorithm='lloyd', # 或 'elkan',见下 random_state=42, ) labels = km.fit_predict(X_scaled)

n_init是最值得调的参数。它表示"用不同的初始化跑几次,取 inertia 最小的那个"。老版本默认10,新版本(1.4起)默认改成了'auto',会根据 K 的大小自动选1或10。老代码升级时这里的行为会变,如果以前靠 n_init=10 拿到了稳定结果,升级后可能出现波动。我的建议是显式写死,不要依赖默认值。

algorithm='elkan'是另一种实现,利用三角不等式跳过不必要的距离计算,在簇分离良好、K较小的情况下能快一倍以上。但它在数据维度很高时会退化甚至更慢。默认是'lloyd',除非你确认数据是低维且簇分得开,否则别动。

copy_x默认 True,会在内部复制一份数据。大数据集上这个复制很吃内存,如果你的原始矩阵之后不再用,设成 False 能省一份。但要注意之后原矩阵可能被覆盖。

4.5 结果怎么解释:把中心翻译成人话

跑出标签只是开始,真正的功夫在解读。三种常用手段。

看中心。km.cluster_centers_给出的是标准化空间的坐标,直接看没意义,要用scaler.inverse_transform还原回原始量纲。还原之后,逐列对比各簇的均值与全量均值的偏差,就能写出"簇0的客单价是均值1.8倍、活跃天数只有均值0.4倍"这样的描述,业务方才听得懂。

看差异最大的特征。不要罗列所有字段,挑出簇间方差最大的前五个特征做成对比表。人脑一次只能比较3到5个维度,给太多信息等于没给。

给簇起名字。这一步看着土,但对落地至关重要。基于中心特征给出"高频低额""低频高额""沉默唤醒""新客尝鲜"这种标签,运营同学才能真的用起来。名字起得准不准,其实也是检验聚类有没有意义的一个自检——如果你看着中心特征想了半天也起不出名字,那多半是这个簇本身就没内聚性。

簇样本占比相对均值倍数(客单价)相对均值倍数(活跃天数)建议命名
012%1.80.4沉睡高价值
131%0.61.3高频小额
28%2.41.5核心高净值
327%0.90.3低频待唤醒
422%0.40.9价格敏感尝鲜

5. 排查实录:那些结果不对劲的时刻

5.1 结果每次运行都不一样

这是最高频的问题。原因通常有三种。

没设 random_state。这个太基础,但真的每天有人在问。设了之后结果可复现,但要注意:设了 random_state 只能保证"同样的输入、同样的库版本、同样的参数"下结果一致。换台机器、换个 numpy 版本,浮点累加顺序变了,结果可能微调。

n_init 太小。有些初始化会收敛到很差的局部最优。n_init=1 时,十次运行拿到两三组不同结果是常事。调到20基本能稳定。

数据本身没有清晰的簇。这条最容易被忽略。如果你的数据是一团均匀分布的点,K-Means 怎么分都是对的,也怎么分都不稳定——因为没有任何结构可以被发现。验证方法就是前面说的稳定性检验,如果二十次跑下来 ARI 只有0.5,那问题不在参数,在数据。

5.2 有一个簇只有两三个点

通常是有离群点。K-Means 为了降低平方和,会把极端值单独划出去,因为一个距离中心很远的点贡献的平方和可能是普通点的几百倍。把它隔离出来,目标函数立刻大幅下降,算法"认为"这是个好主意。

三种处理:一是回到数据清洗阶段做截断;二是改用 K-Medians 或者用 Huber 类的稳健距离;三是接受它,把这类样本单独标记为"异常人群"——有时候这恰恰是业务价值所在,比如那两三个点可能是刷单账号。

5.3 所有簇看起来都差不多

跑完之后发现各簇的中心几乎一样,或者某一维在所有簇上都高。常见根因:

  • 没标准化,某一维主导了全部距离,其他维度形同虚设。
  • 某个特征是近似常量,标准化之后被放大成噪声(前面提过)。
  • K 选太大了。真实结构只有三类,硬要分八类,多出来的五类只能从已有簇里硬拆,中心自然接近。
  • 特征之间高度共线。二十个字段实际上是三个因子派生出来的,有效维度只有三,K 超过三就没信息可分了。这种情况先做 PCA 或者直接人工筛特征。

5.4 大数据量跑不动

十万级以内基本无感。百万级时内存开始成为问题——sklearn 默认的copy_x=True会复制数据,加上内部的距离计算,峰值内存可能是原始数据的几倍。

按顺序试这几个方案:先把数据类型从 float64 降成 float32(精度损失对聚类几乎无影响,内存减半);关闭copy_x;改用MiniBatchKMeans,每次只用一小批样本更新中心,速度能快一个数量级,代价是 inertia 略高一点。实测下来,批大小设成样本数的百分之一到五十分之一之间效果比较平衡,太小则中心抖动大,太大就失去了加速意义。

5.5 结果无法复现于线上

训练时跑得好好的,上线之后新数据的标签每天都在跳。原因通常是每次推理都重新 fit 了模型。正确做法是把KMeans对象和StandardScaler一起持久化(用 joblib 存成文件),线上只调predict。predict不改变中心,只做最近中心分配,是纯确定性的。

还有一个隐蔽的坑:线上新样本的特征分布和训练时不一致。比如训练数据里某个字段的取值范围是0到100,线上来了个500的值,标准化后它会变成一个极端值,被分到最近的簇里但实际毫无意义。所以线上推理前要有分布监控,超出训练范围太多的样本应该走"无法归类"分支,而不是硬塞进某个簇。

5.6 问题速查表

现象最可能的原因优先动作
每次结果不同无随机种子 / n_init太小固定seed,n_init调至20
某簇样本极少离群点被隔离检查该簇样本,做截断或单独标记
各簇中心高度相似未标准化 / 特征共线 / K过大标准化,做相关性筛选,降K
聚类仅按单一维度划分该维度量纲远大于其他标准化,或检查是否漏了log变换
大数据集内存溢出float64 + copy_x降精度,关copy_x,上MiniBatch
线上标签不稳定每次重新fit持久化模型,线上只predict
轮廓系数全为负样本被分到了更近的邻簇检查K,或看是否有重复点干扰
收敛迭代次数打满上限tol太小 / 数据有大量重复点调大tol,去重后再跑

6. 工程落地时我会额外考虑的事

6.1 什么时候该换 MiniBatchKMeans

判断标准不是数据量绝对值,而是单次全量训练的时间。如果一次 fit 超过三分钟,就该考虑换。MiniBatch 的机制是从数据里随机抽小批,用这个批次的均值来增量更新中心,学习率随迭代衰减。它收敛到的解比全量版略差,但差得不多——我在几个百万级数据集上对比过,inertia 差距普遍在1%到3%之间,而训练时间能缩短十几倍。

一个不太为人知的技巧是:用全量KMeans的结果去初始化 MiniBatchKMeans。MiniBatchKMeans支持传init参数为数组,把全量版的cluster_centers_传进去,然后用较小的n_init跑一遍,可以在保证质量的同时处理后续增量数据。这在需要定期重训但数据持续增长的场景里特别有用。

6.2 高维稀疏数据怎么办

文本、one-hot 编码后的类别特征,维度动辄几千上万,且绝大多数位置是0。直接跑 K-Means 有两个问题:欧氏距离在高维空间失去区分度;稠密矩阵存不下。

标准解法是先做 TruncatedSVD(稀疏版的 PCA)降到几十到一百维,再做 L2 归一化,最后跑 K-Means。归一化这一步很关键,因为降维后的向量模长差异很大,不归一化的话距离主要由模长决定,方向信息被淹没。

另外一个选择是用球面 K-Means(spherical k-means),本质就是把中心更新后的归一化做进迭代里。sklearn 没有内置,但基于Normalizer加普通 KMeans 可以近似实现,效果在文本聚类上通常比纯欧氏距离好。

6.3 聚类结果怎么和下游衔接

聚类本身不产生价值,价值在下游动作。我这几年踩过的坑,基本都在衔接环节。

标签稳定性问题。每次重训之后,簇的编号会重新排列——上次的"簇0"这次可能变成"簇3"。如果下游系统存了簇ID做规则,重训之后规则全错。解决办法是在重训后做一次中心匹配:计算新旧中心的距离矩阵,用匈牙利算法做最优匹配,把新编号映射回旧编号。或者干脆不用编号,用中心特征生成一个稳定哈希作为标识。

簇数量变化问题。业务规则是按五个簇写的,这次重训跑出六个,多出来的那个没人管。所以K一旦确定,除非有明确理由,不要频繁改动。要改就得同步通知所有下游。

冷启动问题。新用户还没行为数据,没法算特征,也就没法分配簇。常见做法是给一个默认簇(通常是最小价值那类),等积累到一定行为量再重新分配。这个"一定量"是多少,得根据业务节奏定,太早分配不准,太晚影响体验。

6.4 一个容易被忽略的验证手段

模型跑完,指标看着不错,别急着交付。我最常用的一招是反向验证:从每个簇里随机抽二十个样本,看它们的原始特征,凭直觉判断"这些样本该不该在一起"。

这个方法听起来很不严谨,但极其有效。因为所有的指标都是在特征空间里算的,而特征空间是你构造出来的。如果抽样看下来发现某个簇里混着两类明显不同的用户,那说明特征设计漏掉了关键维度,指标再高也没用。反过来,如果某个簇里全是一类人,但指标显示它和内聚性差,那可能是指标不适合你的数据结构。

我印象最深的一次是做一个商品分群,轮廓系数0.6多,看着很漂亮。抽样一看,最大的那个簇里既有九块九的小饰品,也有几千块的家电。回去查才发现,价格字段做了log变换之后又被标准化,量纲被压平了,高价和低价的区分度被抹掉了。把价格单独加权之后重跑,轮廓系数降到0.48,但业务方一眼就认可了这个结果。这也是我为什么一直强调:指标是用来排除错误的,不是用来证明正确的。


最后分享一个我自己一直在用的小习惯。每次跑完K-Means,不管结果多好,我都会把cluster_centers_反变换回原始量纲,然后手写一遍每个簇的"人物画像",一句话描述。写不出来或者写得含糊的那个簇,通常就是问题所在——要么该合并,要么该拆,要么说明这个维度上你漏了特征。这个动作花不了五分钟,但比看十张图有用。

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

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

立即咨询