☰
K-means聚类算法核心原理与工程实践详解
2026/10/14 10:59:55 网站建设 项目流程

最近在处理一份用户行为数据的时候,我计划做分群分析。第一次直接调现成的K-means实现,K值随手填了个5,数据也没有做标准化,跑出来的结果怎么看怎么别扭——有两个簇几乎完全重叠,还有一个簇里塞进了明显不属于同一类的样本。后来我花了一个下午把K-means从原理到实现重新过了一遍,才发现很多问题不是算法不行,而是我压根没搞清楚它到底在优化什么。

这篇笔记就是把那次重新学习的过程完整记录下来,包括K-means的数学目标、手写实现时的工程细节、K值的确定方法、数据预处理里的坑,以及最后怎么判断聚类结果到底能不能用。如果你也经常调包跑聚类但说不清结果为什么是这样,或者面试前想把K-means彻底弄明白,这篇笔记应该能给你一些参考。

1. K-means到底在优化什么:目标函数与迭代收敛

1.1 聚类不是分类:K-means一开始就说好了"硬分配"

初学者最容易混淆的一件事,是把聚类和分类当成一回事。分类是有标签的学习,模型要学的是"输入到标签"的映射;聚类是无标签的探索性分析,目标是把数据分成若干组,让组内相似、组间差异大。K-means属于最典型的"划分式聚类",它有一个非常明确的预设:每个样本只能属于一个簇,不能同时属于多个簇,这就是所谓"硬分配"。

这个硬分配假设直接决定了K-means的能力边界。因为它的分配规则是"哪个簇中心离我最近,我就归哪个簇",所以K-means天然认为数据在空间中呈现的是一团一团的凸结构。说得再直白一点,它默认每个簇都长得像圆润的球,最多是半径不同的球。如果你的数据实际是弯的、环形的、嵌套的,K-means会把它们强行切出几条直线边界来,结果就会很奇怪。

我在实际中的体会是,拿到一份数据先不要急着跑算法,先问自己一个问题:这些数据真的能用"离谁近就归谁"来描述吗?如果答案是"应该可以",那K-means值得一试;如果答案是否定的,那从一开始就应该换密度聚类或者层次聚类,而不是后面拿一堆参数去补救。

1.2 从误差平方和看K-means的数学本质

K-means要优化的目标函数很简洁,就是所有样本到它所属簇中心的误差平方和(SSE),写成公式是:

J = Σᵢ ₌ ₁ⁿ ||xᵢ - μ_c(i)||²

其中xᵢ是第i个样本,c(i)是这个样本被分配到的簇编号,μ_c是第c个簇的中心(也叫质心)。这个式子可以拆解成k个簇各自计算内部距离再求和,所以K-means的任务本质上是:找出k个代表点,让所有样本距离自己所属代表点的平方距离总和最小。

这里有个数学细节值得留意:为什么用欧氏距离的平方,而不是直接用欧氏距离或者绝对值距离?因为目标对质心μ求导后,最优解恰好是这个簇内所有样本的均值。均值是收到了"平方"这个操作的加持才成立的——只有误差平方和的梯度是线性的,能让每个簇的最优中心直接以解析形式写出来。这也是K-means每次迭代里"更新质心"这一步如此简单的根本原因。

目标函数本身对簇的分配和质心位置是联合优化的,这个问题在计算上是NP难的,没办法一步到位求全局最优。所以K-means选择了一种贪心式的迭代策略:先固定质心分配样本,再固定样本更新质心,反复试探着往山下走。它能找到的通常是一个局部最优解,而不是全局最优解。

1.3 为什么"分配—更新"交替迭代能收敛

K-means的迭代过程其实是最简单的EM思想,虽然作者在原始论文里没有用EM这个说法,但骨架完全一致,每一轮干两件事:

  • 分配步骤(E步):固定所有质心,把每个样本分给距离最近的质心。
  • 更新步骤(M步):固定所有分配结果,把每个簇的均值当作新质心。

为什么这两步不断重复就会收敛?关键在于每一步都不会让目标函数变大。分配步骤取的是"最近质心",即使某个样本分到别的簇会更差,我们也只会选择最近的那个,所以SSE至少不会上升;更新步骤取均值,而均值恰好是最小化簇内平方误差的点,所以同样不会让SSE上升。一个单调不增、又有下界0的序列,必然是收敛的。

用一个生活类比:一群人站在操场上,先随机指定几个领队,然后大家各自走向最近的领队并站好,接着每个领队走到自己队伍的正中央,大家再重新看看哪个领队最近、重新站队……这样反复几次,队形就会稳定下来。K-means就是这个逻辑,只是它用数学把"领队"和"正中央"都精确化了。

不过注意,收敛不等于找到最优解。因为起始质心的位置不同,最终停下来的局部最优也不同。所以稍微靠谱一点的实现都会多次随机初始化,取SSE最小的那次结果。

2. 从零手写K-means:三个容易被忽略的工程细节

2.1 初始化:随机初始化与K-means++的区别

我建议每个人都亲手写一遍K-means,不是为了造轮子,而是因为写完之后你再回头看现成库的文档,很多参数就知道它是干什么用的了。第一个需要重视的环节是初始化。

最朴素的初始化是从样本里随机抽k个点当作初始质心,但这样做有两个风险:抽到的质心可能扎堆在同一片区域,导致K-means要花很长时间才能把质心"推开";也可能抽到离群点,直接把一个簇带偏。为此,K-means++提供了一种更聪明的初始化方式:先随机抽第一个质心,之后每抽一个新质心时,让距离已有质心越远的样本有越大概率被选中。这样初始质心天然分散,收敛更快,最终结果也更稳定。

下面是一个带K-means++初始化的手写实现,代码不多,用于理解原理足够:

import numpy as np def kmeans(X, k, max_iter=100, tol=1e-4, seed=None): rng = np.random.default_rng(seed) n, d = X.shape # K-means++ 初始化 centers = [X[rng.integers(n)]] for _ in range(1, k): dist_sq = np.min([ np.linalg.norm(X - c, axis=1) ** 2 for c in centers ], axis=0) # 加一个小常数,避免概率全为0导致除零 prob = dist_sq / (dist_sq.sum() + 1e-12) centers.append(X[rng.choice(n, p=prob)]) centers = np.array(centers) history = [] for _ in range(max_iter): # 分配步骤:每个样本选择最近的质心 distances = np.linalg.norm( X[:, None, :] - centers[None, :, :], axis=2 ) labels = np.argmin(distances, axis=1) # 更新步骤:重新计算每个簇的均值 new_centers = np.zeros_like(centers) for j in range(k): mask = labels == j if mask.any(): new_centers[j] = X[mask].mean(axis=0) else: # 空簇:保留原质心,避免除零 new_centers[j] = centers[j] sse = 0.0 for j in range(k): mask = labels == j sse += np.sum((X[mask] - centers[j]) ** 2) history.append(sse) # 收敛判据:质心移动距离小于阈值 if np.linalg.norm(new_centers - centers) < tol: centers = new_centers break centers = new_centers return centers, labels, history

这段代码分成三个部分:初始化、迭代主循环、收敛判断。你拿这个实现跑一些自己造的数据,能很直观地感受到质心是怎么一步步移动的。

2.2 主循环中的三个隐藏细节

手写版本麻雀虽小,但有几个细节是实际工程里绕不开的坑,我在标注里只点了一下,这里展开说。

第一个是空簇问题。分配步骤结束后,如果某个簇一个样本都没有,均值计算就会碰到除零。常见做法有两个:一个是直接保留上一轮的质心,让它在下一轮再参与竞争;另一个是重新随机选一个样本作为质心。保留原质心实现简单,但弱化了探索能力;重新初始化可能让结果变得更差更差,需要结合收敛曲线去判断。我一般用保留原质心的方案,然后把这种"空簇事件"打印出来,看看数据里是不是有极端的密度不均。

第二个是收敛判据。代码里用的是"新旧质心的欧氏距离小于tol",这是最简单的一种。更严格一点,可以同时看SSE的变化量,比如连续两轮SSE相差小于tol就停止。但要注意,K-means在某些数据上会出现质心在两个点之间来回振荡的情况,SSE在原地上小幅抖动,tol设得太小会导致永远不收敛,所以必须配合max_iter做兜底。我在实际项目里通常设置max_iter=100左右,很少需要跑满。

第三个是记录SSE历史。很多初学教程只让你输出簇标签,不让你看loss曲线。但SSE随迭代下降的曲线非常有用:如果曲线一路平缓下滑然后稳定,说明收敛正常;如果曲线一直在跳,说明数据可能根本不具备球形簇结构,或者初始化有问题。每次跑完K-means,我习惯把history打出来看一眼,比任何高级评估指标都直观。

2.3 复杂度与数据规模预估

K-means每一轮的时间复杂度是O(n·k·d),其中n是样本数,k是簇数,d是特征维度。分配步骤需要算每个样本到每个质心的距离,这是大头;更新步骤只有O(n)。迭代次数T一般不大,几十轮到几百轮,所以整体复杂度是O(n·k·d·T)。

这个复杂度意味着K-means对样本量是比较友好的。几万样本、几十个特征、k不大,直接跑完全没问题。但如果样本量到了百万级,问题就来了:n·k·d的距离矩阵在n=1,000,000、k=100、d=50时,要占的内存大约是1,000,000 × 100 × 8字节 = 800MB,这还只是存储一个矩阵,如果一次性广播计算距离,内存直接爆炸。

这时候要么用Mini-Batch K-means——每次抽一小批样本来做分配和更新,速度飞快,代价是精度略降;要么先做降维,把d降下来再跑。还有一个经验:如果数据点非常多,可以先用随机抽样跑一次K-means得到质心,然后把全量数据分配到最近质心,也不失为一种快速方案。总之,K-means虽然简单,但真正用到大规模数据时,还是要动点脑筋的。

3. K值怎么定:手肘法、轮廓系数与业务倒推

3.1 手肘法的"肘"在哪里最明显,哪里会骗人

K值是K-means里最让人头疼的参数。最常见的方法是手肘法,原理很简单:随着K增大,SSE一定会下降,因为簇多了每个样本离簇中心更近;但下降速度会有变化,K小于真实簇数时,每多一个簇都能大幅减少SSE,K超过真实簇数后,继续增加簇带来的收益就很有限了。SSE对K的曲线会像一条胳膊,拐弯的地方就是"肘"。

实际操作上,我会对K从1到10逐一跑多次,每次取最优SSE,然后把折线图画出来看。但真到了真实数据上,这条曲线往往是平滑下降的,很少出现教科书里那种锐利的拐角。有时候曲线在K=4时有个小拐弯,在K=6时又有个小拐弯,你就得判断哪个更符合后续需求。

更麻烦的是,K-means每次跑都收敛到不同的局部最优,SSE会抖动。如果每个K只跑一次,折线图甚至可能出现K=5的SSE比K=4还大的不单调情况,肘点就完全没法看了。所以我强烈建议,手肘法必须搭配"每个K重复多次初始化并取最优SSE"这个条件来用,否则画出来的曲线根本不是这条K-means算法真实能力的曲线。

3.2 轮廓系数在什么情况下会误导你

手肘法之外,轮廓系数是另一个高频使用的指标。对单个样本,轮廓系数定义为:

s = (b - a) / max(a, b)

其中a是该样本与同簇其他样本的平均距离,b是该样本与最近的其他簇中所有样本的平均距离。s接近1表示样本被归得很好,簇内紧簇间远;接近0表示样本恰好落在两个簇边界;接近-1说明样本可能被分错了。把所有样本的s取平均,就得到整个聚类的平均轮廓系数,一般用它来比较不同K值。

但轮廓系数有一个隐含偏好:它奖励的是"凸且紧致"的簇。如果业务上的真实分组本来就是长条形的、嵌套的,比如城市里沿河分布的店铺、环路上的住宅区,轮廓系数会给很低的分值,让你误判为聚类失败。另外,平均轮廓系数是全局的,一个小而清晰的簇和一个大而混乱的簇可能互相抵消,导致总体数字中等偏上,但你知道其中一堆簇根本没法用。

我的做法是:轮廓系数只用来做初筛,选两三个候选K值,然后人工看簇的内容。它告诉我"几何上哪个K更合理",但最终决定权必须交给业务上下文。

3.3 用业务问题反推K的三种方式

数学指标只能给建议,真正定K值往往要回到业务问题上来。这里有三个我自己常用的思路,供参考。

第一种是运营可解释性。比如给用户分群是为了做差异化运营,运营团队一般能承接的群数也就三到五个,你硬是按数学指标选出一个K=8,最后没法落地,那这个K就是无效的。我一般先问业务方"你预想中能接受几套策略",把这个数作为K的上界。

第二种是簇的可描述性。聚类完成后,把每个簇的质心特征拿出来对比:如果两个簇在几乎所有维度上的均值都差不多,只有微小的数字差异,说明这两个簇在实际意义上是同一个群,K取大了;反过来,如果一个簇的样本方差极大,明显比别的簇大得多,说明这个簇内部可能还藏着子群,K取小了。

第三种是利用先验标签验证。如果数据里有部分业务自带的标签,比如已知的老客/新客,可以看聚类结果和这些标签的对应关系。但要注意,聚类本来就是在发现标签之外的结构,强行要求它和标签完全一致反而会掩盖新的洞察。所以这种方式更适合用来"评估合理性",而不是"约束聚类的形状"。

4. 数据进聚类之前:标准化、离群点与高维处理

4.1 量纲与标准化:Z-score还是Min-Max

K-means的核心距离是欧氏距离,而欧氏距离对量纲极度敏感。举一个很典型的例子:对用户做分群,特征里有年龄(20-60)和年消费金额(几千到几十万),如果直接算距离,年龄这个维度几乎完全被消费金额淹没,聚类结果实际上只按消费金额切分,年龄信息等于没用。

所以聚类之前做标准化是基本操作。两种最常见的做法:

方法公式优点缺点
Min-Max(x - min) / (max - min)结果固定落在[0,1]区间,直观对离群点极其敏感,一个异常值会把正常样本压到很小的区间
Z-score(x - mean) / std比较常用,保留了分布形状均值和标准差都被离群点影响

如果数据里离群点不多,Z-score是最省心的选择;如果离群点比较多,我推荐用稳健缩放:把均值和标准差换成中位数和四分位距(IQR),即(x - median) / IQR。这个变换对离群点的抵抗力明显更强。

当然也有不需要标准化的时候。如果所有特征本身是同一个物理单位、同一个尺度范围,比如三维空间中点的坐标,或RGB颜色值,标准化反而会破坏原有的几何意义。要不要标准化,归根到底取决于"这个维度的绝对数值大小有没有意义"。

4.2 离群点:一个异常值能拖垮整个聚类

K-means用的是均值,而均值对离群点非常敏感。设想一个簇本来在(0,0)附近密集分布,某个样本跑到了(10000,10000),均值一下子被拉到(500,500),附近正常样本为了"靠近质心"不断被吸过去,整个簇就被撕裂了。一个离群点造成的破坏,可能比少跑几次初始化更大。

处理方式通常有几种路径。第一种是先做离群点检测,比如用IQR规则、基于距离的检测,或者直接用DBSCAN跑一遍把孤立点找出来,然后聚类前剔除。第二种是对长尾特征做对数变换或Box-Cox变换,把极端的数值拉回常规区间。第三种是换用更鲁棒的聚类算法,比如K-medoids,它选簇内实际的样本点作为中心,而不是算均值,天然抗离群点。

但我要特别提醒:不要看到离群点就机械地删除。在用户分群场景里,高消费用户往往数量很少,但却是最有价值的群体,直接删掉等于把最重要的信息扔了。更合理的做法是先识别出这批离群点,单独研究他们属于哪种业务类型,再决定是单独成一个簇、还是剔除、还是归并到某个正常簇里。

4.3 高维数据:维度灾难与降维取舍

K-means在低维数据上表现稳健,但维度一高就容易出问题。原因在于欧氏距离在高维空间里会退化:当维度增加,所有点之间的距离都会趋近于差不多的水平,距离的相对区分度被稀释,聚类结构自然就找不到了,这叫"维度灾难"。

几十个维度还可以接受,几千维甚至更高维就必须先降维。常用的做法是先做PCA或截断SVD,把维度降到几十维,再跑K-means。降维会损失一些细节信息,但换来了距离结构的稳定性,整体聚类质量通常反而更好。

还有一个思路是改变距离度量本身。比如文本场景里,向量经过TF-IDF处理后通常是稀疏的、方向敏感的,欧氏距离并不合适,这时可以用余弦相似度,再用"球面K-means"——每个样本和质心都先归一化到单位长度,更新质心时也是把均值向量归一化再当质心。严格来说这已经不是教科书里的标准K-means,但思路完全一脉相承。只要记住,距离的选择必须匹配数据形态,这条原则永远不变。

5. 聚类结果别急着信:检验方法、失败场景与换算法的信号

5.1 内部指标与外部指标的适用场景

聚类做完之后,怎么判断结果好不好?指标要分两类看。

没有标签的情况下只能用内部指标。除了前面说的SSE和轮廓系数,还有Davies-Bouldin指数(簇内散布除以簇间分离,越小越好)和Calinski-Harabasz指数(簇间方差比簇内方差,越大越好)。内部指标的共同特点是:它们只能评价几何结构好不好,比如簇够不够紧凑、分离够不够明显,但评价不了"这个分组在业务上有没有意义"。所以指标好看只能说明算法层面是自洽的,不能说明别的。

有标签时才能用外部指标,比如调整兰德指数(ARI)、归一化互信息(NMI)、纯度(Purity)。这些指标把聚类结果和真实标签做对比,数值越高说明聚类越接近真实分布。但这里有个陷阱:如果标签本身是业务自己打的,聚类和标签不一致也不代表聚类失败,因为聚类本来的使命就是发现标签里没有的潜在结构。我在项目里会把两类指标都跑一下,内部指标用来判断几何质量,外部指标用来检查是否偏离常识太远。

5.2 簇内形态与质心含义的检查

指标终究不能代替人眼和业务判断。拿到聚类结果之后,我习惯做三件检查。

第一是可视化。如果数据本身是二维或三维的,直接散点图着色就能看出很多问题:簇是不是严重重叠、是不是出现了狭长或空心的奇怪形状、离群点是不是被单拎出来成了一个簇。如果数据维度高,就先做PCA降到二维再看。这个检查能在30秒内帮你否决掉一批明显不合理的K值。

第二是质心检验。把每个簇的质心在每个维度上的值列出来,和整体均值对比,看看差异是否具备解释性。用户行为分群里,一个簇的质心可能在"使用频次"上显著偏高、在"使用时长"上偏低,这种模式能不能解释成某种用户画像?如果解释不了,哪怕指标再好,这个簇也是算法产生的数字幻觉。

第三是稳定性检查。同样的数据,用不同随机种子跑10次K-means,看看样本的分配结果变化大不大。如果每跑一次都有大量样本换簇,说明数据本身没有稳定的簇结构,任何分群都牵强。反过来,如果结果高度稳定,说明确实存在清晰的群组边界。

5.3 什么时候该换掉K-means:非球形簇、密度不均、类别不平衡

K-means的局限性必须在实践中正视,遇到下面三种情况就不该硬撑了。

第一种是非球形簇。数据是弧形的、环形的、嵌套的,K-means强行用直线边界去切,结果一定离谱。比如有两个并排的半环形分布,K-means会把两个环的端头拧在一起,切出完全违背直觉的簇。

第二种是密度差异大。一个簇很稠密、另一个簇很稀疏,K-means为了降低稠密簇的内部SSE,会把稀疏簇的边界不断压缩,最终让稀疏簇被稀释得不成样子。

第三种是簇大小严重不平衡。一个大簇几万样本,几个小簇只有几十个样本,均值更新时大簇的主导权极强,小簇经常在迭代中被大簇"吃掉"。

遇到这些情况,我一般换三类算法:GMM高斯混合模型,它给每个样本一个概率归属而不是硬分配,能处理重叠簇;DBSCAN,它按密度连片建簇,能天然识别噪声;层次聚类,它能给出完整的谱系图,解释起来更方便。

现在总结一下我自己在这些年项目里的习惯:拿到一份未知结构的数据,先做一次快速降维并可视化,看看整体形态。如果像几团密集的凸泡,就用K-means;如果形状复杂,就直接放弃K-means,从密度聚类和层次聚类里选。这个习惯帮我省下了大量调参时间。希望这篇笔记里写的东西,也能帮你少走一些弯路,至少在下次看到一串聚类标签的时候,能准确地判断出:这个结果到底该不该信。

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

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

立即咨询