在这篇文章中,我们将探讨测试和评估异常检测器的问题(这是一个众所周知的难题),并提出了一种解决方案被称为“Doping”方法。使用Doping方法,真实数据行会被(通常是)随机修改,修改的方式是确保它们在某些方面可能成为异常值,这时应该被异常检测器检测到。然后通过评估检测器检测Doping记录的效果来评估这些检测器。
这里我们主要关注表格数据,但这个想法也可以应用到其他模式,包括文本、图像、音频、网络数据等。
测试和评估其他类型的模型
如果你熟悉异常检测,那么可能至少在某种程度上也熟悉用于回归和分类问题的预测模型。对于这类问题,我们有标记的数据,因此在调整模型时(选择最佳的预处理、特征、超参数等)评估每个选项相对简单;估算模型的准确性(在未见数据上的表现)也相对容易:我们可以简单地使用训练-验证-测试拆分或使用交叉验证。由于数据是有标记的,我们可以直接看到模型在有标记的测试数据上的表现。
但是对于异常检测,没有标记的数据,这样问题就要困难得多;因为没有客观的方式来确定由异常检测器得分最高的记录是否确实是数据集中最不寻常的。
以聚类为例,同样没有数据的标签,但至少可以衡量聚类的质量:比如可以确定簇的内部一致性以及簇之间的差异。使用某种距离度量(如曼哈顿或欧几里得距离),就可以简单的计算出簇内部记录之间的接近程度以及簇之间的距离。
给定一组可能的聚类,可以定义一个合理的度量(如轮廓分数)并确定哪种聚类更受好,或者说至少在该度量标准下更好。我们就可以为每个聚类计算一个分数,并选择看起来最有效的聚类方法。
但是对于异常检测,没有类似的东西可以使用。任何试图量化记录的异常性或确定两个记录中哪一个更异常的系统,实际上本身就是一个异常检测算法。
例如可以使用熵作为异常检测方法,并可以检查整个数据集的熵以及删除任何被识别为强异常的记录后的数据集的熵。从某种意义上说,这是有效的,因为熵是衡量异常存在的有用度量。但我们不能假设熵是这个数据集中异常的最终定义,因为异常检测的一个基本特性是没有异常的最终定义(也就是说你没法量化的判断一个值是否就是异常)。
因此,评估异常检测系统相当困难,至少使用现有的真实数据来说,实际上没有好的方法可以做到这一点。
但是可以创建合成测试数据(我们可以假设合成创建的数据主要是异常)。异常检测器则变为了评估合成记录的能力,这样就可以简化一些问题。
”篡改“数据
Doping方法主要是指将现有数据记录稍作修改,通常只更改每条记录中一个或少数几个列的值。
例如,如果正在检查的数据是公司财务绩效相关的,假设我们有包括以下特征:
- 经营的年龄
- 当前所有者持有的年数
- 去年的销售次数
- 去年的销售总额
以及其他一些特征。
典型的记录可能有这四个特征的值,例如:经营20年,当前所有者持有5年,去年10,000次销售,销售总额500,000美元。
我们可以通过调整一个值到一个罕见的值来创建一个修改版本的记录,例如,将经营的年龄设置为100年。这样做很可能任何检测器都能识别这是异常的(假设100的值是罕见的),这样我们首先能排除一些无法可靠地检测到这种修改记录的检测器。
不过我们不一定会移除异常检测器的类型(例如kNN、熵或孤立森林)本身,而是移除异常检测器的类型、预处理、超参数以及检测器的其他属性的组合。也就是说,比如某些超参数的kNN检测器表现良好,而其他超参数的则不行,我们要保留表现好超参数版的检测器。
另外在更改记录中的值时,我们不具体知道该行将如何成为异常值(假设它成为异常值),但我们可以假设大多数表格在特征之间有关联。在这个例子中,将销售额改为100,000美元可能会和销售次数有的强相关联,因为毕竟在单价相同的情况下销售次数越多,销售额就应该越多,这就是特征之间的关联。我们修改单个值会打破这种关联,这就会产生异常,我们是能够简单的判断出来的。
但是在某些表格中,特征之间没有关联,或者只有少数且弱的关联。这种情况虽然罕见,但确实会发生。对于这种情况,没有不寻常的值组合的概念,只有不寻常的单个值。听起来这种情况变得有些复杂,其实不然实际上这是一个更简单的情况:检测异常更容易(我们只需检查单个不寻常的值),评估检测器也更容易(只需检查能多好地检测不寻常的单个值)。
不过这里我们将假设特征之间存在某些关联,大多数异常将是不寻常的值组合。
使用”篡改“的数据
大多数异常检测器(少数例外)具有分割的训练和预测步骤。Doping也一样,在训练步骤中,会评估训练数据并识别数据中的正常模式(例如,记录之间的正常距离、频繁项集、聚类、特征之间的线性关系等)。然后在预测步骤中,将测试数据集(可能是用于训练的相同数据,或是不同的数据)与训练期间发现的模式进行比较,每行数据都被分配一个异常分数(或在某些情况下是二元标签)。
所以可以通过两种主要方式使用”篡改“的数据:
在训练数据中包含”篡改“数据
可以在训练数据中包含少量”篡改“记录,然后使用这些数据进行测试。这测试了在当前可用数据中检测异常值的能力。这是异常检测中的一个常见任务:给定一组数据,希望找到这个数据集中的异常值(尽管可能希望在后续数据中也找到异常值——相对于这些训练数据的规范而言,这些记录是异常的)。
这样做可以只用少量”篡改“记录进行测试,因为我们不希望显著影响数据的整体分布。然后检查是否能够识别这些异常。一个关键的测试是在训练数据中同时包含”篡改“记录的原始版本和”篡改“版本,这样可以确定检测器是否将”篡改“版本的得分显著高于同一记录的原始版本。
鉴于我们只能使用少量”篡改“记录进行测试,这个过程可能会重复多次。
如果我们能够可靠地检测数据中的”篡改“记录,就可以相当有信心地认为能够识别同一数据中的其他异常值,至少是与”篡改“记录类似的异常值
仅在测试数据中包含”篡改“数据
还有一种方法是只使用真实数据进行训练(假设这些数据大部分是非异常值),然后用真实数据和”篡改“数据进行测试。这可以在相对干净的数据上进行训练(真实数据中的某些记录将是异常值,但大多数将是典型的,且没有因”篡改“记录而导致的污染)。
这也允许我们使用可能被投入生产的实际异常检测器进行测试,因为这测试了在未来数据中检测异常值的能力。这是异常检测的另一个常见场景:有一个可以认为相对干净的数据集(要么没有异常值,要么只包含一小部分典型的异常值,且没有任何极端异常值),我们希望将未来的数据与此进行比较。
仅使用真实数据进行训练并使用真实和”篡改“数据进行测试,可以根据需要使用任何数量的”篡改“数据进行测试,因为”篡改“数据仅用于测试而不用于训练。我们可以创建一个大的、更可靠的测试数据集。
创建”篡改“数据的算法
有许多方法可以创建”篡改“数据,为简单起见,我们只介绍一种简单的方法,即数据的修改方式相当随机:修改的单元格是随机选择的,替换原始值的新值也是随机创建的。
这样做虽然有可能某些修改的记录并非真正的异常,但在大多数情况下,随机分配的值将破坏特征之间的一个或多个关联。所以可以假设”篡改“记录大多是异常的,尽管根据它们的创建方式,可能只是略微如此。
代码示例
我们通过一个实例,取一个真实数据集,修改它,并测试如何检测这些修改。使用一个在OpenML上可用的名为鲍鱼(abalone)的数据集
尽管可以进行其他预处理,但为了简单起见我们只对分类特征进行独热编码,并使用RobustScaler缩放数值特征,因为这是最基本的预处理了。
我们这里测试三种异常检测器,分别是孤立森林(Isolation Forest)、局部异常因子(LOF)和集成协同异常检测(ECOD),这些都可以在PyOD库中找到。
我们还使用孤立森林来做清理数据(在任何训练或测试之前移除任何强异常值)。这一步虽然不是必需的,但在异常检测中常常是有用的。
我们在原始数据上进行训练,并使用原始数据和”篡改“数据进行测试。(上面说的第二种方法)
import numpy as np
import pandas as pd
from sklearn.datasets import fetch_openml
from sklearn.preprocessing import RobustScaler
import matplotlib.pyplot as plt
import seaborn as sns
from pyod.models.iforest import IForest
from pyod.models.lof import LOF
from pyod.models.ecod import ECOD
# Collect the data
data = fetch_openml('abalone', version=1)
df = pd.DataFrame(data.data, columns=data.feature_names)
df = pd.get_dummies(df)
df = pd.DataFrame(RobustScaler().fit_transform(df), columns=df.columns)
# Use an Isolation Forest to clean the data
clf = IForest()
clf.fit(df)
if_scores = clf.decision_scores_
top_if_scores = np.argsort(if_scores)[::-1][:10]
clean_df = df.loc[[x for x in df.index if x not in top_if_scores]].copy()
# Create a set of doped records
doped_df = df.copy()
for i in doped_df.index:
col_name = np.random.choice(df.columns)
med_val = clean_df[col_name].median()
if doped_df.loc[i, col_name] > med_val:
doped_df.loc[i, col_name] = \
clean_df[col_name].quantile(np.random.random()/2)
else:
doped_df.loc[i, col_name] = \
clean_df[col_name].quantile(0.5 + np.random.random()/2)
# Define a method to test a specified detector.
def test_detector(clf, title, df, clean_df, doped_df, ax):
clf.fit(clean_df)
df = df.copy()
doped_df = doped_df.copy()
df['Scores'] = clf.decision_function(df)
df['Source'] = 'Real'
doped_df['Scores'] = clf.decision_function(doped_df)
doped_df['Source'] = 'Doped'
test_df = pd.concat([df, doped_df])
sns.boxplot(data=test_df, orient='h', x='Scores', y='Source', ax=ax)
ax.set_title(title)
# Plot each detector in terms of how well they score doped records
# higher than the original records
fig, ax = plt.subplots(nrows=1, ncols=3, sharey=True, figsize=(10, 3))
test_detector(IForest(), "IForest", df, clean_df, doped_df, ax[0])
test_detector(LOF(), "LOF", df, clean_df, doped_df, ax[1])
test_detector(ECOD(), "ECOD", df, clean_df, doped_df, ax[2])
plt.tight_layout()
plt.show()
为了创建”篡改“数据,我们复制了完整的原始记录集,因此”篡改“数据的数量与原始记录相等。对于每条”篡改“记录,我们随机选择一个特征进行修改。如果原始值低于中位数,我们就创建一个高于中位数的随机值;如果原始值高于中位数,我们创建一个低于中位数的随机值。
我们看到IF对”篡改“记录的评分更高,但不是很明显。LOF在区分”篡改“记录方面做得很好,至少对于这种形式的修改是能够鉴别出来的。而ECOD是一种检测器,仅检测异常小或异常大的单个值,而不测试异常组合。而我们这里的修改不会产生极值,只有不寻常的组合,所以ECOD无法将”篡改“与原始记录区分开来。
这个例子使用箱线图比较检测器,但通常我们会使用一个客观的评分,比如使用AUROC分数来评估每个检测器。通常也会测试许多模型类型、预处理和参数的组合。
一些其他的替代方法
我们上面介绍的方法倾向于创建违反特征间正常关联的”篡改“记录,也可以使用其他修改技术来增加这种可能性。例如,首先考虑分类列,我们可以选择一个新值,确保满足两个条件:
- 新值与原始值不同
- 新值与行中其他值预测的值不同,可以创建一个预测模型来预测这一列的当前值,例如使用随机森林分类器。
对于数值数据,可以通过将每个数值特征划分为四个四分位数(或一些数量的分位数,但至少是三个)来实现等效目标。对于数值特征中的每个新值,我们选择一个值确保:
- 新值与原始值处于不同的四分位数
- 新值与根据行中其他值预测的四分位数不同
例如,如果原始值在Q1,预测值在Q2,那么我们可以随机选择一个值在Q3或Q4中。这样新值很可能会违反特征间的正常关系。
创建一套测试数据集
一旦数据被修改了,就没有确定量化的方式来说明记录有多异常。但是可以假设平均而言,修改的特征越多,修改的程度越大,”篡改“记录就越异常。我们可以利用这一点,不仅创建单一的测试,而且创建多个测试组合,这使我们能够更准确地评估异常检测器。
例如,我们可以创建一组非常明显的”篡改“记录(每条记录中修改多个特征,每个都与原始值显著不同),一组非常微妙的”篡改“记录(只修改一个特征,且与原始值差异不大),以及许多中间难度级别的记录。这可以帮助区分不同的检测器。
所以可以创建一套测试集,每个测试集根据修改的特征数量和修改的程度具有(粗略估计的)难度级别。还可以有不同的集合修改不同的特征,因为某些特征中的异常值可能更相关,或者检测起来可能更容易或更困难。
这样任何执行的”篡改“都代表了如果出现在真实数据中将会感兴趣的异常类型。理想情况下,”篡改“记录的组合也很好地覆盖了异常检测的范围。
如果满足这些条件,并创建了多个测试集,这对于选择最佳表现的检测器和估算它们在未来数据上的性能非常有力。我们虽然无法预测将检测到多少异常值,也无法知道会看到什么程度的结果——这些在很大程度上取决于将数据(在异常检测的背景下这是很难预测的)。但是可以得到一个不错的感觉,了解可能检测到哪些类型的异常值,哪些则不会。
更重要的是,我们也能够创建一个有效的异常检测器集成。在异常检测中,集成通常对大多数项目来说是必需的。鉴于一些检测器会捕捉到某些类型的异常值而错过其他类型,而其他检测器则会捕捉并错过另外的类型,我们需要通过使用多个检测器可靠地捕捉到异常值范围。
创建集成本身就是一个庞大且复杂的领域,与预测模型的集成不同。但是对于这种方法,我们可以指出了解每个检测器能检测哪些类型的异常值,同时我们也可以得到哪些检测器是多余的,哪些可以检测到大多数其他检测器无法检测到的异常值。因为通过组合我们知道了哪些值被修改了,虽然有一部分人工的工作,但是得到的结果是我们最想要的。
总结
评估任何给定的异常检测器在当前数据中检测异常的效果有多好是困难的,评估其在未来(未见)数据上的表现更是难上加难。同给定两个或更多的异常检测器,评估哪个会做得更好,无论是在当前数据还是未来数据上,都非常困难。
不过,我们可以使用合成数据来估计这些情况。在本文中,我们快速地回顾了一种”篡改“真实记录并评估能否将这些记录区分的方法。尽管不完美但这个方法可以在异常检测中很多时候没有其他实际的替代方法时使用。
Doping方法通过人为创建异常数据点来测试和训练异常检测算法的鲁棒性。这种方法的优势在于可以在没有自然异常数据的情况下测试检测器的灵敏度和准确性。通过这种方式,可以模拟不同类型和程度的数据异常,从而更全面地评估异常检测器的表现。
虽然这种方法不能完全预测检测器在处理真实世界数据时的表现,但它提供了一个有用的工具来增强我们对异常检测器性能的理解,并帮助我们优化检测器配置,以便更好地处理各种数据异常情况。通过反复试验和评估,我们可以逐步改进异常检测技术,使其更加适应不断变化的数据特征和异常行为。
本文的数据
https://avoid.overfit.cn/post/29f12661ea6145b99a7e04717e892174
作者:W Brett Kennedy