跳至主要内容

Isolation Forest 指南:原理讲解与 Python 实现

Isolation Forest 是一种无监督机器学习算法,通过在一组决策树中进行随机划分来隔离离群点,从而识别数据中的异常。
· 9分钟 阅读

使用 AI 探索

我最近收到银行发来的短信:

“我们已暂时冻结您的卡。请确认您是否在 [vendor] 进行了一笔金额为 [amount] 的交易?”

幸好,那只是我在不太合理的时间点进行网购。但这也让我开始思考……银行如何在每天处理的成千上万笔交易中标记可疑交易?

识别欺诈是 机器学习(ML)中一个更广泛问题——异常检测——的一个具体场景。解决这个问题的方法很多,但本文将聚焦于其中最常用的一种:Isolation Forest。

实践中的异常检测

打击欺诈、网络防御、发现离群点、标记故障设备,这些看似不同的问题其实有一个共同点——并没有明确的标签或定义来界定什么是异常。很多时候,异常既稀少又隐蔽,难以从海量的正常数据中识别出来。

一种思路是使用预测式 机器学习来构建异常检测系统。比如,我们可以收集欺诈交易样本,然后利用交易金额、时间、地点、商户类型等特征,训练一个模型来区分正常与异常交易。问题在于,这种方法通常只能发现与历史相似的欺诈类型。

欺诈者会不断改变策略,尝试全新的作案方式,其中很多都出乎意料。因此,常常使用无监督方法来检测异常。它们通过比较所有交易,识别出具有不寻常特征值的样本。重要的是,这意味着我们无需事先为交易打标签。

难以归类的行为并不只存在于欺诈。在制造业中,机器可能会以各种意料之外的方式随时发生故障。在网络安全中,新型攻击不断被发现与修补。在医学中,罕见病症可能隐藏在大量检测结果之中。应用场景不胜枚举,下面就来看看一种无监督方法如何发现异常。

什么是 Isolation Forest?

顾名思义,Isolation Forest是一种基于树的方法。它与随机森林类似,但其基本构件不同。

随机森林使用基于基尼不纯度的分裂来构建决策树,这需要目标变量;而 Isolation Forest 使用“隔离树”。该方法通过集合这些树来为每个样本计算异常分数。

隔离树

在计算异常分数之前,值得先理解这一基本构件如何工作。假设我们试图检测欺诈交易。为简单起见,我们评估 1000 笔交易(样本),并考虑数据集中的两个变量:金额(x1)和交易时间(x2)。

构建一棵隔离树时,我们从根节点包含全部或部分样本开始。在下方的图 1中,您可以看到我们使用了 256 个样本(稍后解释这个数的来由)。

Isolation Tree created using recursive random splits. The number of instances in each node is given.

图 1:通过递归随机分裂创建的隔离树。每个节点标注了样本数量。

随后:

  1. 随机选择一个特征(x1 或 x2)。
  2. 在当前节点样本的该特征取值范围内,随机选择一个分裂阈值。
  3. 根据分裂阈值将数据划分为两个子节点。
  4. 对每个子节点递归重复上述过程,直到满足以下任一条件:
    1. 每个叶节点仅包含一个样本。
    2. 达到预设的最大深度。

请注意,每一步中特征的取值范围都会改变。也就是说,我们使用当前节点样本在该特征上的最小值和最大值。这样的动态调整可确保分裂有意义,且节点不会出现零样本。

图 2能帮助我们直观理解为何该过程会隔离离群点。样本 A、B、和 C看起来都与其他交易不同。

Scatter plot of transactions . Amount is on the y-axis and time is on the x-axis. Potential outliers are given by points A, B and C.

图 2:交易的散点图

B 很可能最先被隔离,因为它可以通过任一特征的一次分裂就与其他交易分开。

A 则可能通过一次 x2 分裂被隔离。

C 可能需要更长时间,因为需要同时利用两个特征来隔离。也就是说,C 的金额与时间各自都显得正常,但“在该时段上金额不正常”。

我们可能会走运,第一次就隔离出这些样本中的一个,但通常需要多试几次。关键在于,平均而言,B 所需的分裂次数少于 A,A 少于 C,且这三点所需的分裂次数都少于其他交易。那么,我们如何量化这种“平均表现”呢?

计算异常分数

隔离异常所需的分裂次数,决定了样本到叶节点的路径长度。当一片隔离树的森林对某些样本持续给出较短的路径长度时,这些样本很可能是异常。我们通过在所有树上聚合路径长度来捕捉这种平均行为,即计算异常分数。

要为样本 x 计算异常分数,先定义几个量:

路径长度 h(x) 是到达该样本叶节点需经过的边(分裂)数。若直接取平均 E[h(x)] 会有问题,因为更大的树(包含更多样本)通常会有更长的路径,即便对异常也是如此。为此,我们需要基于样本量 n 的归一化量:

该值源自二叉搜索树(BST)理论,表示在 BST 中一次不成功搜索的平均路径长度。由于隔离树与 BST 具有等价的结构,我们将 c(n) 视为一个样本(无论是否异常)的期望路径长度。注意,如果设置了树的最大深度,该值需要相应调整。

最后,我们即可计算异常分数 s(x,n):

​该分数介于 0 与 1 之间。解读时可考虑三种情形:

  • 若某点为异常,则 E[h(x)] < c(n),且 s → 1
  • 若某点非异常,则 E[h(x)] > c(n),且 s → 0
  • 若 E[h(x)] = c(n),则 s = 0.5

换言之,当某样本的平均路径长度短于期望平均路径长度时,其异常分数更接近 1。不过,正如我们在实现时会看到,Scikit-learn 包对该分数还进行了进一步调整。

用 Python 和 Scikit-learn 实现 Isolation Forest

我们一步步来,从导入依赖开始。

导入

我们使用常见的 Python 数据处理与可视化包(第 2–5 行),并使用 Scikit-learn 中的 Isolation Forest 实现(第 7 行),最后一个包用于导入数据集(第 8 行)。

# Imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

from sklearn.ensemble import IsolationForest
from ucimlrepo import fetch_ucirepo

数据集

我们直接从UCI 机器学习资源库加载数据。这里使用Air Quality 数据集(CC BY 4.0),其中包含来自意大利某城市的一台传感器的 9,358 条空气质量测量。在该数据集的语境下,异常可以理解为指示污染水平异常偏高的传感器读数。

# Fetch dataset from UCI repository
air_quality = fetch_ucirepo(id=360)

数据清洗

在应用模型前,我们先做一些清洗操作。先从数据集中选取所有特征(第 2 行),再选取 4 个特征的子集(第 5 行)。这些特征都是空气中不同金属氧化物(即污染物)的测量值。随后我们删除包含缺失值的行(第 8–9 行)。最终剩余 6,941 条样本,下面可以看到特征集的一个快照。

# Convert to DataFrame
data = air_quality.data.features

# Select features
features = data[['CO(GT)', 'C6H6(GT)', 'NOx(GT)', 'NO2(GT)']]

# Drop rows with missing values (-200)
features = features.replace(-200, np.nan)
features = features.dropna()

print(features.shape)
features.head()

A snapshot of the feature set containing measurements of 4 pollutants

定义训练参数

最后一步是定义用于训练 Isolation Forest 的参数。在这里,我们将使用以下三个值:

  • n_estimators:集成中的隔离树数量。Isolation Forest 论文中使用了 100。研究者通过实验发现,这个值在多种数据集上表现良好。
  • contamination:我们预计为异常的数据点比例。
  • sample_size:训练每棵隔离树所用的样本数。常用值为 256,它使我们无需设置最大树深作为停止条件,因为可期望合理的最大树深约为 log(256) = 8。

与其他参数不同,contamination 的取值没有那么“硬”的依据。它可以来自以往经验。比如,在之前的分析中,我们发现 1% 的读数指示污染水平较高。它也可能受资源限制所驱动。例如,在分析欺诈交易时,您可能只来得及对最差的 5% 分数的交易做进一步分析。可视化异常分数时,我们会看到它们如何基于该值进行调整。

# Parameters
n_estimators = 100  # Number of trees
contamination = 0.01  # Expected proportion of anomalies
sample_size = 256  # Number of samples used to train each tree

训练 Isolation Forest

现在可以开始训练模型了。如果您熟悉 Sklearn,下面的流程应该并不陌生。我们用上述参数初始化模型(第 2–5 行),然后在特征集上进行训练(第 6 行)。

# Train Isolation Forest
iso_forest = IsolationForest(n_estimators=n_estimators,
                            contamination=contamination,
                            max_samples=sample_size,
                            random_state=42)
iso_forest.fit(features)

计算异常分数

训练好的模型提供了两个有用的函数:

  • decision_function:以与理论部分类似的方式计算异常分数。
  • predict :基于 contamination 值给出二元标签。在我们的设置中,异常分数最差的 1% 样本被标记为 -1,其余样本标记为 1。

我们将这两个函数的输出添加到数据集中(第 2–4 行)。统计异常标签(第 6 行)后可以看到,有 70 个样本被标记为异常。

# Calculate anomaly scores and classify anomalies
data = data.loc[features.index].copy()
data['anomaly_score'] = iso_forest.decision_function(features)
data['anomaly'] = iso_forest.predict(features)

data['anomaly'].value_counts()

我们还可以进一步把所有异常分数可视化,代码如下:

# Visualization of the results
plt.figure(figsize=(10, 5))

# Plot normal instances
normal = data[data['anomaly'] == 1]
plt.scatter(normal.index, normal['anomaly_score'], label='Normal')

# Plot anomalies
anomalies = data[data['anomaly'] == -1]
plt.scatter(anomalies.index, anomalies['anomaly_score'], label='Anomaly')
plt.xlabel("Instance")
plt.ylabel("Anomaly Score")
plt.legend()
plt.show()

Scatter plot of anomaly scores for all instances in the feature set

橙色标出的 70 个样本拥有最低的异常分数。它们是潜在异常,下一步需要进一步排查。

此时您也许有些困惑:我们不是说“更接近 1 的分数表示更可能异常”吗?

上面散点图中的分数已经过调整。为此,库首先计算一个偏移量,即基于 contamination 值的异常分数分位数。在我们的例子中,偏移量为 0.99 分位。最终分数为 offset - score。这样处理后,小于 0 的分数都提示潜在异常。

进一步分析

借助上面的散点图,我们可以定位潜在异常。但它不会告诉我们“为何”被判为异常。为此需要进一步分析。

例如,下面的代码绘制了所有样本的 CO(GT) 与 NO2(GT) 的散点图,并根据是否被分类为异常进行着色。

# Visualization of the results
plt.figure(figsize=(5, 5))

# Plot non-anomalies then anomalies
plt.scatter(normal['CO(GT)'], normal['NO2(GT)'], label='Normal')
plt.scatter(anomalies['CO(GT)'], anomalies['NO2(GT)'], label='Anomaly')
plt.xlabel("CO(GT)")
plt.ylabel("NO2(GT)")
plt.legend()
plt.show()

Scatter plot of two pollutant measurements

从图中可以看到,CO(GT) 这个污染物指标尤为重要。注意,许多异常样本在该特征上的取值大于 6。然而,这并不能解释所有异常:有些样本在两个特征上都很高,另一些则似乎并非由图中的任一特征导致异常。这表明其他污染物指标也对分类有贡献。

Isolation Forest 的优势

这引出了 Isolation Forest 相较其他异常检测方法的主要优势。其一是能够处理高维数据。如上所示,样本可以基于所有特征被判为异常,而且该方法在远超四个特征的情况下也能很好工作。另一个关键优势是能发现多种类型的异常。

作为对比,一个简单的异常检测方法是 z 分数。若仅逐个特征使用,它会依据特征值与其均值的偏离程度来判定异常。然而这只覆盖了一类异常。这种方法还假设特征服从正态分布,并要求我们检查数据集中每个特征的 z 分数。

而在 Isolation Forest 中,我们不对特征分布作假设。因此,我们可以发现那些“非因远离均值而异常”的样本。而且,我们还能同时利用所有特征进行分类。在处理大型数据集时,这能够显著简化分析。

另外需要考虑的是,Isolation Forest 具有线性时间复杂度,且属于无监督方法。这使其非常适合在缺乏先验知识的大型数据集中检测异常。这在诸如欺诈检测等真实世界应用中尤为常见,因为新型欺诈会不断在海量正常交易中涌现。

Isolation Forest 的局限

Isolation Forest 的一个显著局限是在小数据集上的表现可能不佳。这与隔离树构建的随机过程有关:总有可能出现正常样本路径较短、反之异常样本路径较长的情况。在小数据集中,这种情况更容易发生。换言之,要让 Isolation Forest 发挥作用,我们需要足够的数据来平滑随机性。

另一个局限,如前所述,Isolation Forest 只能识别潜在异常,却无法解释为何某些样本会被视为异常。为此需要进一步分析。而“能识别复杂异常”的优势在此处也会成为挑战——要完整解释一个异常,往往需要大量工作与领域知识。

结论

本文探讨了 Isolation Forest,这一在多个领域都适用的强大异常检测工具。我们看到,其通过随机划分来“隔离异常”的独特思路,相较传统方法具有优势:能处理高维数据、识别复杂异常模式,并具备良好的可扩展性。

结合 Python 与 Scikit-learn 的示例也展示了其易用性。

尽管 Isolation Forest 能在缺乏先验知识的情况下有效识别潜在异常,但它也存在局限,尤其是在小数据集场景下的表现,以及无法解释具体样本为何为异常的不足。

Conor O'Sullivan

作者

Conor O'Sullivan

Conor 正在攻读以海岸监测为方向的机器学习博士学位。他也曾在银行业和半导体制造业担任数据科学家,积累了相关经验。

主题