使用 UMAP 检测异常值
虽然之前的教程介绍了使用 UMAP 进行聚类,但只要稍加注意,它也可以用于异常检测。本教程将通过在 MNIST 手写数字数据集中查找异常数字来介绍如何以这种方式使用 UMAP 以及需要注意的事项。首先,让我们加载相关库。
import numpy as np
import sklearn.datasets
import sklearn.neighbors
import umap
import umap.plot
import matplotlib.pyplot as plt
%matplotlib inline
有了这些准备,让我们使用 sklearn 中新的 fetch_ml
加载器从互联网上获取 MNIST 数字数据集。
data, labels = sklearn.datasets.fetch_openml('mnist_784', version=1, return_X_y=True)
在开始之前,我们应该尝试在 MNIST 数字所在的原始 784 维空间中寻找异常值。为此,我们将利用 局部异常因子 (LOF) 方法来确定异常值,因为 sklearn 提供了一个易于使用的实现。LOF 的核心思想是寻找那些(局部近似)密度与其邻居平均密度显著不同的点。对于我们的情况,具体细节并不那么重要——只需要知道该算法在向量空间数据上相当鲁棒且有效即可。我们可以使用 sklearn 类的 fit_predict
方法来应用它。LOF 类有一个参数 contamination
,它指定了用户预期为噪声的数据百分比。对于本用例,我们将它设置为 0.001428,因为考虑到 MNIST 中有 70,000 个样本,这将产生 100 个异常值,然后我们可以更详细地研究这些异常值。
%%time
outlier_scores = sklearn.neighbors.LocalOutlierFactor(contamination=0.001428).fit_predict(data)
CPU times: user 1h 29min 10s, sys: 12.4 s, total: 1h 29min 22s
Wall time: 1h 29min 53s
值得注意的是这花费了多长时间。一个半多小时!为什么花了这么长时间?因为 LOF 需要密度的概念,而密度概念又依赖于最近邻计算——这在 sklearn 中处理高维数据时非常耗时。仅这一点就可能是考虑降低数据维度的原因——这使得它更容易应用现有的技术,如 LOF。
现在我们有了一组异常值分数,我们可以找到实际的异常数字图像——那些分数等于 -1 的图像。让我们提取这些数据,并检查我们是否得到了 100 个不同的数字图像。
outlying_digits = data[outlier_scores == -1]
outlying_digits.shape
(100, 784)
既然我们已经得到了异常数字图像,我们应该问的第一个问题是“它们看起来像什么?”。幸运的是,我们可以将 784 维向量转换回图像并进行绘制,以便更容易查看。由于我们提取了 100 个最异常的数字图像,我们可以直接展示一个 10x10 的网格。
fig, axes = plt.subplots(7, 10, figsize=(10,10))
for i, ax in enumerate(axes.flatten()):
ax.imshow(outlying_digits[i].reshape((28,28)))
plt.setp(ax, xticks=[], yticks=[])
plt.tight_layout()

这些看起来确实有些奇怪的手写数字,所以我们的异常检测似乎在一定程度上起作用了。
现在让我们尝试使用 UMAP 的一种简单方法,看看能达到什么效果。首先,让我们直接使用默认参数将 UMAP 应用到 MNIST 数据。
mapper = umap.UMAP().fit(data)
现在我们可以使用 umap.plot 中新的绘图工具查看我们得到的结果。
umap.plot.points(mapper, labels=labels)
<matplotlib.axes._subplots.AxesSubplot at 0x1c3db71358>

这看起来就像我们对 MNIST 的 UMAP 嵌入所期待的那样。问题在于我们是否已经足够好地保留了异常值,使得 LOF 仍然可以找到那些奇怪的数字图像,还是嵌入过程丢失了这些信息并将异常值压缩到各个数字聚类中?我们可以简单地将 LOF 应用到嵌入结果上,看看会得到什么。
%%time
outlier_scores = sklearn.neighbors.LocalOutlierFactor(contamination=0.001428).fit_predict(mapper.embedding_)
这显然快得多,因为我们是在一个低得多的维空间中操作,这个空间更适合 sklearn 用于查找最近邻的空间索引方法。和之前一样,我们提取了异常数字图像,并验证我们得到了 100 个。
outlying_digits = data[outlier_scores == -1]
outlying_digits.shape
(100, 784)
现在我们需要绘制这些异常数字图像,看看这种方法找到了哪些特别奇怪的数字图像。
fig, axes = plt.subplots(7, 10, figsize=(10,10))
for i, ax in enumerate(axes.flatten()):
ax.imshow(outlying_digits[i].reshape((28,28)))
plt.setp(ax, xticks=[], yticks=[])
plt.tight_layout()

从很多方面来看,这似乎比原始高维空间中的 LOF 结果更好。虽然高维 LOF 发现的奇怪数字图像确实有些古怪,但这些数字图像中的许多都异常得多——线条粗细明显异常、形状扭曲,以及甚至难以辨认的图像。这有助于说明在检查异常值时存在的某种确认偏见:既然我们期望被标记为异常值的东西是奇怪的,我们就倾向于发现它们中那些能证明这种分类的方面,而可能没有意识到数据中实际上可能存在更多奇怪的部分。这应该让我们对这组异常值保持警惕:数据集中还可能隐藏着什么?
事实上,我们可以通过稍微调整 UMAP 嵌入来改进这个结果,使其更适合寻找异常值的任务。当 UMAP 将不同的局部单纯集(更多详细信息请参见 UMAP 工作原理)组合在一起时,标准方法使用并集,但我们可以改为使用交集。交集确保异常值保持分离,这对于寻找异常值肯定是有益的。交集的一个缺点是它倾向于将得到的单纯集分解成许多不连通的组件,丢失了许多非局部和全局结构,从而导致得到的嵌入质量大大降低。然而,我们可以在并集和交集之间进行插值。在 UMAP 中,这由 set_op_mix_ratio
参数控制,其中值为 0.0 表示交集,值为 1.0 表示并集(默认值为 1.0)。通过将此参数设置为较低的值,例如 0.25,我们可以鼓励嵌入更好地将异常值保留为异常值,同时仍然保留并集操作的好处。
mapper = umap.UMAP(set_op_mix_ratio=0.25).fit(data)
umap.plot.points(mapper, labels=labels)
<matplotlib.axes._subplots.AxesSubplot at 0x1c3f496908>

如您所见,当 set_op_mix_ratio
为 1.0 时,嵌入结果的整体结构不如现在好,但我们可能更好地确保了异常值保持异常。我们可以通过对此嵌入结果运行 LOF 并查看得到的数字图像来验证这个假设。理想情况下,我们应该会找到一些可能更奇怪的结果。
%%time
outlier_scores = sklearn.neighbors.LocalOutlierFactor(contamination=0.001428).fit_predict(mapper.embedding_)
outlying_digits = data[outlier_scores == -1]
outlying_digits.shape
(100, 784)
我们已经得到了预期的 100 个最异常的数字图像,所以让我们可视化结果,看看它们是否真的特别奇怪。
fig, axes = plt.subplots(10, 10, figsize=(10,10))
for i, ax in enumerate(axes.flatten()):
ax.imshow(outlying_digits[i].reshape((28,28)))
plt.setp(ax, xticks=[], yticks=[])
plt.tight_layout()

在这里,我们看到原始嵌入有助于呈现的线条粗细变化(特别是“粗”数字或“细”线条)在这里表现得更明显。我们还看到一些明显损坏的图像,带有额外的线条、点或出现奇怪的模糊。
总之,在运行经典的异常检测方法(如 LOF)之前使用 UMAP 进行降维,可以提高算法运行速度,并改善异常检测结果的质量。此外,我们还介绍了 set_op_mix_ratio
参数,并解释了如何使用它来潜在地改进应用于 UMAP 嵌入的异常检测方法的性能。