ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

K-Means聚类从原理到实战:目标函数、K值选择与数据标准化全解析

K-Means聚类从原理到实战:目标函数、K值选择与数据标准化全解析 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的数学本质并不复杂它要优化的目标函数是簇内平方和WCSSWithin-Cluster Sum of Squares也叫惯性inertia[ J \sum_{i1}^{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, methodrandom): 初始化簇中心 if method random: # 从样本中随机选k个作为初始中心 indices np.random.choice(X.shape[0], k, replaceFalse) 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], pprob) centroids.append(X[next_idx]) return np.array(centroids) def kmeans(X, k, max_iter100, initkmeans, tol1e-4): 完整K-Means算法实现 centroids init_centroids(X, k, init) labels np.zeros(X.shape[0], dtypeint) 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(axis0) 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_samples500, centers3, cluster_std1.5, random_state42) # 手写版 labels_manual, centers_manual kmeans(X, k3, initkmeans, max_iter100) # sklearn版 kmeans_sk KMeans(n_clusters3, initk-means, n_init10, max_iter300, random_state42) 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 13466sklearn版WCSS 13456差距不到千分之一。但注意手写版我只跑了一次初始化sklearn默认n_init10也就是跑10次取最优。如果手写版那次初始化的“运气”不好落进局部极小WCSS可能差到5%以上。所以手写版为了对齐sklearn应该在循环里多跑几次外层初始化选WCSS最小的那组结果。这也是K-Means工程实现里极其重要的一条多跑几次初始化取最优点。3.3 K-Means为什么有效直觉与证明的中间地带K-Means的初始化策略就一句话第一个中心随机选之后每个新中心按“距离加权概率”从样本点中抽取离已有中心越远的点越容易被选为中心。这个策略的聪明之处在于它直接对冲了最坏情况——如果初始中心全挤在同一个真实簇内部算法要多花很多轮迭代才能“爬”出来。K-Means保证第一个中心落进的真实簇肯定能通过距离加权把下一个中心推到另一个簇去。它的理论结果是期望WCSS不超过最优解的O(log K)倍。这个证明需要点概率分析功底但工程直觉上你只需记住K-Means几乎总是比纯随机初始化更快收敛到更优解。我在手写版里把两种初始化都实现了你可以拿同一份数据对比固定max_iter5看纯随机版和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], bins50) 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_clustersk, initk-means, n_init10, random_state42) 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-, labelWCSS) ax2.plot(list(K_range), silhouettes, ro-, labelSilhouette) plt.show()实际跑出来的趋势通常是K从2到4时轮廓系数上升4或5到达峰值之后开始下滑。这时K4或5都值得进一步验证。关键一步是看每个簇的样本量和业务定义是否合理——如果某个簇只有几十个人被切得太碎即使轮廓系数再高落地价值也不大。聚类不是纯数学游戏业务解读永远是最后一道关卡。4.3 聚类结果可视化与业务解读选定K4后完整流程如下final_km KMeans(n_clusters4, initk-means, n_init10, random_state42) 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主要原因是消费金额可能为0log1p能把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是怎么把“以概率隶属”这件事做得更优雅的。
返回列表