1. 从"没有标签的数据"说起:聚类到底在解决什么问题
很多人刚接触机器学习的时候,脑子里第一反应都是分类——给一堆带标签的数据,训练一个模型,然后拿它去预测新样本属于哪一类。这条路子确实直观,也容易出成果。但真正到了实际项目里,你会发现一个很尴尬的现实:绝大多数数据是没有标签的。
我做过好几个数据分析的项目,客户丢过来一张几十万行的表格,问"能不能帮我分个组",但表格里压根没有"组别"这一列。这时候分类算法就完全派不上用场了,因为你连训练目标都没有。聚类就是为这种场景而生的——它不需要任何标签,纯粹根据样本之间的相似度,把"长得像"的样本自动归到一堆去。
举个生活化的例子。假设你面前有一大把混在一起的豆子,有黄豆、绿豆、红豆,但没有任何标签告诉你哪颗是什么豆。你怎么办?你会看颜色、看大小、看形状,把看起来差不多的放一起。这个过程就是聚类。KMeans和高斯混合聚类(GMM)就是两种不同的"分豆子"策略,区别在于它们对"相似"的定义和分组的方式完全不同。
这篇文章我打算把这两条路线从头到尾捋一遍。不是那种教科书式的公式堆砌,而是按照我实际做项目时的思路来:先搞清楚KMeans的脾气和它的硬伤,再看GMM是怎么用概率的思路把KMeans的短板补上的,最后落到代码和调参上,把踩过的坑都摊开讲。如果你正在做用户分群、图像分割、异常检测、文档归类这类没有标签的任务,这篇内容应该能帮你少走不少弯路。
关键词方面,非监督学习、聚类、KMeans、GMM高斯混合聚类、EM算法、轮廓系数这些概念会贯穿全文,我会在用到的地方自然展开,不会一上来就甩一堆定义。
2. KMeans的直觉:为什么"找中心"这件事这么有效
2.1 用"选班长"来理解KMeans的核心逻辑
KMeans的思路其实特别朴素,朴素到你第一次听完会觉得"就这?"。它的核心假设是:每一堆数据都有一个中心点,这个中心点能代表整堆数据。聚类的目标就是找到这K个中心点,让每个样本都归属于离它最近的那个中心。
我习惯用"选班长"来类比。假设一个班有50个学生,老师想把他们分成5个小组,每组选一个班长。KMeans的做法是这样的:
- 先随机指定5个学生当临时班长(初始化中心点)。
- 每个学生看自己离哪个班长最近,就站到那个班长那边去(分配样本)。
- 每个小组重新选一个"平均位置"最靠中间的人当新班长(更新中心点)。
- 重复第2、3步,直到班长位置不再变化(收敛)。
这个过程用专业术语说就是"分配-更新"的迭代。数学上,KMeans在优化的目标函数是簇内平方误差和(WCSS/SSE),也就是每个样本到它所属中心点距离的平方和。这个值越小,说明同一簇内的样本越紧凑。
为什么用平方而不是绝对值?因为平方对距离远的点惩罚更重,而且求导方便——对中心点求导令其为零,解出来正好是簇内所有点的均值。这就是"KMeans"名字里"Means"(均值)的由来:中心点就是簇内样本的均值。
2.2 距离度量:欧氏距离不是唯一选择,但默认它最稳
KMeans默认用的是欧氏距离。对于连续型特征,这通常没问题。但你要注意,欧氏距离对特征的量纲极其敏感。举个例子,假设你在做用户分群,特征里有"年龄"(范围18-70)和"年收入"(范围3万-300万)。如果不做标准化,年收入的差异会完全主导距离计算,年龄这个维度几乎等于没用。
我一般会做Z-score标准化(减均值除标准差),让每个维度的均值为0、方差为1。这样各维度在距离计算里的权重就拉平了。当然,如果你业务上明确知道某个维度更重要,也可以手动加权,但那是进阶操作,新手先把标准化做对。
提示:KMeans对异常值非常敏感。因为中心点是均值,一个极端离群点能把整个簇的中心拽偏。如果数据里离群点多,要么先做异常值处理,要么考虑用KMedoids(用中位数代替均值,更鲁棒)。
2.3 K值怎么定:肘部法和轮廓系数,我用哪个
KMeans最大的痛点就是K值要你自己定。它不会告诉你数据里到底有几堆。我常用的两个方法是肘部法和轮廓系数。
肘部法的逻辑是:随着K增大,SSE必然下降(簇越多,每个点离中心越近)。但下降速度会在某个点突然变缓,形成一个"肘部"。这个拐点就是比较合适的K。实际操作时,我一般把K从2试到10,画一条SSE曲线,肉眼找拐点。
轮廓系数则是从另一个角度衡量聚类质量。对每个样本,计算它到同簇其他点的平均距离a,以及到最近的其他簇所有点的平均距离b,轮廓系数=(b-a)/max(a,b)。取值在-1到1之间,越接近1说明分得越开。把所有样本的轮廓系数平均,就得到整体评分。
我的经验是:两个方法结合看。肘部法给个大致范围,轮廓系数在候选K里挑最高的。但别迷信数值,最终还是要结合业务解释性。比如你分用户群,K=4可能轮廓系数最高,但业务上只认3个等级,那就用3。
| 方法 | 计算成本 | 优点 | 缺点 |
|---|---|---|---|
| 肘部法 | 低 | 直观,快速 | 拐点有时不明显 |
| 轮廓系数 | 较高(O(n²)) | 有量化评分 | 大数据集慢,偏向凸形簇 |
| Gap Statistic | 高 | 统计严谨 | 实现复杂,少用 |
2.4 KMeans的三个硬伤,你必须心里有数
用了这么多年KMeans,我总结它有三大硬伤,这也是GMM要出场的原因:
第一,它假设簇是球形的。KMeans用距离划分,隐含假设每个簇是各向同性的球形。如果真实簇是长条形、环形或者大小差异很大,KMeans会切得很糟糕。我见过一个数据集,两个簇一个特别大一个特别小,KMeans硬是把大簇劈成两半去凑K值。
第二,它是硬分配。每个样本只能属于一个簇,没有"概率"的概念。但现实中很多样本是模棱两可的,比如一个用户既像高价值客户又像潜力客户。KMeans直接一刀切,丢失了这种不确定性信息。
第三,它对初始化敏感。随机选初始中心,可能收敛到局部最优。经典解法是KMeans++:第一个中心随机选,后续每个中心以与已有中心距离平方成正比的概率选取,让初始中心尽量分散。sklearn的KMeans默认就用KMeans++,所以实际用的时候这个问题缓解了很多,但n_init参数(跑几次取最优)还是建议设大一点,比如10。
3. 从硬到软:GMM凭什么能补上KMeans的短板
3.1 换个世界观:数据不是"属于"某簇,而是"由某簇生成"
GMM(高斯混合模型)的思考方式和KMeans有本质区别。KMeans问的是"这个点离哪个中心最近",GMM问的是"这个点是由哪个高斯分布生成的"。
它的假设是:整个数据集是由K个高斯分布混合生成的。每个高斯分布有自己的均值(决定位置)和协方差矩阵(决定形状和方向)。每个高斯分布还有一个权重,表示它在混合里占多大比例。一个样本的生成过程是:先按权重选一个高斯分布,再从那个分布里采样出这个点。
这个视角的转变带来一个巨大的好处:协方差矩阵让每个簇可以是任意椭圆形状,而不只是球形。协方差矩阵的对角线是各维度的方差,非对角线是维度间的相关性。如果协方差矩阵是各向异性的,簇就能拉长、倾斜,完美贴合真实数据的形状。
3.2 软分配:一个样本可以"70%属于A,30%属于B"
GMM输出的是后验概率,也就是给定样本x,它属于第k个簇的概率。这叫"软分配"(soft assignment)。KMeans是硬分配,非0即1;GMM给的是连续概率,更细腻。
这个概率在实际业务里非常有用。比如做用户分层,一个用户有0.6的概率属于"高活跃"、0.4属于"中活跃",你可以根据这个概率做更精细的运营策略,而不是粗暴地归到一类。再比如异常检测,如果一个样本对所有簇的归属概率都很低,那它很可能就是异常点。
3.3 EM算法:GMM是怎么"猜"出参数的
GMM的参数估计用的是EM算法(期望最大化)。这个名字听起来唬人,但逻辑和KMeans的迭代很像,只是多了概率的维度。
EM算法分两步循环:
E步(期望):固定当前所有高斯分布的参数,计算每个样本属于每个簇的后验概率。这个概率可以理解为"责任"(responsibility)——第k个簇对生成样本x负多大责任。
M步(最大化):根据E步算出的责任,重新估计每个高斯分布的均值、协方差和权重。均值的更新是样本的加权平均,权重就是责任值;协方差也是加权计算;簇权重是该簇总责任除以样本总数。
这两步反复迭代,直到对数似然函数收敛。可以证明,每次迭代对数似然都不会下降,所以EM算法保证收敛到局部最优。
和KMeans对比一下:KMeans的E步是硬分配(最近的中心),M步是算均值;GMM的E步是软分配(后验概率),M步是加权算均值、协方差和权重。GMM相当于KMeans的"概率升级版"。
3.4 协方差类型:一个容易被忽略但影响巨大的选择
sklearn的GaussianMixture有个covariance_type参数,这个参数对结果影响极大,但很多人默认用full就不管了。它有四个选项:
- full:每个簇有自己完整的协方差矩阵,最灵活,参数最多,需要足够多的样本才能估准。
- tied:所有簇共享同一个协方差矩阵,适合簇形状相似但位置不同的情况。
- diag:每个簇的协方差是对角矩阵,即各维度独立,簇是轴对齐的椭圆。
- spherical:每个簇的协方差是标量乘以单位矩阵,簇是球形,这其实就退化成类似KMeans的假设了。
我的选择逻辑是:样本量充足(每簇至少几百个)且簇形状各异,用full;样本少或者维度高,用diag或tied防止过拟合;如果只是想要个比KMeans灵活一点的版本,spherical也行但意义不大。
注意:full协方差在高维数据下参数数量是O(d²),维度一高就容易过拟合,而且计算量大。我一般维度超过20就优先考虑diag。
4. 代码实战:从数据生成到两种聚类对比
4.1 造一批"故意刁难KMeans"的数据
光讲理论没感觉,我直接造一批数据来对比。这批数据我特意设计成三个簇:两个是拉长的椭圆,一个是球形,而且大小不一。这种数据KMeans会很难受,GMM应该能处理得更好。
import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import make_blobs from sklearn.cluster import KMeans from sklearn.mixture import GaussianMixture from sklearn.preprocessing import StandardScaler from sklearn.metrics import silhouette_score # 造三个簇,故意让形状不同 np.random.seed(42) n1, n2, n3 = 300, 200, 150 # 簇1:横向拉长的椭圆 c1 = np.random.randn(n1, 2) @ np.array([[3.0, 0.0], [0.0, 0.5]]) + [0, 0] # 簇2:纵向拉长的椭圆 c2 = np.random.randn(n2, 2) @ np.array([[0.5, 0.0], [0.0, 3.0]]) + [8, 8] # 簇3:球形,偏小 c3 = np.random.randn(n3, 2) @ np.array([[1.0, 0.0], [0.0, 1.0]]) + [0, 8] X = np.vstack([c1, c2, c3]) y_true = np.array([0]*n1 + [1]*n2 + [2]*n3) # 标准化 scaler = StandardScaler() X_scaled = scaler.fit_transform(X) print("数据形状:", X_scaled.shape)这段代码里我用矩阵乘法来生成不同形状的簇。np.random.randn生成标准正态分布,乘以一个对角矩阵就能控制各方向的方差。簇1横向方差大,簇2纵向方差大,簇3各向同性。这就是GMM的"主场"。
4.2 KMeans跑一遍:看它怎么"切歪"
# KMeans聚类 kmeans = KMeans(n_clusters=3, n_init=10, random_state=42) labels_km = kmeans.fit_predict(X_scaled) # 评估 sil_km = silhouette_score(X_scaled, labels_km) print(f"KMeans轮廓系数: {sil_km:.4f}") print(f"KMeans簇中心:\n{kmeans.cluster_centers_}")跑完你会发现,KMeans的轮廓系数大概在0.5左右,不算差但也不惊艳。问题出在可视化上——它很可能把两个拉长的簇切得乱七八糟,因为KMeans只能用圆形边界去分割,遇到椭圆就力不从心。
这里有个细节:n_init=10表示用不同的初始中心跑10次,取SSE最小的那次。这个参数在sklearn新版本里默认是10,但老版本是1,如果你用的是老版本,一定要手动设大。我踩过这个坑,同样的数据两次跑出来结果不一样,排查半天才发现是初始化的问题。
4.3 GMM接手:协方差矩阵开始发挥作用
# GMM聚类 gmm = GaussianMixture( n_components=3, covariance_type='full', # 关键:允许椭圆形状 n_init=10, random_state=42 ) labels_gmm = gmm.fit_predict(X_scaled) # 评估 sil_gmm = silhouette_score(X_scaled, labels_gmm) print(f"GMM轮廓系数: {sil_gmm:.4f}") print(f"GMM各簇权重: {gmm.weights_}") print(f"GMM各簇均值:\n{gmm.means_}")GMM的轮廓系数通常会比KMeans高一些,更重要的是,可视化时你会发现它把两个椭圆簇分得干净利落。covariance_type='full'让每个簇有自己的完整协方差矩阵,能捕捉到椭圆的方向和拉伸程度。
gmm.weights_输出的是每个簇的混合权重,加起来等于1。这个信息KMeans是没有的,它能告诉你哪个簇在数据里占比大。比如输出可能是[0.45, 0.30, 0.25],对应三个簇的样本比例。
4.4 软分配概率:GMM独有的信息
# 获取软分配概率 probs = gmm.predict_proba(X_scaled) print("前5个样本的归属概率:\n", probs[:5]) # 找出模棱两可的样本(最大概率低于0.6) max_probs = probs.max(axis=1) ambiguous = np.where(max_probs < 0.6)[0] print(f"模棱两可的样本数: {len(ambiguous)}")predict_proba返回一个n×k的矩阵,每行是一个样本属于各簇的概率。我经常用这个来找"边界样本"——那些最大概率不高的样本,往往是最需要人工复核或者特殊处理的。比如在用户分群里,这些就是"摇摆用户",运营策略上可以单独对待。
4.5 用BIC/AIC选簇数:比肘部法更统计的选择
GMM有个KMeans没有的优势:它可以用BIC(贝叶斯信息准则)或AIC(赤池信息准则)来选簇数。这两个指标都权衡了模型拟合度和复杂度,值越小越好。
bic_scores = [] aic_scores = [] k_range = range(1, 8) for k in k_range: gmm = GaussianMixture(n_components=k, covariance_type='full', n_init=10, random_state=42) gmm.fit(X_scaled) bic_scores.append(gmm.bic(X_scaled)) aic_scores.append(gmm.aic(X_scaled)) # 找BIC最小的K best_k_bic = k_range[np.argmin(bic_scores)] print(f"BIC推荐的簇数: {best_k_bic}")BIC的惩罚项比AIC更重,所以通常推荐更简洁的模型。我一般两个都看,如果一致就放心用,不一致就结合业务判断。这个方法比肘部法客观,因为它有明确的数学定义,不用肉眼找拐点。
5. 调参和踩坑:那些文档里不会写的经验
5.1 初始化陷阱:为什么你的GMM结果每次都不一样
GMM和KMeans一样对初始化敏感。sklearn的GaussianMixture默认用KMeans初始化,这比纯随机好很多。但即便如此,n_init参数还是很重要。我建议设成10以上,尤其是数据复杂的时候。
还有个坑是协方差矩阵可能奇异。如果某个簇的样本数少于维度数,协方差矩阵就不可逆,程序会报错或者给出垃圾结果。sklearn有个reg_covar参数(默认1e-6),给协方差对角线加一个小值防止奇异。如果数据维度高、簇又小,可以把这个值调大一点,比如1e-3。
5.2 标准化:不做这一步,后面全白搭
我再强调一遍标准化的重要性。KMeans和GMM都基于距离或概率密度,量纲不统一会直接毁掉结果。但标准化也有讲究:
- 如果数据有极端离群点,Z-score标准化会被拉偏,这时候可以考虑RobustScaler(用中位数和四分位距)。
- 如果特征本身就是比例(0-1之间),可以不标准化,但要看具体情况。
- 标准化要在训练集上fit,然后transform测试集,不能全量fit,否则有数据泄露。
我见过有人对每个簇单独标准化,这是错的。标准化是全局操作,聚类前做一次就行。
5.3 高维灾难:维度一高,距离就没意义了
当维度超过20-30维时,欧氏距离会失效——所有点之间的距离都差不多,这叫"维度灾难"。这时候KMeans基本废掉,GMM的full协方差也会过拟合。
我的应对策略是先降维再聚类。常用的是PCA,保留95%方差,通常能把维度压到10以内。或者用t-SNE/UMAP做可视化降维(但这两个不适合直接喂给聚类,因为会扭曲距离)。另一个思路是用余弦距离代替欧氏距离,对文本类高维稀疏数据更合适。
5.4 评估指标:没有标签怎么知道聚得好不好
没有真实标签时,评估聚类质量只能靠内部指标。我常用的有三个:
| 指标 | 取值范围 | 越大越好? | 适用场景 |
|---|---|---|---|
| 轮廓系数 | -1到1 | 是 | 凸形簇,通用 |
| Calinski-Harabasz | ≥0 | 是 | 速度快,适合大样本 |
| Davies-Bouldin | ≥0 | 否(越小越好) | 对球形簇敏感 |
轮廓系数最常用但计算慢(O(n²)),大数据集可以采样计算。Calinski-Harabasz基于簇间和簇内离散度之比,计算快。Davies-Bouldin衡量簇内散度和簇间距离的比值,越小越好。
我的做法是:先用Calinski-Harabasz快速筛K,再用轮廓系数在候选里精挑。但记住,这些指标都偏向凸形簇,如果真实簇是环形或流形,它们会给出误导性结果。最终还是要可视化+业务判断。
5.5 业务解释性:聚类结果不能只看得分
这是我最想强调的一点。聚类是探索性工具,不是终点。得分再高,如果分出来的簇没法用业务语言解释,那这个聚类就是失败的。
我一般会做簇画像:对每个簇,计算各特征的均值/中位数,看看这个簇有什么特点。比如用户分群,簇1可能是"高消费低频次",簇2是"低消费高频次",簇3是"中等消费中等频次"。有了这些画像,才能给运营团队提建议。
如果某个簇的画像很模糊,说不清它和别的簇有什么区别,那要么是K选多了,要么是特征选得不对。这时候要回到特征工程,而不是硬调模型参数。
6. 到底选KMeans还是GMM:一张决策表说清楚
6.1 对比维度全解析
| 维度 | KMeans | GMM |
|---|---|---|
| 簇形状假设 | 球形 | 任意椭圆(取决于协方差类型) |
| 分配方式 | 硬分配 | 软分配(概率) |
| 参数数量 | 少(只有中心) | 多(均值+协方差+权重) |
| 计算速度 | 快 | 慢(尤其full协方差) |
| 对样本量要求 | 低 | 高(估协方差需要足够样本) |
| 异常值敏感度 | 高 | 中(概率框架下稍好) |
| 簇数选择 | 肘部法/轮廓系数 | BIC/AIC |
| 可解释性 | 高(中心点直观) | 中(协方差难解释) |
| 收敛保证 | 局部最优 | 局部最优(EM) |
6.2 我的选择逻辑
实际项目里我按这个顺序决策:
- 先跑KMeans。它快、简单、结果好解释。如果轮廓系数不错且业务能解释,就用它,别过度设计。
- KMeans效果差再上GMM。如果数据明显是椭圆簇、或者需要软分配概率、或者想用BIC客观选K,就换GMM。
- 样本量是硬约束。如果每个簇只有几十个样本,别用full协方差的GMM,参数估不准。这时候要么用diag,要么退回KMeans。
- 维度高先降维。不管用哪个,维度超过20都先PCA。
还有个折中方案:用GMM做初始化,再跑KMeans。或者反过来,用KMeans的结果作为GMM的初始均值。sklearn的GMM默认就是这么干的,效果通常比纯随机好。
6.3 一个容易忽略的点:聚类数量的一致性
有时候你会发现,KMeans和GMM给出的最优K不一样。这很正常,因为它们的目标函数不同。KMeans优化SSE,GMM优化似然。这时候不要纠结哪个"对",而是看哪个K的业务解释更好。
我遇到过一次,KMeans说K=4,GMM的BIC说K=6。可视化后发现,GMM多分出来的两个簇其实是同一个大簇里的两个子区域,业务上没必要分开。最后用了K=4。所以统计指标是参考,业务需求是拍板。
7. 进阶方向:聚类之后还能做什么
7.1 用聚类结果做特征工程
聚类不只是终点,还可以是中间步骤。把簇标签作为一个新特征,喂给下游的有监督模型,有时候能提升效果。比如在推荐系统里,先对用户聚类,把簇ID作为特征,再训练点击率预测模型。这个技巧叫聚类特征编码。
但要注意,聚类是在全量数据上做的,如果下游模型有训练/测试划分,聚类也要在训练集上fit然后transform测试集,否则泄露。
7.2 层次聚类和DBSCAN:另外两条路
KMeans和GMM都是"划分式"聚类,需要预先指定K。如果K完全未知,或者簇形状极不规则,可以考虑:
- DBSCAN:基于密度,能发现任意形状的簇,还能识别噪声点。缺点是对参数eps和min_samples敏感,且高维效果差。
- 层次聚类:生成一棵聚类树,可以通过树状图直观选K。缺点是计算复杂度O(n³)或O(n²),大数据集跑不动。
我一般把DBSCAN作为异常检测的补充工具,层次聚类用于小数据集的探索性分析。
7.3 半监督聚类:用少量标签引导
如果手头有少量标签(比如100个样本标了类别),可以用半监督聚类,比如约束KMeans(must-link和cannot-link约束)。这在实际项目里很实用,因为标全部数据太贵,标一点点还是可行的。sklearn没有直接支持,但可以用第三方库或者自己实现约束逻辑。
7.4 聚类稳定性检验:别被一次结果骗了
最后分享一个我常用的技巧:稳定性检验。用不同的随机种子跑多次聚类,看结果是否一致。如果每次分出来的簇差异很大,说明数据本身没有清晰的簇结构,或者K选得不对。
具体做法是跑10次,每次记录标签,然后计算任意两次之间的调整兰德指数(ARI)。如果平均ARI高于0.8,说明聚类很稳定;低于0.5就要警惕了。这个检验能帮你判断聚类结果到底可不可信,比单看轮廓系数更靠谱。
from sklearn.metrics import adjusted_rand_score labels_list = [] for seed in range(10): gmm = GaussianMixture(n_components=3, covariance_type='full', random_state=seed) labels_list.append(gmm.fit_predict(X_scaled)) # 计算两两ARI aris = [] for i in range(len(labels_list)): for j in range(i+1, len(labels_list)): aris.append(adjusted_rand_score(labels_list[i], labels_list[j])) print(f"平均ARI: {np.mean(aris):.4f}")这个检验我几乎每个聚类项目都会跑一遍。有一次数据看起来分得挺好,轮廓系数0.6,但稳定性检验ARI只有0.4,后来发现是K选大了,减到3之后ARI升到0.85,业务解释也清晰了。所以别偷懒,多跑一步能省很多返工。
聚类这件事,说到底是在没有标准答案的情况下找结构。KMeans和GMM是两把最常用的刀,各有各的锋利之处。我的建议是先把KMeans用熟,理解它的边界,再根据数据特点决定要不要上GMM。工具没有高低,合适最重要。