☰
高斯混合模型 GMM 原理与 EM 算法:从软聚类到工程调参
2026/10/14 1:33:17 网站建设 项目流程

做聚类分析的时候,最开始我觉得 KMeans 挺顺手——给数直接跑,两三行代码就出结果。后来数据一换就露馅了:有两簇数据长得像橄榄球,沿一条对角线拉得老长,KMeans 硬生生把中间劈成两半;还有的样本明明落在两个簇的交界处,非黑即白的硬分配怎么都有点勉强。这时候,高斯混合模型(Gaussian Mixture Model,GMM)就该上场了。

GMM 本质上是用若干个高斯分布叠加去拟合整体数据分布,训练之后每个样本拿到的不是“我属于第几类”的一个标签,而是“我属于每个簇的概率分别是多少”。它在聚类、密度估计、异常检测、分布建模这些方向都很常见。这篇文章适合刚入门机器学习、想搞明白 GMM 原理和 EM 算法的读者,也适合已经在用 sklearn 聚类、但想知道参数背后该怎么选的工程朋友。

1. 从 K-Means 的局限说起:为什么研究聚类的人绕不开 GMM

1.1 硬聚类与软分配的本质差异

K-Means 的理论假设其实很朴素——簇是各向同性的圆形。它用欧氏距离找最近质心,样本只能归属到一个簇,这种非此即彼的分配方式对边界地带的数据非常粗暴。比如两个分布看起来像两团靠得比较近的正态云,交界处样本到底属于哪边?K-Means 一定会做出选择,但 GMM 会说“你左边 60%,右边 40%”。

这种软分配不是花架子。在用户分群、异常样本识别、图像分割等场景,概率输出可以直接反哺后续决策:拿后验概率做风险加权,比拿一个硬标签做二选一稳定得多。而且 GMM 是一个生成式模型,它不只是给出划分,还还原了一整套“数据是怎么产生的”的概率解释。你可以从模型里采样出新样本,也可以回答“某个区域的数据密度有多高”这类密度问题,这是 K-Means 完全做不到的。

1.2 GMM 的数学形态:三组参数说了算

GMM 假设数据集的产生过程是:先按每个簇的权重 π_k 挑一个簇 k,然后从该簇对应的高斯分布 N(μ_k, Σ_k) 里采样一个点。因此整个模型的密度函数是:

p(x) = Σ_k π_k · N(x | μ_k, Σ_k)

其中 π_k 是第 k 个簇的混合系数,所有 π_k 加起来等于 1;μ_k 是簇中心;Σ_k 是协方差矩阵,描述这个簇的形状与方向。这里的协方差矩阵特别值得注意,它决定了簇可以是扁的、斜的、椭圆的,不像 K-Means 隐含的球形假设。正因为有 Σ_k,GMM 能描述的数据形状比 KMeans 精细得多。

参数随成分数 K 增长很快:全协方差时每个簇需要估计 d 个均值参数加上 d×(d+1)/2 个协方差参数,所以高维数据用 GMM 要特别小心,样本量不够很容易把协方差估计成奇异矩阵。这个坑我后面专门用一章来讲。

2. EM 算法拆解:GMM 训练过程里那两步迭代在干什么

2.1 从最大似然到隐变量:为什么 EM 是唯一解

如果用最大似然估计直接去找参数,我们的目标是最大化:

ln L = Σ_i ln( Σ_k π_k N(x_i | μ_k, Σ_k) )

麻烦就出在求和符号在对数里面。对 μ_k 求导时,分母是整个混合密度,分子只与其中一个成分相关,解析解根本拆不出来。这不是数学不够努力,而是这个目标函数本身对参数非凸,没有闭式解。EM(Expectation-Maximization)的思路是:引入隐变量 z_i 表示样本 i 来自哪个簇。

完整数据的似然是 Π_i π_{z_i} N(x_i | μ_{z_i}, Σ_{z_i}),取对数之后求和符号跑到对数外面,求导立刻简单很多——可问题是 z_i 也是未知量。所以 EM 做的是“先猜后修”:给参数一个初值,猜每个样本来自各簇的后验概率,再用这批概率去更新参数,如此反复,直到参数和似然都不再明显变化。

2.2 E 步:算每个样本属于每个簇的后验概率

E 步做的事,是用当前参数算每个样本 i 属于第 k 个簇的后验概率:

γ(z_ik) = π_k N(x_i | μ_k, Σ_k) / Σ_j π_j N(x_i | μ_j, Σ_j)

这个式子其实就是贝叶斯公式:分子是“选中第 k 簇的先验概率 × 在该簇下出现样本 x 的概率”,分母是归一化。注意 π_k 在分子里,不是无关紧要的:当各簇数量差距大时,后验概率会自然偏向先验权重大的簇,这比盲目按距离分簇更贴近真实场景。举例来说,如果一簇占总样本量的 80%,即使某个样本几何上离它稍远一点,模型也会倾向于认为它更可能来自这个大局域簇。

2.3 M 步:参数更新公式为什么长这样

有了每个样本对每个簇的后验贡献 γ,M 步的更新公式就跟加权统计量一样自然:

π_k_new = (1/N) Σ_i γ(z_ik)

μ_k_new = (Σ_i γ(z_ik) x_i) / Σ_i γ(z_ik)

Σ_k_new = (Σ_i γ(z_ik) (x_i - μ_k_new)(x_i - μ_k_new)ᵀ) / Σ_i γ(z_ik)

权重是后验贡献的平均,均值是后验加权的中心,协方差是后验加权的散布矩阵。跟 KMeans 里“把簇内点全平均”相比,这里每个样本对每个簇都有贡献,只是贡献大小不一样。E 步和 M 步交替迭代,每做一轮对数似然只会增大不会变小,这是 EM 的收敛保证,也是你判断自己实现有没有写对的关键信号。收敛判据一般看对数似然增量小于 tol,或者到 max_iter 强制停止。

3. 用 NumPy 从零手写 GMM:核心代码与结果验证

3.1 初始化策略:别天真地随机撒点

GMM 对初值很敏感。均匀随机初始化均值,很可能落到同一个簇里,EM 反复迭代也跳不出去,最后得到无意义的中间成分。工程上最常用的兜底解法是先用 KMeans 跑一遍,拿 KMeans 的簇中心做 GMM 的 μ 初值,协方差直接取各簇样本的协方差矩阵,权重取簇大小占比。这一招看似简单,却能大幅降低最后收敛到坏局部最优的概率。下面的手写版本为了简洁用了随机选点初始化,实际项目里建议换成 KMeans 初始化。

3.2 核心代码实现

我用 NumPy 写了一个最简版 GMM,包含 E 步、M 步、对数似然计算和迭代训练,代码可以直接粘到 Jupyter 里跑:

import numpy as np def gaussian_pdf(x, mu, sigma): d = x.shape[-1] sigma = sigma + 1e-6 * np.eye(d) diff = x - mu inv = np.linalg.inv(sigma) norm_const = 1.0 / np.sqrt((2 * np.pi) ** d * np.linalg.det(sigma) + 1e-12) return norm_const * np.exp(-0.5 * np.einsum('ni,ij,nj->n', diff, inv, diff)) class GMM: def __init__(self, n_components=3, max_iter=200, tol=1e-4): self.K = n_components self.max_iter = max_iter self.tol = tol self.pi = None self.mu = None self.sigma = None self.log_likelihoods = [] def _initialize(self, X): n, d = X.shape idx = np.random.choice(n, self.K, replace=False) self.mu = X[idx].astype(float) self.pi = np.full(self.K, 1.0 / self.K) self.sigma = np.array([np.cov(X.T) + 1e-4 * np.eye(d) for _ in range(self.K)]) def _e_step(self, X): n, d = X.shape gamma = np.zeros((n, self.K)) for k in range(self.K): gamma[:, k] = self.pi[k] * gaussian_pdf(X, self.mu[k], self.sigma[k]) gamma /= gamma.sum(axis=1, keepdims=True) return gamma def _m_step(self, X, gamma): n, d = X.shape Nk = gamma.sum(axis=0) self.pi = Nk / n self.mu = (gamma.T @ X) / Nk[:, None] self.sigma = np.zeros((self.K, d, d)) for k in range(self.K): diff = X - self.mu[k] self.sigma[k] = (gamma[:, k][:, None] * diff).T @ diff / Nk[k] self.sigma[k] += 1e-6 * np.eye(d) def _log_likelihood(self, X): n = X.shape[0] ll = np.zeros((n, self.K)) for k in range(self.K): ll[:, k] = self.pi[k] * gaussian_pdf(X, self.mu[k], self.sigma[k]) return np.log(ll.sum(axis=1)).sum() def fit(self, X): self._initialize(X) for _ in range(self.max_iter): gamma = self._e_step(X) self._m_step(X, gamma) ll = self._log_likelihood(X) self.log_likelihoods.append(ll) if len(self.log_likelihoods) > 1 and \ abs(ll - self.log_likelihoods[-2]) < self.tol: break return self def predict_proba(self, X): return self._e_step(X) def predict(self, X): return self._e_step(X).argmax(axis=1)

几个实现细节想提醒一下:高斯密度函数里我加了 1e-6 的对角正则,这是防止协方差矩阵奇异导致求逆崩溃;einsum 那行等价于对所有样本算二次型 (x-μ)ᵀ Σ⁻¹ (x-μ),向量化写法比 for 循环快一个量级;M 步更新协方差时用 (x-μ)(x-μ)ᵀ 的加权外积,本质上是在估计二阶矩。

3.3 用模拟数据验证手写版

造一组三簇二维高斯数据来做验证:

from sklearn.datasets import make_blobs X, y_true = make_blobs(n_samples=600, centers=3, cluster_std=1.2, random_state=42) model = GMM(n_components=3, max_iter=200) model.fit(X) print(model.predict_proba(X[:5]))

运行结果里前 5 个样本的后验概率大概是下面这种感觉:每个样本的主导簇概率基本都超过 0.9,另外两簇概率只有零点零几。同数据用 sklearn 的 GaussianMixture 跑一遍,聚类一致率通常在 95% 以上,对数似然数值也同数量级。这至少说明手写版的迭代逻辑是对的,没有把 E 步和 M 步写反。

从零手写一遍 GMM 最大的收获不是把代码敲出来,而是理解参数之间怎么互相牵制:协方差变大后验概率会变扁,权重更新会反过来影响下一轮 E 步的分子,这些动态关系光看公式很难体会,亲手迭代几轮就清楚了。

4. 工程落地指南:sklearn 中的 GMM 调参与模型选择

4.1 covariance_type 四种约束的选择逻辑

sklearn 的 GaussianMixture 提供了四种协方差约束,选哪一个直接决定模型复杂度和拟合能力:

covariance_type含义参数数量适用场景
full每个簇独立完整协方差K×(d + d×(d+1)/2)簇形状方向差异大,样本量充足
tied所有簇共享协方差d×(d+1)/2 + K×d簇形状接近,样本量紧张
diag对角协方差K×(d + d)高维数据,特征间相关性不强
spherical各向同性圆球K×(d + 1)低维数据,快速粗略聚类

很多新手上来就选 full,总觉得最灵活一定最好。实际不是。高维场景下 full 的参数数量随 d 平方增长,600 个样本撑不住 50 维数据的全协方差估计;如果各簇形状差异不大,tied 更稳;如果特征之间本身没太多相关性,diag 性价比最高。选型核心是看样本量 vs 参数量的比例,我一般会先算一笔账:全协方差需要 K×(d+d×(d+1)/2) 个参数,样本量至少是参数量的 5 到 10 倍才安心。

4.2 聚类数怎么定:BIC/AIC 实操

GMM 不像 KMeans 那样靠肘部法则选 K,但信息准则天然适配概率模型。在 sklearn 里可以直接调用:

from sklearn.mixture import GaussianMixture best_k = None best_bic = np.inf for k in range(1, 9): gm = GaussianMixture(n_components=k, covariance_type='full', random_state=42) gm.fit(X) bic = gm.bic(X) if bic < best_bic: best_bic = bic best_k = k

BIC 越低代表模型在拟合能力和复杂度之间平衡得越好。不过实践中别只盯数值最小值,更值得看 BIC 曲线的“肘”:通常下降变缓的那个 K 更可解释。有时候 BIC 更低的 K 会分出一个小得离谱的碎簇,业务上毫无意义,这时候我会宁可取次优 K。AIC 同理,只是惩罚项更轻,适合偏重拟合精度的场景。

4.3 初始化、reg_covar 与收敛控制

工程调参上这几个参数我建议重点看:

  • random_state:固定下来保证结果可复现,跨团队协作时尤其重要。
  • n_init:多次初始化取最优,默认 1,不确定模型稳定性时调到 5 到 10。
  • init_params:默认kmeans,稳;能试random做对照。
  • reg_covar:协方差对角线加的正则项,默认 1e-6。数据量小或维度偏高时,我经常调到 1e-4 到 1e-2,能有效防止协方差奇异。
  • max_iter和tol:迭代次数上限和似然增量阈值。跑完后看一眼gm.lower_bound_,如果最后一次迭代和前面差得远,说明还没收敛,需要放宽tol。

5. 目录中不会写的坑:协方差奇异、成分崩坏与模型可信度判据

5.1 协方差奇异:训练警报是最常见的拦路虎

症状很明确:训练时报警Singular covariance matrix,或者predict_proba输出极端值。原因一般不出这三种:某个簇只有一两个样本,协方差矩阵秩亏;特征里有常量列或高度共线性的列;数据维度远大于样本量。解决顺序我建议先做数据体检——去掉常量列、处理强相关特征,然后增大reg_covar,减少成分数,或者干脆退一步用 diag 或 tied 限制自由度。

5.2 成分崩坏:训练完出现权重趋零的幽灵簇

训练结束后检查gm.weights_,如果有个成分的权重接近 1e-6,或者某个mu孤零零地贴在一个极端样本上,说明模型发生了成分崩坏。本质原因是 EM 发现“用一个无限尖的高斯去包裹单独一个样本”能让似然暴涨,这是典型的过拟合表现。解法就是前面说的reg_covar正则 + 多次初始化,并在训练后剔除权重低于 1% 的成分。任何模型训练完不检查权重和中心分布,直接拿结果说事,都是要出问题的。

5.3 高维陷阱:50 维以上 GMM 就很吃力

GMM 的理论很漂亮,但它在高维下的表现往往被高估。全协方差矩阵参数爆炸,EM 每个 E 步要算 K 次 d 维高斯密度,复杂度 O(N K d²),50 维数据跑到后期非常酸爽。更麻烦的是高维空间数据稀疏,协方差估计极不稳定。实际项目里我一般先 PCA 降维到 10 到 30 维再用 GMM,或者评估一下是不是改用密度聚类、谱聚类更合适。如果数据分布本身严重非高斯——多峰、长尾、离散特征太多——GMM 贴合的只是“形式上的高斯混合”,结果未必有业务意义。

5.4 拿什么判断模型学得好不好:看置信度分布

一个非常实用的诊断信号是看predict_proba输出最大值的分布。如果绝大多数样本的最大后验概率都集中在 0.99 以上,说明簇和簇分得很开,模型信心很足;如果大量样本连 0.6 都摸不到,说明数据本身重叠严重,或者 K 取得过大。我在项目里会把低置信度样本单独拎出来做二次诊断,经常会发现一批“模型自己都拿不准”的边缘样本,它们往往就是业务上最容易流失或者最值得关注的人群。

5.5 离群点与异常检测玩法:GMM 的另一面

GMM 天然可以做异常检测:score_samples返回每个样本的对数密度,低密度区域的样本就是异常候选。做法是训练好模型后,算全量样本得分,取 5% 分位数做阈值。但注意 GMM 会把“一个只有几个样本的浓密小簇”也当成正常区域,离群点如果凑成了一小撮,也会被模型吃掉。所以做异常检测前最好先画一下分数分布,正常情况下应该是单峰平滑的,如果出现明显的第二峰,说明模型可能把异常簇学成了正常簇。

我在实际项目里最常用 GMM 的不是聚类,而是拿它当软标签生成器:先扫一遍 K,用 BIC 选个大致范围,再输出后验概率给下游模型当特征。这个玩法在用户分群、流量异常识别上都比较稳。最后说一句个人体会:GMM 不是“丢进去就能用”的模型,每次用它之前都要问自己一句,数据真的像几个高斯簇生成的吗?如果答案是否定的,它给出来的那条漂亮拟合曲线,可能只是自欺欺人。

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

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

立即咨询