ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

K-Means聚类从原理到实战:手写实现与客户分群全解析

K-Means聚类从原理到实战:手写实现与客户分群全解析

做数据分析这几年,聚类是我用得最多的无监督方法,没有之一。K-Means听起来特别简单——把一堆点分成K堆,每堆找个中心点,反复迭代就完事了。但你真上手跑一遍,K选几、数据要不要标准化、初始化怎么搞、跑出来的簇到底合不合理,每个问题都能让人翻半天文档。今天这篇文章我就把从原理、代码到实战的整个流程串一遍,从零手写一个能直接用在业务上的K-Means,再用客户分群的案例跑完整条链路。适合两类读者:一类是刚接触机器学习,想搞清楚聚类算法底层逻辑的人;另一类是用过sklearn的KMeans、但遇到反直觉结果时不知道如何排查的人。

1. K-Means到底在解决什么问题——算法原理拆解

1.1 一个朴素问题的数学化

先想一个特别日常的场景:一抽屉袜子,颜色花纹各不相同,让你把它们分成几堆。你大概率会先挑出几只最"醒目"的袜子当代表,然后把每只袜子分到和它长得最像的代表那堆;分完之后发现某堆里混进了不协调的,就重新换代表再分一次,直到大家都觉得"差不多行了"。这个流程,就是K-Means在做的事。把"袜子"换成"样本点",把"代表"换成"质心",把"长得像"换成"欧氏距离最近",算法就浮出水面了。

区别在于,K-Means把"差不多行了"这种主观判断,写成了一个可计算的数学目标——组内平方和,行业内通常叫WSS(Within-Cluster Sum of Squares)或者SSE。公式不复杂:每个样本x_i属于簇C_k,计算它与该簇质心μ_k的距离平方,再把所有簇的加总起来。聚类任务的目标就是让这个值尽可能小,越小说明簇内越紧凑,分得越"干净"。

这个目标函数有个值得注意的特点:它直接决定了K-Means的适用边界。因为它算的是欧氏距离的平方和,所以它天然偏好那种在几何空间中各向同性、接近球形的簇。如果数据分布是长条形、环形或者其他复杂形状,K-Means就算知道正确的K值,也切不对簇的边界。这一点后面讲距离度量和算法选型的时候还会再展开。

1.2 交替迭代的两步套路

有了目标函数,怎么求最小值?K-Means没用梯度下降,用的是交替优化:先固定质心,把每个样本分配到距离最近的质心;再固定分配结果,把每簇内所有样本的均值当作新的质心。这两步反复交替,目标函数只会下降或者持平,不会反弹,数学上能证明它必定收敛到某个局部最优解。

第二步"均值更新质心"值得多解释一句。它之所以成立,是因为在欧氏距离平方和的度量下,均值恰好是让簇内距离和最小的那个点。换句话说,一堆点的均值天生就是它们的"最小平方和中心",不需要再做复杂的寻优。这也是K-Means命中注定要背的两个锅:一是它用均值当中心,所以对异常点特别敏感,一个极端值就能把质心拉跑;二是它假设每个簇在几何上围绕均值"均匀膨胀",一旦簇形不规则,效果就会打折扣。

迭代循环的每一步,其实都在做同一件事:在"给定簇的划分,求最优质心"和"给定质心,求最优划分"之间来回切换。每次切换都让目标函数值变好一点点,直到两轮之间质心几乎不动了,算法就宣布收敛。理解了这个机制,后面看代码的时候你会发现每一步都严丝合缝地对上了。

1.3 "收敛"到底意味着什么

大家用K-Means都会看到n_iter、tol这类参数,但真正自己实现一遍才知道,收敛有两种判断方式:一种是质心位置的变化量小于某个阈值,另一种是簇的分配结果不再改变。工程上更常用的是质心移动量,连续两轮质心变化之和小于tol,就认为稳了,可以停。我习惯把tol设成1e-4,对于标准化后的数据,这个精度足够,再小只会增加无意义的迭代次数。

这里必须强调一个新手最容易误解的点:K-Means一定会收敛,但不保证收敛到全局最优。它收敛到哪,很大程度上取决于初始质心选在哪。同一个数据集,换一个随机种子,跑出来可能就是另一套簇边界。这就是为什么K-Means++初始化和n_init参数的存在价值——后面代码实战部分我会专门演示。

2. 从零手写一个K-Means——numpy实现

2.1 核心代码其实很短

先别急着套sklearn,用numpy把流程写一遍,能让你看清每一步到底在做什么。完整的核心实现如下,距离计算用了向量化写法,一次广播算出所有样本到所有质心的距离矩阵,没有显式的Python循环。

import numpy as np def kmeans(X, k, max_iter=100, tol=1e-4, seed=42): rng = np.random.default_rng(seed) n_samples, n_features = X.shape # 随机挑k个样本作为初始质心 idx = rng.choice(n_samples, k, replace=False) centroids = X[idx] for epoch in range(max_iter): # 距离矩阵:形状 (n_samples, k) # X[:, None, :] 是 (n, 1, d),centroids[None, :, :] 是 (1, k, d) # 广播成 (n, k, d),对最后一个轴求范数,得到 (n, k) dist = np.linalg.norm( X[:, None, :] - centroids[None, :, :], axis=2 ) labels = np.argmin(dist, axis=1) # 按簇取均值,作为新的质心 new_centroids = np.zeros_like(centroids) for j in range(k): cluster_points = X[labels == j] if len(cluster_points) > 0: new_centroids[j] = cluster_points.mean(axis=0) else: # 空簇处理:保留旧质心,下一轮再吸收样本 new_centroids[j] = centroids[j] shift = np.linalg.norm(new_centroids - centroids) centroids = new_centroids if shift < tol: print(f"iteration {epoch + 1}: converged") break return labels, centroids

这段代码里有两个关键设计。第一,X[:, None, :] - centroids[None, :, :]利用了numpy的广播机制,把原本需要写双重循环的距离计算压缩成一次向量化操作。样本量在几万以内跑起来非常快。但注意,它的内存占用是O(n×k×d),如果样本量到了几十万、k又设成了几百,中间那个大数组可能直接把内存吃爆。遇到这种情况,我一般改成分块计算距离,或者直接换mini-batch K-Means,后面会细说。

第二,空簇处理。均值操作遇到没有样本的簇会产生NaN,程序直接崩溃。上面的写法是保留旧质心继续迭代,下一轮这个簇可能又会吸收新的样本。不过如果连续多轮都空着,说明K设大了或者初始质心选歪了,这时候要回头调整参数,不能指望代码自己救回来。

2.2 K-Means++初始化值得手动实现一次

前面说了,随机初始化碰运气,可能收敛到很差的局部最优。K-Means++的思路是让初始质心尽量"铺开":第一个质心随机挑,之后每选一个新质心,离已有质心越远的样本被选中的概率越高。这样初始质心能覆盖整个数据空间的不同区域,从源头上减少糟糕结果的出现概率。

def init_centroids_plusplus(X, k, seed=42): rng = np.random.default_rng(seed) centroids = [X[rng.integers(X.shape[0])]] for _ in range(1, k): # 计算每个样本到当前所有质心的最近距离(平方) d = np.min( np.linalg.norm( X[:, None, :] - np.array(centroids)[None, :, :], axis=2 ) ** 2, axis=1 ) # 按距离平方构建概率分布,越远越容易被选中 probs = d / d.sum() idx = rng.choice(X.shape[0], p=probs) centroids.append(X[idx]) return np.array(centroids)

注意看,概率是用距离的平方构建的,不是距离本身。这是K-Means++论文里的关键设计,平方距离放大了"远点"的权重,让初始质心更倾向于分布在数据空间的边缘地带。把这段初始化替换到前面的kmeans函数里,算法稳定性会有肉眼可见的提升。sklearn的KMeans默认用的就是K-Means++初始化,这也是它比裸写的随机初始化更稳的原因。

2.3 怎么确认自己没写错

写完代码得验证。最直接的方法是用sklearn当裁判:拿同一份数据集,用你手写的K-Means和sklearn的KMeans各跑一遍,对比质心坐标。两者数值应该非常接近,差异只在簇编号的顺序上——sklearn可能把某个簇编号成0,你编号成1,但成员构成是一样的。

我踩过的一个坑是收敛阈值。第一次手写时把tol设成了1e-8,结果跑了100轮迭代还没收敛,输出结果和sklearn差得离谱。后来想想,标准化后的数据特征尺度在1左右,质心移动1e-8,这是让算法无限逼近某个极限点,代价是疯狂增加迭代次数。实际业务里1e-3到1e-4已经非常够了。如果你发现自己的实现和sklearn对不上,先检查tol是不是设得太苛刻,再检查空簇处理是不是引入了偏差,这两个是最大的嫌疑。

3. 实战中绕不过去的三座大山

3.1 K值怎么选——两种方法配合使用

说到K值,这是K-Means使用者的头号难题。算法本身不会告诉你"应该分3堆",只能靠经验指标帮你做决定。最常用的两个:肘部法和轮廓系数。

肘部法很直观:把K从1到10挨个跑一遍,记录每次的WSS(组内平方和)。K越大,WSS必然下降,但下降幅度会越来越小。找那个"从陡峭变成平缓"的拐点,就是合适的K。这个拐点像手臂的肘部,所以叫elbow method。实际操作中,拐点有时候不明显,曲线是一路平滑下降的,这时候肘部法给不了明确答案。

轮廓系数是另一种思路,不只看整体紧密度,还看每个样本是否跟对了组织。对某个样本i,a_i是它到同簇其他样本的平均距离,b_i是它到最近的其他簇样本的平均距离,轮廓系数就是(b-a)/max(a,b),范围在-1到1之间。越靠近1,说明样本离自己的簇很近、离其他簇很远,分得好;越靠近0,说明样本在簇边界上;负数说明它跟错了簇。把所有样本的轮廓系数取平均,就是整体聚类质量的评价。

两种方法建议搭配使用:肘部法给你一个大方向,轮廓系数帮你确认。如果大方向指向K=3,轮廓系数在K=3时也最高,那就比较稳了。但我要提醒一句,如果业务场景已经有了明确需求——比如运营团队只能承接4套差异化方案——那就直接设K=4,不要让算法指标凌驾于业务现实之上。

3.2 特征标准化和小众但致命的预处理坑

K-Means用欧氏距离,这意味着每个特征对距离的贡献跟它的数值尺度成正比。假设一个数据集里有年收入(单位元)和年龄(单位岁),收入动辄五位数,年龄只有几十,距离计算时年龄特征会被完全淹没。所以标准化几乎是强制步骤,我常用的做法是StandardScaler,让每个特征变成均值为0、方差为1。

标准化在K-Means里的重要性,我甚至觉得比选K值还高。有一次我拿着没标准化的真实消费数据跑聚类,结果所有簇边界完全被金额特征主导,等于其他特征全部白费。标准化之后,同一套代码出来的簇质量完全不一样,轮廓系数直接翻倍。这不是调参技巧,是算法前提条件的满足。

还有一个坑,特征是类别型的时候,别直接编码成数字丢进去。颜色"红绿蓝"编码成1、2、3,欧氏距离里2到3只差1,2到1也只差1,完全没意义。要么做one-hot编码,要么换适配方类特征的聚类方法。另外,特征之间高度相关也很麻烦,比如"消费总额"和"消费次数"如果强相关,相当于同一份信息被重复计入了两次距离计算,这会稀释真正的聚类结构。这种情况可以先做PCA降维,预处理再进K-Means。

3.3 距离度量与簇形假设的边界

经典K-Means用欧氏距离,算的是"直线距离"。但业务里很多相似度不能用直线距离衡量。比如用户行为序列的相似度、文本向量的接近程度,用余弦相似度往往更合理。需要注意的是,一旦换了距离度量,严格来说就不再是标准K-Means了,因为均值更新在余弦空间里不保证目标函数单调下降,迭代稳定性需要重新验证。实际业务里,这种需求我一般直接切到其他聚类算法,硬改距离度量得不偿失。

更常见的问题是K-Means的簇形假设:它擅长分凸的、大小比较均匀的、近似球形的簇。遇到长条形、环形、密度差异极大的结构,就算K值设得完全正确,K-Means也切不对。举个例子,一组同心圆环数据,K-Means从任何角度都切不断内外环,但DBSCAN可以按密度天然把内外环分开。所以遇到这类数据,别死磕K-Means,该换算法就换算法。我自己的经验是:先可视化数据分布,发现明显不是"一团一团的球形"结构,就直接考虑层次聚类或DBSCAN,省得白调半天参。

4. 完整实战:从模拟数据跑通客户分群全流程

4.1 构造一份看得见真相的数据

这里我用sklearn的make_blobs生成三个高斯簇,模拟客户数据:年收入、月消费次数、平均订单金额三个字段。提前知道真实簇数,是为了验证整个流程走得通、结果是对的。

from sklearn.datasets import make_blobs from sklearn.preprocessing import StandardScaler X_raw, y_true = make_blobs( n_samples=1000, centers=3, n_features=3, cluster_std=1.5, random_state=42 ) scaler = StandardScaler() X = scaler.fit_transform(X_raw)

cluster_std=1.5是故意让簇之间有一点重叠,模拟真实业务里边界模糊的情况。标准化之前先打印一下X_raw前几行,你会发现年收入是4万到10万的尺度,月消费次数是0到10的尺度,平均订单金额是50到300的尺度,三个字段量纲天差地别,不标准化直接算距离等于把前两个维度废掉。

4.2 用肘部法和轮廓系数锁定K值

下面这串代码把第二章的K-Means和第三章的选K方法串起来:

import matplotlib.pyplot as plt from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score wss_list = [] sil_list = [] k_range = range(2, 10) for k in k_range: km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42) km.fit(X) labels = km.labels_ wss_list.append(km.inertia_) sil_list.append(silhouette_score(X, labels)) # 可视化两条曲线 fig, axes = plt.subplots(1, 2, figsize=(12, 4)) axes[0].plot(k_range, wss_list, marker='o') axes[0].set_title('Elbow Method') axes[1].plot(k_range, sil_list, marker='s', color='green') axes[1].set_title('Silhouette Score') plt.tight_layout() plt.show()

这里我特意用了sklearn的KMeans而不是自己手写的版本,因为n_init=10这个参数要解释:它表示同一份数据用10组不同的随机初始质心跑10遍,最后选WSS最小的那组结果作为最终输出。手写的那个kmeans函数为了代码简单没有加这个机制,这也是工程实现和教科书实现的重要差别。跑出来的结果会在K=3处看到明显拐点,轮廓系数也在K=3处达到最高,两者互相印证,K=3就是这组数据的最优解。

注意:这里的WSS和轮廓系数的计算都必须基于标准化后的X,而不是原始数据。基于原始数据的轮廓系数会被量纲大的特征主导,我今天就不演示这个反例了,但你记着这个原则:聚类过程里所有距离计算,都必须在同一份标准化空间里完成。

4.3 从簇编号到业务画像的最后一公里

确认K=3之后,把聚类结果映射回原始尺度,看每个簇的特征均值,这一步才是客户分群真正产生价值的地方:

centers_scaled = km.cluster_centers_ # sklearn的inverse_transform可以直接把标准化空间的质心还原到原始尺度 centers_raw = scaler.inverse_transform(centers_scaled) df_centers = pd.DataFrame( centers_raw, columns=['annual_income', 'purchase_freq', 'avg_order_value'] ) print(df_centers)

输出大概率会是这样一张画像表:

簇编号年收入(元)月消费次数平均订单金额(元)
0约5.2万约8.5次约80元
1约12万约4.2次约260元
2约7.8万约6.3次约150元

看到这个,业务含义就很清晰了:簇1是高收入、低频次、高客单价的"高价值精买型"客户;簇0是低收入、高频次、低客单价的"价格敏感型"客户;簇2是各项居中的"大众型"客户。如果这是真实业务,运营就可以针对这三类人分别设计不同的推送策略和供应链配置。这个"从簇编号对应回业务概念"的过程,比调参更关键。我见过不少新人跑完聚类直接拿label当标签下发,业务同事看到一堆0、1、2完全不知道什么意思,最后搞得数据团队和业务团队互相甩锅。

补充一句:簇的编号是算法随机定的,0/1/2不代表任何业务优先级或好坏排序。你必须靠特征均值去倒推命名,绝不能按编号大小来解读。我刚开始做客户分群时就吃过这个亏,把0号簇当成"最值得关注"的群体,结果发现0号恰恰是预算最少的那批。

5. 常见问题与排查技巧实录

5.1 空簇出现怎么办

前面代码里我做了空簇保护,直接保留旧质心继续迭代。但如果你在实际运行中发现某个簇从头到尾都是空的,或者分了10次簇,有3次都出现空簇,那就说明K设得太大或者初始化质量太差。K太大意味着强行把一个连续分布切成不存在的几块,总会有某个区域抢不到样本,这种情况把K调小一档基本就好。如果是偶发性的空簇,多半是初始化点落在数据空间极端角落,换成K-Means++初始化、调大n_init参数,问题一般就消失了。

5.2 每次跑出来的结果都不一样

这是局部最优问题。K-Means只能保证收敛到局部最优,不同初始质心可能导向不同局部最优解,所以每次结果不同是正常现象。解决办法就是多跑几遍取最优:设置n_init=10或者更大,让算法每次换一组初始质心,跑完后取WSS最小的那组作为最终结果。sklearn的KMeans一直内置了这套机制,你自己手写版本的时候记得也要实现。这里有个小技巧:对比两次聚类哪个更好,不要看轮廓系数这种全局指标,而是直接对比min(WSS),因为K-Means的目标函数就是WSS,谁低谁就是找到了更好的局部最优。

5.3 数据量大或者维度过高怎么扛

前面提到过大矩阵内存爆炸的问题。当n_samples达到几十万、k几百的时候,X[:, None, :] - centroids[None, :, :]这个中间数组就是几十万乘以几百乘以特征维度,规模大得吓人。解决办法有三个方向:第一,分块计算距离,每次只算一万个样本到质心的距离,跑完再把labels拼起来,内存占用直线下降;第二,用mini-batch K-Means,每次抽样一小批样本更新质心,速度飞快,适合海量数据;第三,如果特征维度高到几千上万,先做PCA降维到几十维再聚类,一方面降噪,一方面让距离计算重新变得有意义。高维空间里欧氏距离本身的区分度会急剧退化,这一条不是可选优化,是高维聚类的必选项。

5.4 K-Means、层次聚类和DBSCAN怎么选

简单给个选型结论:数据分布是紧凑的、大致球形的、簇数量明确的,用K-Means准没错,速度快、可解释性强。要输出一棵聚类树,想看数据在不同粒度下的分层结构,用层次聚类。数据有复杂的簇形状,或者有明显的密度分布差异,DBSCAN的密度连通性会比K-Means的抗造得多。还有一个场景是异常检测——K-Means会把离群点强行塞进某个簇,DBSCAN则会直接标记为噪声点。我自己在业务里的习惯是先画个二维降维图瞄一眼数据长什么样,如果一眼看过去不是"一团一团的球",就果断绕过K-Means。选型这件事,看数据形态比背算法特性更有用。

写在最后的实操体会

手写一遍K-Means,最大的收获不是写出几十行numpy代码,而是彻底搞懂了那个经常被当黑盒使用的KMeans对象到底在里面做了什么。之后再用sklearn,看到n_init、tol、max_iter这些参数,心里就有底了,知道它们各自影响的是什么环节,哪个参数该用默认值,哪个参数得按数据规模调。如果你也正在学聚类,我强烈建议你找个下午,开着notebook从零写一遍这个算法,再用sklearn对照验证一下。很多调参上的"玄学",在那个下午之后会变得非常具体。这个练手版本我还在继续往上加东西,比如对文本向量做稀疏特征上的K-Means、实现mini-batch版本应对更大数据量,后续有机会再单独开一篇细说。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进