news 2026/10/16 1:11:46

高斯混合模型(GMM)异常检测原理与Python实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
高斯混合模型(GMM)异常检测原理与Python实现

异常检测(Anomaly Detection)是机器学习里一个比较特殊的任务:大部分模型在努力学会“正常长什么样”,而它恰恰相反,专门盯住那些跟正常不太一样的东西。高斯混合模型(GMM)是异常检测中基于密度估计的经典做法——它不直接给样本打标签,而是先拟合正常数据服从的概率分布,再把落在低概率区域的样本判成异常。这篇文章适合两类人:一是刚接触异常检测、想理解GMM为什么能被用来做这件事的初学者;二是已经用过孤立森林或One-Class SVM,想换一种带概率解释、业务上更可控的思路来落地的开发。我会把GMM的原理、完整Python实现、参数调优方法以及实际踩过的坑一起讲清楚,代码可以直接拿走改。

1. 先理解异常检测这件事本身

1.1 异常检测到底在解决什么问题

异常检测的核心假设非常简单:正常样本占绝大多数,且它们之间存在某种可学习的共性;异常样本少量、分散、形态各异,很难直接总结出规律。所以很多异常检测算法的思路并不是“学习异常长什么样”,而是“学习正常的边界在哪”,凡是越过边界的都算异常。

这个边界在不同场景里有不同定义。在服务器监控场景,边界可能是CPU使用率的一个合理波动范围;在信用卡反欺诈场景,边界可能是某个人历史消费习惯的合理区间;在工业质检场景,边界可能是产品尺寸、颜色、纹理的合格区间。GMM所做的,是把“边界”这个词变成“概率密度”——正常区域是密度高的地方,异常区域是密度极低的地方。

这里有个容易混淆的点:GMM不是分类模型,它属于无监督学习中的密度估计方法。分类模型需要事先标注好的正负样本,而GMM不需要标签,只要给一堆正常数据,它就能把分布结构学出来。这一点在异常检测里非常实用,因为很多实际场景中,“异常样本”根本收集不到,或者异常类型多到根本没法全部标注。

1.2 为什么选GMM而不是孤立森林或者单高斯

很多人在做异常检测时第一反应是孤立森林,因为它快、对高维数据友好、使用门槛低。但孤立森林有它的问题:它输出的异常分数是基于“孤立难度”的,缺乏概率解释,业务评审时很难回答“为什么这个样本异常”这样的问题。GMM则不同,它输出了每个样本的密度估计值,你可以明确地告诉业务方:这个样本在当前正常行为模型下出现的概率极低,所以被判为异常。

那为什么不用最简单的单高斯模型?因为绝大多数真实数据根本不是单峰的。比如一个电商平台的用户访问行为,白天和深夜完全是两种分布,单高斯会把两个峰硬揉成一个圆,导致大量误报。GMM天生就是来对付多峰分布的,它用多个高斯成分的组合去逼近任意复杂的概率密度,既保留了高斯分布的数学可解释性,又有了拟合复杂分布的能力。

从落地角度来说,GMM还有一个好处:训练完毕之后,模型可以输出三个很有用的量——每个样本的密度值(用于异常打分)、每个样本属于哪个成分(用于初步定位异常模式)、每个成分的参数(用于理解正常行为的子类型)。这三样东西在异常分析报告里非常有用,是很多纯树模型给不了的。

2. 高斯混合模型的原理,拆开揉碎讲

2.1 多个高斯分布怎么拼成一个混合模型

高斯混合模型的数学形式不太复杂,它假设数据是由若干个高斯分布按一定比例混合生成的。假设有K个成分,那么每个样本x的概率密度是:

p(x) = π1·N(x|μ1, Σ1) + π2·N(x|μ2, Σ2) + ... + πK·N(x|μK, ΣK)

其中每个N(x|μk, Σk)是一个高斯分布,有自己的均值向量μk和协方差矩阵Σk;πk是第k个成分的权重,表示“所有数据中有多大比例来自这个成分”,所有权重加起来等于1。

用生活化的方式理解:假设你在一所学校里统计所有学生的身高,如果把所有学生混在一起画直方图,你可能看到一个不太规则的山峰群。但如果你知道这里有男生和女生两个群体,你就能理解这个分布其实是由两个不同的高斯分布混合而成的——男生的身高围绕一个均值波动,女生的身高围绕另一个均值波动。GMM要做的,就是在你完全不知道哪些样本属于哪个群体的情况下,把这几个隐藏的分布结构猜出来。

这里的“成分数K”是唯一需要人为指定的关键参数。K具体取多少,怎么选,后面我会专门展开讲。现在先记住一个原则:K既不是越大越好也不是越小越好,它应该对应数据中真实的“潜在分组数”。

2.2 EM算法在训练中到底做了什么

训练GMM时最大的难题是:我们不知道每个样本来自哪个成分。如果知道,直接分组后分别算均值和方差就行;可问题就是不知道。如果我们知道了每个成分的参数,又反过来容易推断样本属于哪个成分。这形成了一个互相依赖的死结。

EM算法(期望最大化算法)解决这个死结的方式非常聪明:它先用随机初始化给定一组粗略的参数,然后在这组参数下“猜”每个样本属于各成分的概率,再基于这些猜测重新估计参数,然后再猜、再估计,反复迭代到收敛。

每次迭代分两步:

  • E步(期望步):固定当前参数,计算每个样本属于每个成分的后验概率,术语叫“责任度”。这步相当于在说:“按现在的分布理解,这个样本有70%可能来自成分1、30%可能来自成分2。”
  • M步(最大化步):用这些责任度作为软权重,重新计算每个成分的均值、协方差和混合权重。责任度高的样本对参数估计贡献大,责任度低的样本贡献小。

迭代若干轮后,参数不再明显变化,模型就收敛了。EM算法有一个很实用的性质:每一轮迭代都保证对数似然不下降,所以你可以把对数似然当作训练过程中的监控指标,直观地看到模型在逐步变好。

2.3 从密度估计到异常打分,就差一个负号

训练好GMM之后,每个样本都能算出一个p(x)值,表示它在这个正常模型下出现的概率密度。但这个值直接使用时有两个问题:第一,密度值通常极小,动辄就是10的负几十次方,不直观;第二,在多个维度下密度值之间的差异会被放大,不好比较。

实际做法是取对数,得到所谓的“对数似然”log p(x)。对数似然是个负数,因为概率密度通常小于1。然后我们再取一个负号,得到“负对数似然”(Negative Log Likelihood,简写NLL),这个值越大表示样本越不可能出现,正好符合“异常分数”的直觉——分数越高,越异常。

在代码里,这个过程就是调用score_samples拿到对数似然,再取负号。举个直观的例子:如果正常样本的负对数似然集中在10到15之间,某个样本算出来是80,那基本可以断定它是异常点。

3. 实操:用Python跑通一个完整的GMM异常检测

3.1 构造一批带异常点的实验数据

为了把原理落到代码上,我们先构造一批可控的二维数据。用两个不同的高斯簇模拟正常数据,再手动撒一些离群点模拟异常,这样我们心里有标准答案,可以验证模型效果。

import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.mixture import GaussianMixture from sklearn.preprocessing import StandardScaler from sklearn.metrics import classification_report, roc_auc_score np.random.seed(42) # 正常数据:两个高斯簇 cluster1 = np.random.normal(loc=[0, 0], scale=0.5, size=(400, 2)) cluster2 = np.random.normal(loc=[5, 5], scale=0.8, size=(600, 2)) normal_data = np.vstack([cluster1, cluster2]) # 异常数据:散落在远处的点 outliers = np.array([ [3.0, 0.2], [0.5, 4.5], [6.0, 1.8], [-1.5, 3.5], [2.8, 6.2], [7.5, 4.2], [-2.0, 0.5], [0.8, 7.0] ]) data = np.vstack([normal_data, outliers]) labels = np.array([0] * len(normal_data) + [1] * len(outliers))

这里设计了两簇正常数据:一簇在(0,0)附近,一簇在(5,5)附近,异常点被故意放在两簇的“缝隙”和“外围”。这样的构造贴近真实场景——异常点不一定离所有正常点都很远,但一定落在正常密度比较低的区域。

3.2 训练前的标准化处理不能省

GMM对特征的尺度非常敏感。假设第一个特征的取值在0到100之间,第二个特征的取值在0到1之间,那么协方差矩阵会主要由第一个特征主导,距离计算和密度估计都会被带偏。所以做GMM之前,标准化是必须的一步。

scaler = StandardScaler() data_scaled = scaler.fit_transform(data)

注意这里的细节:fit_transform是在全部数据上做的。如果这是一条在线流程,标准化的均值和标准差也应该只从训练数据(正常样本)里学,然后用同一组参数去变换新来的样本。道理跟其他模型一样——不能让测试数据的信息提前泄露到预处理里。

3.3 训练GMM并给样本打异常分

参数方面,先直接采用一个比较稳的组合:成分数K=2,协方差类型full,随机种子固定以保证可复现。K=2对应我们构造时的两个正常簇,full意味着每个簇可以有不同的形状和方向,最灵活。

# 只拿正常数据训练 scaler = StandardScaler() normal_scaled = scaler.fit_transform(normal_data) gmm = GaussianMixture(n_components=2, covariance_type='full', random_state=42) gmm.fit(normal_scaled) # 对全部数据打分 data_scaled = scaler.transform(data) log_likelihood = gmm.score_samples(data_scaled) anomaly_score = -log_likelihood

拿到每个样本的异常分数后,把结果都拼凑到DataFrame里,方便观察和画图:

df = pd.DataFrame({ 'x': data[:, 0], 'y': data[:, 1], 'label': labels, 'anomaly_score': anomaly_score }) print(df.head(10))

3.4 阈值怎么定?用百分位数还是验证集搜索

异常分数算出来只是第一步,真正在业务中要回答的问题是:分数多高才算异常?两种常用做法:

第一种是百分位数法。模型在正常数据上训练完成后,把训练集本身的异常分数分布取出来,设定一个分位点,比如95%分位数。意思是:如果某个样本的异常分数高过了95%的正常样本,就把它视为异常。这个方法的优点是简单、无需标签,缺点是95%这个数字是拍脑袋的,不一定贴合业务容忍度。

第二种是验证集搜索法。如果手里有一部分已标注的数据(哪怕是几十条),就可以在验证集上遍历不同阈值,找F1分数最高的点。这个方法更可靠,但要求有标注。现实里往往是两种结合:先用百分位数法给出一个初始阈值,再用少量历史标注样本微调。

这里用一个公示性质的固定阈值来演示,实际操作时请用训练集分布来定:

threshold = np.percentile(anomaly_score[:len(normal_data)], 95) df['pred'] = (df['anomaly_score'] > threshold).astype(int) print(classification_report(df['label'], df['pred'])) print('ROC-AUC:', roc_auc_score(df['label'], df['anomaly_score']))

跑出来的结果里可以关注几个指标:ROC-AUC衡量整体排序能力,理想情况下会接近1;精确率和召回率则取决于阈值高低。如果召回率偏低,说明阈值定太高了;如果精确率偏低,说明阈值定太低,把正常样本误伤了。在异常检测里,误报和漏报的代价往往不同,具体调阈值时要把业务代价考虑进去,不能只盯着F1。

3.5 可视化:把密度边界和异常点画出来

GMM是一个概率模型,最大的好处之一就是可以把决策边界画出来。我来写一段可视化代码,看模型学到的高斯区域和异常点之间的关系:

x_min, x_max = data_scaled[:, 0].min() - 1, data_scaled[:, 0].max() + 1 y_min, y_max = data_scaled[:, 1].min() - 1, data_scaled[:, 1].max() + 1 xx, yy = np.meshgrid(np.linspace(x_min, x_max, 200), np.linspace(y_min, y_max, 200)) zz = -gmm.score_samples(np.c_[xx.ravel(), yy.ravel()]) zz = zz.reshape(xx.shape) plt.figure(figsize=(8, 6)) plt.contourf(xx, yy, zz, levels=50, cmap='viridis') plt.scatter(data_scaled[:, 0], data_scaled[:, 1], c=df['label'], cmap='coolwarm', s=20, edgecolor='white') plt.colorbar(label='Anomaly Score') plt.title('GMM Anomaly Scoring Heatmap') plt.show()

画出来之后你会非常直观地看到:两个正常簇的中心区域颜色是暗的(分数低),越往外颜色越亮(分数高),那些被构造出来的异常点恰好落在亮色区域。这就在视觉上验证了GMM异常检测的核心逻辑——正常区域内低分,边界外高分。

4. 参数调优与踩坑实录

4.1 成分数K到底怎么定

前面说了K需要人为指定,这里说具体怎么选。最常用的方法是BIC(贝叶斯信息准则),它本质上是在拟合能力和模型复杂度之间找平衡。K越大,模型拟合能力越强,但参数越多、越容易过拟合,BIC会相应惩罚。遍历K从1到10,画一条BIC曲线,通常取下降趋势放缓或出现“拐点”的位置。

bics = [] ks = range(1, 11) for k in ks: gm = GaussianMixture(n_components=k, covariance_type='full', random_state=42) gm.fit(normal_scaled) bics.append(gm.bic(normal_scaled)) plt.plot(ks, bics, marker='o') plt.xlabel('K') plt.ylabel('BIC') plt.show()

在实际项目中,我会把BIC当成一个参考而非金标准。因为BIC选出来的最优K有时在业务上没法解释——比如选了K=7,你很难跟业务方说清楚这7个成分分别代表什么。我的习惯是:先看BIC的拐点,初步确定候选范围,然后结合业务场景判断“正常行为大致可以分成几类”,最终确定K。如果数据本身就是按时间生产出来的,甚至可以按业务经验分成几个时段簇,再去看BIC是否支持。

4.2 协方差类型怎么选

GMM的covariance_type有四种选项,这是初学者最容易忽略的地方:

  • full:每个成分有自己的完整协方差矩阵,可以拟合任意方向和形状的椭圆簇,参数最多,需要的数据量也更大。

  • tied:所有成分共享同一个协方差矩阵,但各自均值不同。适合各个簇形状相似但位置不同的场景。

  • diag:每个成分的协方差矩阵只有对角线,即假设各特征独立,拟合的是轴对齐的椭圆。参数比full少很多,适合特征间相关性不强的数据。

  • spherical:每个成分是球形簇,只有一个方差参数,最简单,最省数据。

我的经验是:如果样本量够大(比如每个簇有几百个样本),直接上full,它最能反映数据真实形态。如果样本量紧张,或者特征维度较高,优先考虑diag,它在拟合能力和参数数量之间比较平衡。spherical我很少在生产里用,除非确认每个簇确实近似球形。tied是个容易被低估的选项——在多个簇形状一致但中心位置不同的场景里,它既能让每个成分保留形状信息,又不会让参数数量爆炸。

4.3 数据维度太高怎么办

GMM在高维数据上会遇到“维度灾难”。协方差矩阵的参数量随维度平方级增长,一旦特征维度到了50以上,full型GMM的参数数量是非常可观的,既容易过拟合,训练速度也会明显变慢。

我的做法是先用PCA降维到一个合理的低维空间,比如保留能解释90%以上方差的前n个主成分,然后再跑GMM。这不仅是降维,也是在去噪声——很多无关的细节维度不会干扰距离计算和密度估计。另外,降维后的成分也更容易可视化,对于和业务方沟通异常样本的位置很有帮助。

具体做法:

from sklearn.decomposition import PCA pca = PCA(n_components=0.9, svd_solver='full') normal_pca = pca.fit_transform(normal_scaled) gmm.fit(normal_pca)

注意PCA和标准化一样,都需要先只在正常数据上fit,再transform新样本,逻辑不变。

4.4 训练过程中的稳定性问题

GMM的训练依赖EM算法,而EM对初始化比较敏感。同一个K、同一份数据,跑两遍可能收敛到不同的局部最优。解决方法是多跑几个随机种子,选择对数似然最高的那一个。sklearn里有一个参数叫n_init,默认是1,我实际使用时经常设成10或20。

gmm = GaussianMixture(n_components=2, covariance_type='full', random_state=42, n_init=10)

此外还有一种情况:训练过程中可能出现某个成分的参数发散,比如某成分被分配到极少的数据点,协方差矩阵变成奇异矩阵。如果遇到警告或报错,先检查是不是K设置太大,导致某个簇分不到足够样本;或者某个特征在某簇内部几乎没有波动。处理方式要么减少K,要么给协方差加一点正则,sklearn里的reg_covar参数就是干这个的,默认值是1e-6,可以适当调大到1e-3试试。

5. 常见问题速查与扩展思考

5.1 高频问题速查表

我把实际操作中经常遇到的问题整理成一个速查表,方便你遇到情况时直接对号入座。

问题现象可能原因处理办法
所有样本异常分数都很高标准化时混入了异常点,均值方差被带偏标准化参数只用正常数据拟合
某个生物群几乎不含任何样本K设置过大,或某个簇初始位置不好调小K,或增大n_init重跑
训练日志里出现协方差矩阵非正定警告特征维度高,或某特征方差趋近于零用diag协方差,或调整reg_covar
部分正常样本被误判异常阈值设太低,或正常数据本身有多模态未被覆盖提高阈值分位点,或增加K
异常点没有被检出来异常点落在两个正常簇之间的密度较高区域降低阈值,或考虑局部密度方法辅助
模型在验证集上AUC曲线很稳定但业务效果差业务对漏报误报的代价不一致,只看AUC不够按业务代价曲线选定阈值,而非F1

5.2 GMM做异常检测的局限与替代思路

GMM并非万能的。当数据维度很高且结构非常复杂时,基于距离或密度的传统方法都会受限,GMM也一样。另外,GMM假设每个簇是高斯形状的,如果正常数据的真实簇形是长条形、弧形等非椭圆结构,GMM的拟合效果会很勉强。这时候可以考虑核密度估计(KDE),或者基于邻域的方法,如局部异常因子(LOF),甚至用自编码器的重构误差来做异常检测。

我个人的实践体会是:GMM在以下几类场景中表现尤其好——正常行为有明显多模态特征、数据维度不高(或已降维)、业务方需要概率解释。当你拿到一个异常检测任务时,我建议把GMM当作基线模型先跑一遍:它训练快、可解释、效果好,如果它已经能满足业务需求,就没必要上更复杂的深度模型。如果GMM效果不行(比如正常数据形态太怪异),再往局部方法和深度方向找替代,这是一条比较节约时间的路线。

另外还有一个实用小技巧:GMM训练完之后,不要只保存模型,建议把训练集上异常分数的分位数分布也保存下来。这样在线上部署时,每来一个样本,不仅能输出它自身的异常分数,还能直接告诉你“这个分数超过了历史正常样本的百分之几”。这个百分位信息在业务侧特别直观,业务人员看一眼就能做判断,省去了大量的解释沟通成本。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 1:11:08

Python批量上传图片到OSS:目录结构保真与并发优化实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:11:08

手机杂音背后的电磁三幕剧:静电屏蔽、涡流与趋肤效应

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:10:49

三维相机交互原理:鼠标操控背后的坐标变换与状态机设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:09:46

Selenium模拟淘宝滑块验证码:行为建模与轨迹伪造实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:09:45

Linux内核层定制实战:配置裁剪、源码修改与编译部署全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:08:42

双节锂电主动均衡设计:MP2642GR-Z与PIC18F45K42协同方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华