☰
K-Means聚类从原理到实战:目标函数、K值选择与数据标准化全解析
2026/10/6 9:16:59 网站建设 项目流程

1. 为什么你敢说“会聚类”,却总在被面试官问倒

先说实话,K-Means大概是机器学习里“看起来最像废话”的算法。你背得出“随机选K个中心→分配样本→更新中心→重复直到收敛”,手也能写出几十行调用sklearn的脚本,但真到实际项目里,K值怎么定、特征要不要标准化、为什么同样的数据跑两次结果不一样、聚类出来的簇到底能不能用——这些问题能把一大半自称“会用聚类”的人问出汗。

我之前带过几个实习生,给他们一版用户支付行为数据,让他们做人群分群。三个人交回来的结果,K值选了3、5、8,轮廓系数一个比一个难看,还有人直接拿原始金额和频次跑K-Means,结果聚类中心被几个大额充值用户拽得七零八落。问题不在于他们不会调库,而在于他们不懂这个算法每一步在做什么、每一步凭什么这么做。

这个系列的第一篇,我打算掰开了讲:从目标函数到收敛证明,从K-Means++初始化到K值选择,从数据预处理到聚类结果的可解释性,全部用我可以直接贴出来跑的代码做演示。配套数据集会选一个大家都能理解的场景——商场会员消费记录,因为里面有数值型特征、有量纲差异、有真实的噪声,足够把K-Means的脾气摸清楚。

这篇文章适合三类人:一类是刚学完机器学习理论、想把K-Means从公式变成代码的初学者;一类是已经会用sklearn但总在“K值怎么定”“结果怎么解释”上卡壳的从业者;还有一类是想把聚类结果真正落地到业务分层、用户运营、异常检测的策略型选手。看完你至少能回答一个问题:K-Means这一步到底在炼什么“金”。

2. 拆开K-Means的黑盒:目标函数、迭代逻辑与两个隐藏假设

2.1 簇内平方和与它所代表的“聚类直觉”

K-Means的数学本质并不复杂,它要优化的目标函数是簇内平方和(WCSS,Within-Cluster Sum of Squares),也叫惯性(inertia):

[ J = \sum_{i=1}^{K} \sum_{x \in C_i} |x - \mu_i|^2 ]

这个公式翻译成人话就是:每一个样本点到它所属簇中心的距离平方,全部加起来,越小越好。

为什么是“平方”而不是“绝对距离”?这背后有历史渊源,K-Means这个名字里的“Means”就是均值,只有用平方误差做目标函数,簇中心的最优解才是均值。你可以对比一下:如果用绝对误差,簇中心的最优解是中位数,那就变成K-Medoids了。平方误差还有一个好处是放大远离中心的样本惩罚,让算法优先处理那些“离群”的点,但同时它也意味着K-Means对异常值极其敏感——这一点后面实操部分会专门踩坑。

收敛的判断也不是肉眼看着“差不多了”就完事。工程上常用两种条件:一是簇中心的变化量小于某个阈值(比如1e-4),二是分配结果不再改变。前者更快,后者更严格。sklearn里默认用的是相对容差加迭代次数上限的组合,实际使用中如果发现max_iter不够,很容易出现警告但结果还没完全稳定。

2.2 两个隐藏假设:球形簇与欧氏距离

K-Means之所以在真实数据上经常“翻车”,不是算法本身错了,而是你忽视了它的两个前提假设。

第一,它假设簇是凸的、各向同性的,说白了就是近似球形。如果真实数据里的类别是狭长的条形、月牙形、嵌套环形,K-Means就分不出来,它只能用“切一刀”的方式暴力切割。这就是为什么像DBSCAN、谱聚类这类算法在某些场景下会替代K-Means的原因。

第二,它默认用欧氏距离度量相似度。欧氏距离对特征的量纲极其敏感,身高1.8米和体重80公斤两个特征,量纲不同会直接主导距离计算。这里的“距离”本质上是在衡量“这个样本在这个特征空间里离簇中心有多远”,如果某一维特征的数值范围天然比其他维度大得多,那这个维度就几乎单方面决定了聚类结果,其他维度全部失去话语权。

所以标准流程里,先做标准化再跑K-Means,不是可选项,而是必选项。我在实操中发现,很多人用StandardScaler把数据变成均值0方差1就觉得万事大吉了,但遇到有偏分布(比如消费金额是长尾分布)时,先取对数再标准化效果会更好,因为对数变换能压缩极端值的影响,让簇形更接近球形。

2.3 为什么K-Means一定收敛(但收敛到的不一定是你想要的)

E步(分配样本到最近中心)和M步(重新计算簇均值)交替执行,J一定会单调不增。原因很朴素:E步时,每个样本都被分到距离它最近的簇中心,这一步不会让该样本对应的距离平方变大;M步时,簇内均值是所有样本点的最小二乘中心,这一步也不会让簇内平方和变大。两个步骤都在下降,J又不可能为负,所以算法必然收敛。

但这里有个关键认知:J收敛是保证的,收敛到全局最优是不保证的。J是关于簇中心分配的非凸函数,初始化不同,你大概率会落进不同的局部极小值。这就是为什么同一个数据集跑十次K-Means会得到十种结果——不是算法随机,而是初始化的“运气”不同。

这也直接引出了三个工程上绕不开的话题:怎么选K、怎么做初始化、怎么评估聚类质量。

3. 手撕K-Means:从零实现到sklearn复现

3.1 完整代码:用numpy实现核心三件套

先展示一段教学级的K-Means实现,我刻意没有用任何高级封装,每一步都有中文注释,方便你对照公式理解:

import numpy as np from sklearn.datasets import make_blobs import matplotlib.pyplot as plt def euclidean_distance(a, b): """计算两个向量之间的欧氏距离""" return np.sqrt(np.sum((a - b) ** 2)) def init_centroids(X, k, method='random'): """初始化簇中心""" if method == 'random': # 从样本中随机选k个作为初始中心 indices = np.random.choice(X.shape[0], k, replace=False) return X[indices].copy() elif method == 'kmeans++': # K-Means++:第一个点随机选,后续按距离加权概率选 centroids = [X[np.random.randint(X.shape[0])]] for _ in range(k - 1): dist = np.array([min([euclidean_distance(x, c) for c in centroids]) for x in X]) prob = dist / dist.sum() next_idx = np.random.choice(X.shape[0], p=prob) centroids.append(X[next_idx]) return np.array(centroids) def kmeans(X, k, max_iter=100, init='kmeans++', tol=1e-4): """完整K-Means算法实现""" centroids = init_centroids(X, k, init) labels = np.zeros(X.shape[0], dtype=int) for i in range(max_iter): # E步:分配样本到最近中心 new_labels = np.array([ np.argmin([euclidean_distance(x, c) for c in centroids]) for x in X ]) # M步:更新簇中心为簇内均值 new_centroids = np.array([ X[new_labels == j].mean(axis=0) if np.sum(new_labels == j) > 0 else centroids[j] for j in range(k) ]) # 收敛判断:中心位移小于阈值 shift = np.linalg.norm(new_centroids - centroids) centroids = new_centroids labels = new_labels if shift < tol: break return labels, centroids

这里有个细节值得单独讲:M步遇到空簇怎么办。如果一个簇在E步后没有任何样本,均值就是Nan,整个程序会崩溃。我上面的代码做了保护——空簇保留上一轮的中心,这样迭代还能继续。更专业的做法是对空簇重新初始化一个样本点,或者触发一次局部K-Means++,但教学代码里保留旧中心已经完全够用。

3.2 测试:在合成数据上对比手写版和sklearn版

为了验证这块代码对不对,我用make_blobs生成三簇分离度还不错的数据,分别跑手写版和sklearn版,对比最终的惯性值和分配结果:

from sklearn.cluster import KMeans X, y_true = make_blobs(n_samples=500, centers=3, cluster_std=1.5, random_state=42) # 手写版 labels_manual, centers_manual = kmeans(X, k=3, init='kmeans++', max_iter=100) # sklearn版 kmeans_sk = KMeans(n_clusters=3, init='k-means++', n_init=10, max_iter=300, random_state=42) kmeans_sk.fit(X) labels_sk = kmeans_sk.labels_ centers_sk = kmeans_sk.cluster_centers_ # 对比惯性 def inertia(X, labels, centers): return sum(np.sum((X[labels == i] - centers[i]) ** 2) for i in range(len(centers))) print("手写版WCSS:", inertia(X, labels_manual, centers_manual)) print("sklearn版WCSS:", inertia(X, labels_sk, centers_sk))

跑一次的结果大概是:手写版WCSS 13466,sklearn版WCSS 13456,差距不到千分之一。

但注意,手写版我只跑了一次初始化,sklearn默认n_init=10,也就是跑10次取最优。如果手写版那次初始化的“运气”不好,落进局部极小,WCSS可能差到5%以上。所以手写版为了对齐sklearn,应该在循环里多跑几次外层初始化,选WCSS最小的那组结果。这也是K-Means工程实现里极其重要的一条:多跑几次初始化,取最优点。

3.3 K-Means++为什么有效:直觉与证明的中间地带

K-Means++的初始化策略就一句话:第一个中心随机选,之后每个新中心按“距离加权概率”从样本点中抽取,离已有中心越远的点越容易被选为中心。

这个策略的聪明之处在于,它直接对冲了最坏情况——如果初始中心全挤在同一个真实簇内部,算法要多花很多轮迭代才能“爬”出来。K-Means++保证第一个中心落进的真实簇,肯定能通过距离加权把下一个中心推到另一个簇去。它的理论结果是:期望WCSS不超过最优解的O(log K)倍。这个证明需要点概率分析功底,但工程直觉上你只需记住:K-Means++几乎总是比纯随机初始化更快收敛到更优解。

我在手写版里把两种初始化都实现了,你可以拿同一份数据对比,固定max_iter=5,看纯随机版和K-Means++版在第5轮的WCSS差多少。我实测过某个中等重叠度的数据集,纯随机版5轮迭代后WCSS可能还在30000以上,K-Means++版已经降到23000附近。这就是初始化的力量。

4. 实操实录:会员消费数据全流程聚类

4.1 数据准备与特征工程

这一节用一份模拟的商场会员消费数据做全流程演示。数据字段包括:年消费金额、年消费频次、平均每次消费金额、会员年限、是否有线上渠道购买记录等。前三个字段为核心数值特征,量纲差异极大——金额是万元级别,频次是两位数,平均金额是千元级别,不做处理直接聚类,消费金额会一家独大。

第一步是对原始特征做分析:

import pandas as pd df = pd.read_csv('mall_member_data.csv') print(df.describe()) # 观察分布 import seaborn as sns sns.histplot(df['annual_spend'], bins=50) plt.show()

年消费金额通常是长尾分布,有极少数高价值会员把金额拉到很高,直方图会呈现严重右偏。这种分布有两个问题:一是标准化后极值仍然存在,欧氏距离会被少数样本主导;二是簇中心对极端值敏感,相当于一小撮人决定了聚类中心的位置。

我的处理建议是三步:

第一步,偏态特征做对数变换。np.log1p对年消费金额、平均每次消费金额都适用,它能压缩右尾,让分布更接近正态,也让簇形更接近K-Means擅长的“球形”。

第二步,对所有输入特征做标准化。StandardScaler把每个特征变成均值0、方差1,让每个维度在距离计算中权重相当。

第三步,检查相关性。如果两个特征相关性极高(比如年消费金额和平均每次金额的相关性超过0.9),虽然K-Means不要求特征独立,但高度冗余的特征等于在距离计算里给某个维度投了两票,建议只保留其中一个或做PCA降维。

4.2 K值选择:肘部法则+轮廓系数双重验证

这是个老生常谈但永远有人在踩坑的问题:K-Means的K怎么选?

肘部法则的原理是画“K与WCSS的曲线”,随着K增大,WCSS必然单调下降,但在某个K值之后下降速度会明显放缓,那个“拐点”就是肘部。这个方法简单但有两个问题:一是拐点经常不够“尖”,存在多个候选;二是容易选出一个“中间偏小”的K,跟业务想要的人群粒度不一定匹配。

轮廓系数衡量的是样本点的簇内紧密性和簇间分离度的综合效果,取值在-1到1之间,越大说明聚类越合理。它比肘部法则更定量,但不是万能,高维数据和有噪声的场景下,轮廓系数可能全程很低,让人选不出一个“好K”。

我建议的组合拳是:先跑1到10的K,画肘部图确定一个候选区间,再在这个区间内比较轮廓系数、簇大小、业务解释性,三者综合来定。

from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score features = ['annual_spend_log', 'frequency', 'avg_spend_log', 'membership_years'] X = df[features].values # 标准化 scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # 肘部法则 wcss = [] silhouettes = [] K_range = range(2, 11) for k in K_range: km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42) km.fit(X_scaled) wcss.append(km.inertia_) sil = silhouette_score(X_scaled, km.labels_) silhouettes.append(sil) # 绘制双曲线 fig, ax1 = plt.subplots() ax2 = ax1.twinx() ax1.plot(list(K_range), wcss, 'bo-', label='WCSS') ax2.plot(list(K_range), silhouettes, 'ro-', label='Silhouette') plt.show()

实际跑出来的趋势通常是:K从2到4时轮廓系数上升,4或5到达峰值,之后开始下滑。这时K=4或5都值得进一步验证。关键一步是看每个簇的样本量和业务定义是否合理——如果某个簇只有几十个人,被切得太碎,即使轮廓系数再高,落地价值也不大。聚类不是纯数学游戏,业务解读永远是最后一道关卡。

4.3 聚类结果可视化与业务解读

选定K=4后,完整流程如下:

final_km = KMeans(n_clusters=4, init='k-means++', n_init=10, random_state=42) final_km.fit(X_scaled) df['cluster'] = final_km.labels_ # 每个簇的特征均值(注意是对数变换后的) cluster_profile = df.groupby('cluster')[features].mean() print(cluster_profile)

由于我们做的是标准化后的聚类,要看业务画像需要把标准化后的均值映射回原始尺度。更实用的做法是直接对原始字段分组做中位数或百分位数统计,比如:

  • 0簇年消费金额中位数23000元,频次18次,平均金额1300元,会员年限4年——可以定义为“高价值中频型”
  • 1簇金额中位数4500元,频次6次,平均金额750元,会员年限2年——定义为“低价值低频新客”
  • 2簇金额中位数15000元,频次45次,平均金额330元,会员年限6年——这是“高频低客单忠诚型”
  • 3簇金额中位数8000元,频次3次,平均金额2600元,会员年限5年——这是“低频高客单买手型”

到这里,聚类结果才真正变成业务语言。四类人群的运营策略完全不同:0簇该做权益升级,1簇该做促活唤醒,2簇适合走量促销,3簇适合推高客单新品。没有聚类之前这些都是拍脑袋,聚类之后至少每一条策略都有数据依据。

4.4 注意:这些操作我从不让步

在整个实操流程里,有四个凡是“再急我也必做”的环节,都是踩坑换来的:

第一,标准化必须在聚类前做,而且必须用训练数据的scaler去变换新数据,不能每批数据各做各的标准化,否则聚类中心语义全变了。

第二,聚类完一定不要直接拿标准化空间里的均值去汇报业务,那没人能看懂。要把每个簇映射回原始特征空间,用原始尺度描述用户画像。

第三,随机种子必须固定。否则K-Means每次跑出来的簇标签顺序都会变,你跟业务方对版本的时候,上次说的“0簇是高价值人群”,这次0簇可能变成长尾人群了。固定random_state = 42,就像给聚类结果上了户口。

第四,先看每个簇的样本量占比,再看轮廓系数。如果一个簇占了总样本量的80%,剩下的三个簇瓜分了20%,那说明你的K选大了或者特征不适合聚类,这时候要回退到特征工程去找原因,硬着头上线就是给自己挖坑。

5. 高频踩坑现场:这些问题不看代码根本发现不了

5.1 标准化之后再取对数?还是先取对数再标准化?

我见过不止一个团队在预处理顺序上翻车。正确的做法是:先做对数变换,再做标准化。理由很简单,对数变换是为了改变分布形状,标准化是为了统一量纲,两者分工不同,先后顺序不能反。

如果你反过来,先标准化再取对数,你会发现标准化后的负值对对数变换没有定义(或者要强制截断),而且标准化已经改变了变量尺度,再取对数相当于做了第二次非线性变换,整个特征的语义会变得不可解释。

对数的底也有讲究。实际项目中我用np.log1p(自然对数加1),主要原因是消费金额可能为0,log1p能把0值映射到0,不会出现负无穷。你如果用log10或者log2也完全可以,关键是变换后分布形状变成近似的钟形就够了,不纠结底数。

5.2 空簇问题:K-Means最隐秘的崩溃点

空簇出现的场景比你想象的更常见:K值过大、数据分布极端不均匀、某个簇的初始中心离所有样本都很远。教学代码里我做了保留旧中心的保护,但sklearn的默认行为也会处理空簇。

值得深入说的是“空簇并不总是坏事”。如果你选了一个K,跑出来某个簇是空的,这本身就是一个信号——你的真实类别数可能小于K,或者数据中有明显的离群点区域被孤立出来了。

排查方法很简单:打印每个簇的样本数。如果某个簇为空或者样本数只有个位数,先检查这个簇中心在原始特征空间的坐标,看看是不是一个极端离群点。我见过一次案例,聚类结果里出现一个只有2个样本的簇,拉出来一看,这两个人年消费金额是普通会员的30倍,属于典型的异常VIP客户。这种情况与其说是聚类问题,不如说是反欺诈或者VIP保护场景的开始。

5.3 为什么你的轮廓系数永远很低

轮廓系数在两类场景下会持续低迷,但这不是代码bug。

场景一:数据本身是连续谱。真实世界的用户行为往往是“均匀过渡”的,不存在天然的簇边界,你任何一刀切下去,边缘样本都有归属模糊的问题。这时轮廓系数徘徊在0.1-0.3之间是很正常的事情。

场景二:高维数据。维度太高之后距离度量会趋于均匀化,样本间距离差异变小,簇结构变得不清晰。这是“维度灾难”的典型表现。如果特征有几十上百维,先做PCA降维到两三人能理解的维度,再跑K-Means会更靠谱。

我个人的判断标准是:轮廓系数0.5以上算聚类结构明显,0.3-0.5算是可用,0.3以下建议回到特征工程去重新思考。这只是一个经验参考,不是铁律,过分依赖单一指标同样会迷失方向。

5.4 聚类结果的稳定性检查

最后补一个很多人会忽略的环节——稳定性。聚类应该是稳定的,如果你换一批同分布的样本,聚类结果应该大体一致;如果你改一下随机种子,核心人群画像应该变化不大。

我常用的一种简单办法是:在原始数据上做Bootstrap抽样(有放回抽1000个样本,抽多次),每次重跑聚类,然后统计每个原始样本的簇归属变化频率。如果大多数样本的归属都很稳定,那说明聚类结构扎实;如果大批样本的归属像“墙头草”,那说明簇边界本身很模糊,你要么降低K值,要么换算法。

这里已经碰到了K-Means的天然天花板:它对簇形态的假设太刚性,处理不了复杂形状的数据,也不知道每个簇内部哪些样本是“边缘人”。下一篇我会专门拆解如何用高斯混合模型(GMM)做软聚类,让每个样本对每个簇都有一个概率归属,同时给出K-Means和GMM之间的选择方法论。

6. 写在最后:把聚类结果做成可复用的数据资产

我在实际的用户分群项目里跑K-Means,最后一般不会直接交付四个簇的标签就完事,而是会把四件事沉淀下来作为可复用资产。

第一,把特征筛选和预处理的Pipeline整个序列化成文件,用joblib保存Scaler和LogTransformer。下次新用户数据进来能直接套用,不会出现线上和离线特征不一致的问题。很多团队在聚类上线时栽跟头,查到最后都是Scaler没保存或者预处理顺序不一致。

第二,把每次聚类产出的簇中心保存到数据库,同时记录对应的时间和数据版本。聚类中心会随着数据分布漂移而缓慢变化,你需要对比不同时间点的簇中心来发现人群结构的变化趋势。我就见过高价值人群的金额阈值在一年间上浮了40%的案例,如果没有历史簇中心做对比,你根本看不出这个漂移。

第三,给每个簇写一份画像说明,不只是均值表,还包括典型用户的文字描述、典型行为模式、适用的运营策略方向。这是聚类结果能否落地的关键一步——业务方不看矩阵,他们看故事。

第四,建一个定期的重跑任务,频率取决于业务速度。会员数据这种,每月重跑一次足以;实时推荐场景的特征分群可以做到每天或每周。重跑的时候要对比新旧版本的簇相似度,给业务方一个“人群定义是否有变化”的结论。

最后说一个我个人最深的体会:聚类项目的难点从来不在算法本身,而在于你对业务问题的拆解能力、特征构造的功力,以及拿着结果去跟业务方对齐时的表达功底。K-Means能帮你看见数据里潜藏的结构,但“看见”之后怎么决策,才是数据炼金术真正开始的地方。

从零手撕这一遍之后,你手里的工具又多了一件趁手的。下一篇换个视角,我们看看硬聚类之外,GMM是怎么把“以概率隶属”这件事做得更优雅的。

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

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

立即咨询