传感器监控、安全运营、欺诈检测——这些场景都需要及时发现异常状况。但是问题在于,异常样本出现频率低导致标注数据稀缺,监督学习模型难以构建。虽然异常(anomaly)和新颖性(novelty)这两个概念经常混用,但它们在建模假设和处理流程上存在本质差异。
本文会先讲清楚异常检测的核心概念,分析anomaly和novelty的区别,然后通过实际案例演示如何用概率密度拟合方法构建单变量数据集的无监督异常检测模型。所有代码基于distfit库实现。
Anomaly还是Novelty?差异在哪
两者都是偏离"常态"的观测值,统称为离群值(outlier)。离群值通常出现在分布的尾部,远离主体密度区域。如果某个值或某个小范围内出现显著的密度尖峰,也可能是潜在离群点。虽然检测目标一致,建模思路却不同:
Anomaly(异常):训练数据中已知存在离群值,它们偏离正常模式。建模策略是在正常样本(inlier)上拟合模型,忽略那些偏离样本,把落在正常行为范围外的观测识别为异常。
Novelty(新颖性):训练数据中不存在已知离群值,数据本身不包含偏离正常的观测。这种情况更棘手,因为缺少离群参照。领域知识在这里变得关键,避免模型在正常样本上过拟合。
三类离群值形态
刚才说了anomaly和novelty的建模差异,在建模前需要明确"在具体应用场景下,离群值长什么样"。离群值大致分三类(图1):
全局离群值(point outliers)指那些独立的、与所有其他观测都不同的单个数据点[1, 2]。平时说的"离群值"通常就是指这类。
上下文离群值(contextual outliers)是在特定上下文下不合理的观测。上下文可能体现为双峰或多峰分布,离群值在某个峰的范围内偏离。比如冬天零度以下正常,夏天就成了异常。时间序列、季节性数据、传感器数据、安全监控都会遇到这类问题。
集体离群值(collective outliers)是一组相似实例表现出的异常行为。这组离群点可能形成独立的峰,往往暗示着不同于单点异常的问题——批处理错误或数据生成过程的系统性问题。检测集体离群值通常需要专门的方法。
图1. 从左到右:全局离群值、上下文离群值、集体离群值示例
建模前还要考虑数据集维度。从特征数量看,异常检测分为单变量(univariate)和多变量(multivariate)两种方式。下一节我们详细讲。
单变量 vs 多变量建模
异常检测的建模路径主要有两条:单变量分析和多变量分析(图2)。本文聚焦单变量随机变量的异常检测,先说说区别:
单变量方法每次只用一个变量来判断样本是否异常——人的年龄、体重,或时间序列里的单个变量。这种情况下分析数据分布很适合做异常检测。
多变量方法同时分析多个特征,比如年龄、体重、身高一起看。当特征间存在(非)线性关系,或各变量分布严重偏斜时,多变量方法更合适。单变量方法在这些场景下效果有限,因为没考虑变量间的关联。主成分分析(PCA)就是常用的多变量异常检测手段。
图2. 单变量与多变量分析用于异常检测的对比
用分布拟合做单变量离群值检测
单变量数据集的离群值检测有很多(非)参数方法,比如Z-score、Tukey's fences、基于密度的方法等。这些方法的共同点是对底层分布建模。distfit库正好擅长这个——既能为单变量随机变量确定概率密度函数(PDF),也能用百分位数或分位数做非参数建模。它可以处理前面提到的三类离群值:全局、上下文、集体离群值。
建模流程大致如下:
- 在多个PDF上计算随机变量的拟合效果,用拟合优度检验对PDF排序,bootstrap验证是否过拟合。非参数方法也可以用分位数或百分位数实现。
- 可视化检查直方图、PDF、CDF和QQ图。
- 综合前两步选最佳模型,但要确保模型特性符合应用场景。选模型不只是统计问题,也是建模决策。
- 用选定的模型对新样本做预测。
连续随机变量的Novelty检测
从一个简单例子入手,演示如何用分布拟合和假设检验做单变量的novelty检测。这个例子的目标是开发一种检测全局离群值的方法,识别那些偏离正常值的数据点。某个时候需要结合领域知识来设定离群值的边界。
下面生成10,000个人类身高测量值,均值163,标准差10。预期是钟形曲线,两端分别是偏矮和偏高的样本。注意随机性可能让重复实验的结果略有差异。
# import library
import numpy as np
# Generate 10000 samples from a normal distribution
X = np.random.normal(163, 10, 10000)
1、找出最适合人类身高的PDF
检测异常前先要对"正常身高"拟合一个分布(PDF)。distfit库可以拟合多达89个理论分布,这里只搜索常见的概率密度函数,因为身高数据大概率是钟形曲线。安装依赖
# Install distfit library
pip install distfit
代码:
# import library
from distfit import distfit
# Initialize for common/popular distributions with bootstrapping.
dfit = distfit(distr='popular', n_boots=100)
# Estimate the best fit
results = dfit.fit_transform(X)
# Plot the RSS and bootstrap results for the top scoring PDFs
dfit.plot_summary(n_top=10)
# Show the plot
plt.show()
图3. 最常见分布拟合人类身高的RSS得分
根据拟合优度统计量(RSS)和bootstrap验证,loggamma PDF是人类身高的最佳拟合。bootstrap方法评估PDF有没有过拟合,得分在[0,1]区间,反映在100次bootstrap中的拟合成功率。从图3看,除了loggamma,还有其他几个RSS较低的PDF——Beta、Gamma、Normal、t分布、广义极值、Weibull分布。但只有五个通过了bootstrap检验(图3中的绿点)。
2、可视化检验最佳拟合
目测拟合效果,可以让我们更加确定选择,distfit库提供了绘图功能,可以画直方图配合PDF/CDF,还有QQ图。
# Make figure
fig, ax = plt.subplots(1, 2, figsize=(20, 8))
# PDF for only the best fit
dfit.plot(chart='PDF', n_top=1, ax=ax[0]);
# CDF for the top 10 fits
dfit.plot(chart='CDF', n_top=10, ax=ax[1])
# Show the plot
plt.show()
图4. 帕累托图:经验数据直方图和估计PDF。左图:最佳拟合(Beta)的PDF;右图:前10个拟合的CDF。置信区间alpha=0.05
可视化验证了top PDF的拟合优度得分。不过Weibull分布(图4黄线)出现了两个峰,虽然RSS低,但目测拟合效果不好。bootstrap方法早就排除了Weibull,现在我们知道具体的原因了。
3、结合PDF特性做决策
还有五个候选分布在拟合优度、bootstrap、可视化检查上都表现不错。现在要判断哪个PDF的基本特性最适合建模人类身高,逐个分析:
对数伽马分布(Log-gamma)形状偏斜,类似伽马分布但尾部更重。它对数值的对数建模,适合数据包含大量高值的情况。
Beta分布通常用来建模比例或比率,不太适合身高这种连续变量。如果身高除以参考值(比如中位数),Beta才合适。所以虽然它拟合优度最好,目测也不错但不是首选。
伽马分布(Gamma)是连续分布,常用于正偏数据,即存在高值长尾。人类身高因为有特别高的个体,可能呈正偏分布。但bootstrap显示拟合不佳。
学生t分布在小样本或总体方差未知时可以替代正态分布。它的尾部比正态分布重,能更好地捕捉离群值或偏态。小样本情况下t分布可以考虑,但样本量增大后t分布会接近正态分布。
正态分布是经典选择,但人类身高的正态性假设未必适用所有人群。它没有重尾,可能捕捉不好离群值。
另外还有两个不显著但可能的候选:
广义极值分布(GEV)可以建模人群中的极值,比如最大值或最小值。它允许重尾,能捕捉离群值或偏态。不过它通常用于极值分布,而非连续变量的整体分布。
Weibull分布通常建模单调递增或递减趋势的数据,比如失效时间。人类身高没有明显的单调趋势,PDF/CDF/QQ图也显示不匹配。
综合拟合优度、bootstrap、可视化、PDF特性,loggamma分布在这个场景下是最佳选择。需要的话可以指定loggamma重新拟合:
# Initialize for common or popular distributions.
dfit = distfit(distr='loggamma', alpha=0.01, bound='both')
# Estimate the best fit
results = dfit.fit_transform(X)
# Print model parameters
print(dfit.model)
# {'name': 'loggamma',
# 'score': 6.676334203908028e-05,
# 'loc': -1895.1115726427015,
# 'scale': 301.2529482991781,
# 'arg': (927.596119872062,),
# 'params': (927.596119872062, -1895.1115726427015, 301.2529482991781),
# 'color': '[#e41a1c](#e41a1c)',
# 'CII_min_alpha': 139.80923469906566,
# 'CII_max_alpha': 185.8446340627711}
# Save model
dfit.save('./human_height_model.pkl')
4、对新样本做预测
用拟合好的模型评估新样本的显著性,判断它们是否偏离正常范围(inlier)。预测基于理论概率密度函数,速度快、开销小、结果可解释。PDF的置信区间用
alpha
参数设置。这里需要领域知识,因为数据集里没有已知离群值。这个例子设置置信区间(CII)
alpha=0.01
,得到最小边界139.8cm,最大边界185.8cm。默认分析两个尾部,可以用
bound
参数调整。
predict
函数可以对新样本预测并画图(图5)。注意显著性做了多重检验校正:
multtest='fdr_bh'
。所以离群值可能在置信区间外但没被标记为显著。
# New human heights
y = [130, 160, 200]
# Make predictions
results = dfit.predict(y, alpha=0.01, multtest='fdr_bh', todf=True)
# The prediction results
results['df']
# y y_proba y_pred P
# 0 130.0 0.000642 down 0.000428
# 1 160.0 0.391737 none 0.391737
# 2 200.0 0.000321 up 0.000107
plt.figure();
fig, ax = plt.subplots(1, 2, figsize=(20, 8))
# PDF for only the best fit
dfit.plot(chart='PDF', ax=ax[0]);
# CDF for the top 10 fits
dfit.plot(chart='CDF', ax=ax[1])
# Show plot
plt.show()
图5. 左图:经验数据直方图和对数伽马PDF。黑线是经验分布,红线是拟合的理论分布,红色竖线是0.01的置信区间。绿色虚线标记为离群值,红叉不显著
预测结果存在
results
里,包含几列:
y
、
y_proba
、
y_pred
、
P
。
P
是原始p值,
y_proba
是多重检验校正后的概率(默认
fdr_bh
)。用
todf=True
参数会返回数据框。两个观测值概率
alpha<0.01
,被标记为显著的
up
或
down
。
真实数据的Anomaly检测
前面演示了如何拟合模型并检测novelty场景下的全局离群值。现在用真实数据做anomaly检测。真实数据通常更棘手。这里用汤森路透的天然气现货价格数据集,下载导入并去除nan值后,有6555个数据点,跨度27年。
# Initialize distfit
dfit = distfit()
# Import dataset
df = dfit.import_example(data='gas_spot_price')
print(df)
# price
# date
# 2023-02-07 2.35
# 2023-02-06 2.17
# 2023-02-03 2.40
# 2023-02-02 2.67
# 2023-02-01 2.65
# ...
# 1997-01-13 4.00
# 1997-01-10 3.92
# 1997-01-09 3.61
# 1997-01-08 3.80
# 1997-01-07 3.82
# [6555 rows x 1 columns]
先画个天然气现货价格的折线图,看看有没有明显趋势或其他特征(图6)。2003年和2021年有两个明显峰值,暗示全局异常。价格走势有自然起伏,局部高低点。从折线图能建立对分布的初步判断。价格主要在[2, 5]区间波动,但2003-2009年有几个特殊年份,区间偏向[6, 9]。
# Get unique years
dfit.lineplot(df, xlabel='Years', ylabel='Natural gas spot price', grid=True)
# Show the plot
plt.show()
图6. 汤森路透天然气现货价格开源数据集
用distfit深入分析数据分布,确定配套的PDF。搜索空间设为所有可用PDF,bootstrap设为100评估过拟合。
# Initialize
from distfit import distfit
# Fit distribution
dfit = distfit(distr='full', n_boots=100)
# Search for best theoretical fit.
results = dfit.fit_transform(df['price'].values)
# Plot PDF/CDF
fig, ax = plt.subplots(1,2, figsize=(25, 10))
dfit.plot(chart='PDF', n_top=10, ax=ax[0])
dfit.plot(chart='CDF', n_top=10, ax=ax[1])
# Show plot
plt.show()
图7. 左:PDF;右:CDF。所有拟合的理论分布用不同颜色显示
最佳拟合PDF是Johnsonb(图7),但绘制经验分布时,PDF(红线)没有完全贴合经验数据。多数数据点确实在[2, 5]区间(分布峰值位置),分布中还有个较小的第二峰,价格在6附近。这就是PDF拟合不够平滑的地方,出现了欠拟合和过拟合。用摘要图和QQ图能更好地检查拟合效果:
# Plot Summary and QQ-plot
fig, ax = plt.subplots(1,2, figsize=(25, 10))
# Summary plot
dfit.plot_summary(ax=ax[0])
# QQplot
dfit.qqplot(df['price'].values, n_top=10, ax=ax[1])
# Show the plot
plt.show()
摘要图显示拟合优度检验在所有top分布上得分不错(低分)。但看bootstrap结果,除了一个分布,其他全都过拟合了(图8A橙线)。这不意外,因为前面就注意到了过拟合和欠拟合。QQ图证实拟合分布与经验数据偏差较大(图8B)。只有Johnsonsb分布勉强合格。
图8. A. 左图:PDF按bootstrap得分和拟合优度排序;B. 右图:QQ图,经验分布vs所有理论分布
继续用Johnsonsb分布和
predict
功能检测离群值。数据集包含离群值,遵循anomaly方法——在正常样本上拟合分布,落在置信区间外的观测标记为潜在离群点。用
predict
和
lineplot
可以检测并绘制离群值。从图9看,检测到了全局离群值,还有一些上下文离群值,虽然没有专门建模。红条是低于下限的离群值,绿条是高于上限的离群值。
alpha
参数可以调置信区间。
# Make prediction
dfit.predict(df['price'].values, alpha=0.05, multtest=None)
# Line plot with data points outside the confidence interval.
dfit.lineplot(df['price'], labels=df.index)
图9. 拟合分布并预测后的离群值可视化。绿条是95% CII上限外的离群值,红条是下限外的离群值
总结
本文讲了anomaly和novelty检测的区别,演示了如何用分布拟合创建离群值检测模型。distfit库可以评估89个理论分布,选出最佳模型,对新样本做预测。通过实践案例和真实数据,确定了最匹配的PDF。
有时候没有理论分布显著匹配,distfit库也提供了非参数拟合选项,用百分位数或分位数实现。更多细节可以看分布拟合的相关博客。离群值检测本身就是个有挑战的任务,"正常"或"预期"的定义因场景而异,有主观性。离群值可能源于测量误差、数据错误或自然波动,下结论前要仔细分析偏差的根本原因。
https://avoid.overfit.cn/post/d56cd3ea448340059511f1489dfb1f05
作者:Erdogan T