Isolation Forestの理論と実装 — 木構造で異常を「分離」する

多くの異常検知手法は、まず「正常とはどういうものか」を学習し、そこから外れるものを異常と判定します。たとえば、One-Class SVMは正常データの分布の「境界」を学習しますし、オートエンコーダは正常データを圧縮・復元するパターンを学習します。しかし発想を逆転させたらどうでしょうか。「異常は分離しやすい」という性質に直接注目する手法があります。

なぜこのような手法が必要なのでしょうか。たとえば、工場の製造ラインで異常製品を検出したい場合を考えてみてください。正常な製品は互いに似た特徴量を持ち「群れ」を形成しますが、異常な製品は他の製品とは大きく異なる特徴量を持ちます。このとき「群れの中から1つを取り出すのは大変だが、群れから離れた1つを取り出すのは簡単だ」という素朴な観察を数学的に定式化したのがIsolation Forestです。

Isolation Forest(Liu, Ting & Zhou, 2008)は、ランダムな分割で各データ点を「他のデータから分離するのにどれだけのステップが必要か」を測定します。異常データは正常データの密集領域から離れているため、少ないステップで分離でき、正常データは多くのステップが必要です。

この直感はシンプルですが、非常に強力です。距離計算やカーネル計算が不要で、データ数 $n$ に対して $O(n \log n)$ で動作するため、大規模データに適しています。また、従来の手法が「正常を学習する」(密度推定を行う)のに対して、Isolation Forestは「分離のしやすさ」を直接測定するため、正常分布の正確なモデル化が不要です。これは高次元データや複雑な分布を持つデータに対して大きな利点となります。

Isolation Forestを理解すると、以下のような場面で活用できます。

  • 大規模データの異常検知: 数百万レコードの中から異常を効率的に検出
  • 高次元データ: 距離ベースの手法が苦手な高次元空間でも有効
  • ストリーミングデータ: オンライン版の適用が容易
  • 前処理なしの適用: 特徴量のスケーリングが不要

本記事の内容

  • Isolation Forestの直感とアルゴリズム
  • 異常スコアの数学的定義と正規化
  • 二分探索木の平均経路長との関係
  • Pythonでのスクラッチ実装
  • オートエンコーダとの比較実験

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

分離の直感

Isolation Forestの概念

正常データは密集し、異常データは孤立しています。Isolation Forestは「孤立した点ほど少ない分割で切り離せる」性質を利用します。

なぜ「分離」なのか

2次元の散布図を想像してください。ほとんどの点が中央に密集しており、1つだけ遠く離れた点があります。

この離れた点を「他の点から分離する」には、データ空間をランダムに分割していけば、ほんの数回の分割で済みます。一方、密集領域の中の1つの点を分離するには、多くの分割が必要です。

日常生活でのアナロジーとして、「教室で1人だけの生徒を見つける」状況を考えてみてください。もし生徒全員が1つの教室にいて、1人だけ校庭にいるなら、「建物の中か外か?」という1回の質問で校庭の生徒を特定(分離)できます。しかし、教室の中の特定の1人を見つけるには、「前半分か後ろ半分か?」「右側か左側か?」と何度も質問を重ねる必要があります。Isolation Forestはまさにこの考え方を形式化したものです。

これがIsolation Forestの核心的な洞察です。異常データは少ない分割回数で分離でき、正常データは多くの分割回数を必要とする

分離の直感

左の正常点は周囲が密で、孤立させるのに多くのランダム分割(深い経路)が必要です。右の異常点は周囲が疎で、わずかな分割(浅い経路)で切り離せます。

二分探索木との対比

Isolation Forestの分離過程は、ランダムな二分探索木の構築と本質的に同じです。二分探索木にデータを挿入するとき、各ノードでの比較に基づいてデータが左右の子ノードに振り分けられます。

  • 密な領域のデータ: 周囲に多くのデータ点があるため、何度も分割しないと1つのデータ点を分離できません。木の深い位置(葉ノード付近)に到達します
  • 疎な領域のデータ(異常): 周囲にデータ点が少ないため、数回の分割で分離されます。木の浅い位置で分離されます

データ点の木における深さが、そのまま異常スコアの指標になります。これは「密度」を明示的に推定しなくても、木の深さという間接的な量を通じて密度に関する情報を得ていることになります。密な領域では深くなり、疎な領域では浅くなるため、木の深さは局所的な密度の代理指標として機能するのです。

この分離の直感を踏まえて、次にIsolation Forestのアルゴリズムを具体的に見ていきましょう。

アルゴリズム

Isolation Tree(iTree)の構築

Isolation Treeは、以下の手順でランダムに構築します。

入力: データ集合 $X$、現在の深さ $e$、深さ制限 $l$

  1. $|X| \leq 1$ または $e \geq l$ なら葉ノードを返す
  2. 特徴量 $q$ をランダムに選ぶ
  3. 分割点 $p$ を $q$ の値の範囲 $[\min(X_q), \max(X_q)]$ からランダムに選ぶ
  4. $X_q < p$ を左部分木、$X_q \geq p$ を右部分木として再帰的に構築

通常の決定木と異なり、情報利得やジニ不純度は計算しません。特徴量と分割点は完全にランダムです。この「ランダム性」が計算効率とロバスト性の源です。

Isolation Treeの構築

通常の決定木(分類や回帰で使うもの)が「データを最もうまく分ける分割」を探すのに対して、Isolation Treeは「とにかくランダムに分割する」だけです。この違いは大きな意味を持ちます。最適な分割を探す必要がないため、分割の計算コストが $O(1)$(特徴量と値をランダムに選ぶだけ)になります。また、ランダム性により各木が異なる分割パターンを学習するため、アンサンブルとしての多様性が確保されます。

深さ制限の設定

深さ制限 $l$ は、サブサンプルサイズ $\psi$ に基づいて

$$ l = \lceil \log_2 \psi \rceil $$

と設定します。これは $\psi$ 個のデータを二分探索木に挿入したときの平均的な深さに対応します。たとえば $\psi = 256$ の場合、$l = \lceil\log_2 256\rceil = 8$ です。

なぜこの深さ制限で十分なのでしょうか。我々が知りたいのは「異常データが浅い位置で分離されるかどうか」です。正常データの経路長を正確に計測する必要はなく、「正常データは深い位置まで到達する」ということさえわかれば十分です。深さ $l$ は正常データの大部分がまだ分離されていない程度の深さに設定されており、正常データは深さ制限に到達して葉ノードに残ります。その場合の残りの経路長は、二分探索木の平均経路長 $c(n)$ で推定します(後述)。この設計により、不必要な深さの計算を省略して計算効率を大幅に向上させています。

Isolation Forest の構築

Isolation Forestは、複数のIsolation Treeのアンサンブルです。

  1. 訓練データからサブサンプルを $\psi$ 個(通常 $\psi = 256$)非復元抽出する
  2. サブサンプルからIsolation Treeを構築する
  3. ステップ1-2を $t$ 回(通常 $t = 100$)繰り返す

サブサンプリングには2つの重要な効果があります。

  • 計算効率: $n$ 個全体ではなく $\psi$ 個($\psi \ll n$)で木を構築するため、高速
  • マスキング効果の緩和: 密集した異常のクラスターが互いを「隠す」問題を軽減

全体の計算量は $O(t \cdot \psi \log \psi)$ で、データ数 $n$ にほぼ依存しません(サブサンプリングの $O(n)$ を除く)。

Isolation Forest

多数のiTreeで得た経路長を平均することで、ランダム性のばらつきが打ち消され、安定した異常スコアが得られます。

サブサンプリングがマスキング効果を緩和する点について補足しておきます。マスキング効果とは、密集した異常のクラスターが存在する場合、クラスター内の異常データ同士が互いを「正常のように見せてしまう」現象です。$n$ 個の全データを使って木を構築すると、異常クラスターの密度が見かけ上高くなり、その中のデータ点は深い位置に到達してしまいます。しかし $\psi \ll n$ のサブサンプルを使えば、異常クラスターから抽出されるデータ点の数が少なくなるため、異常データ点が互いに近くに集まる確率が低下し、正しく浅い位置で分離されやすくなります。

以上がIsolation Forestのアルゴリズムです。次に、経路長から異常スコアを計算する数学的な枠組みを見ていきましょう。

異常スコアの数学的定義

経路長

データ点 $\bm{x}$ のIsolation Tree $T$ における経路長(path length)$h(\bm{x})$ は、$\bm{x}$ が根ノードから葉ノードに到達するまでに通過するエッジの数です。エッジ数は分割回数に対応しており、根ノードからの深さと等しくなります。

葉ノードにまだ複数のデータが残っている場合(深さ制限に到達した場合)、残りの分離に必要な期待経路長を推定値として加算します。葉ノードに $n’$ 個のデータが残っているとき、$c(n’)$ を加算します。この推定は「もし深さ制限がなければ、あと $c(n’)$ ステップ分は分割が必要だったはず」という仮定に基づいています。

二分探索木の平均経路長

$n$ 個のデータに対する二分探索木(Binary Search Tree, BST)の平均的な経路長 $c(n)$ は

$$ \begin{equation} c(n) = 2H(n-1) – \frac{2(n-1)}{n} \end{equation} $$

で与えられます。ここで $H(k) = \ln k + \gamma$($\gamma \approx 0.5772$ はオイラー定数)は調和数です。

平均経路長c(n)

この式は、ランダムな二分探索木に $n$ 個の要素を挿入したときの探索失敗の平均経路長です。導出の概要を見ておきましょう。$n$ 個の要素をランダムな順序でBSTに挿入するとき、要素 $i$ の経路長は、挿入順序のランダム性により調和級数 $H(n-1) = \sum_{k=1}^{n-1} 1/k$ で特徴づけられます。調和数の近似 $H(k) \approx \ln k + \gamma$($\gamma \approx 0.5772$ はオイラー・マスケローニ定数)を用いると、上の式が得られます。

Isolation Treeの構築過程はランダムBSTの構築と統計的に類似しているため、この値を正規化の基準として使います。

$c(n)$ の値の目安:

$n$ $c(n)$
2 1.0
16 5.4
256 10.2
1024 13.3
10000 17.3

異常スコア

$t$ 本のIsolation Treeの平均経路長を

$$ E[h(\bm{x})] = \frac{1}{t}\sum_{j=1}^{t} h_j(\bm{x}) $$

としたとき、異常スコア $s(\bm{x}, \psi)$ は

$$ \begin{equation} s(\bm{x}, \psi) = 2^{-\frac{E[h(\bm{x})]}{c(\psi)}} \end{equation} $$

で定義されます。この式の意味を解釈しましょう。

指数部分 $-E[h(\bm{x})] / c(\psi)$ を分析します。

  • $E[h(\bm{x})] \to 0$(非常に少ない分割で分離): $s \to 2^0 = 1$(異常
  • $E[h(\bm{x})] \to c(\psi)$(平均的な経路長): $s \to 2^{-1} = 0.5$(不明確
  • $E[h(\bm{x})] \to \infty$(非常に多くの分割が必要): $s \to 0$(正常

つまり、異常スコアは $[0, 1]$ の範囲に正規化され、1に近いほど異常、0.5付近は不明確、0に近いほど正常です。

異常スコアの定義

$c(\psi)$ で割ることで、サブサンプルサイズ $\psi$ に依存しない正規化が実現されています。

異常スコアの数式 $s = 2^{-E[h(\bm{x})]/c(\psi)}$ が底を2にしている理由は、Isolation Treeが二分木であることに対応しています。二分木では各ステップでデータ空間が2つに分割されるため、$n$ 個のデータを分離するのに平均 $\log_2 n$ ステップが必要です。底2の指数関数を使うことで、経路長と異常スコアの関係が自然な形になります。

異常スコアの閾値については、通常 $s > 0.5$ を異常と判定するのが一般的ですが、アプリケーションの要求に応じて調整します。偽陽性を減らしたい場合は閾値を上げ(たとえば $s > 0.6$)、見逃しを減らしたい場合は閾値を下げます(たとえば $s > 0.45$)。

理論を理解したところで、Pythonでスクラッチ実装してみましょう。

Pythonでの実装

Isolation Tree と Isolation Forest の実装

import numpy as np
import matplotlib.pyplot as plt

class IsolationTreeNode:
    """Isolation Treeのノード"""
    def __init__(self, left=None, right=None, split_attr=None,
                 split_value=None, size=0):
        self.left = left
        self.right = right
        self.split_attr = split_attr
        self.split_value = split_value
        self.size = size  # 葉ノードのデータ数

def build_itree(X, e=0, l=None):
    """Isolation Treeを再帰的に構築"""
    n, d = X.shape
    if l is None:
        l = int(np.ceil(np.log2(max(n, 2))))

    if e >= l or n <= 1:
        return IsolationTreeNode(size=n)

    # ランダムな特徴量と分割点
    q = np.random.randint(d)
    x_min, x_max = X[:, q].min(), X[:, q].max()
    if x_min == x_max:
        return IsolationTreeNode(size=n)

    p = np.random.uniform(x_min, x_max)

    mask_left = X[:, q] < p
    X_left = X[mask_left]
    X_right = X[~mask_left]

    left_child = build_itree(X_left, e + 1, l)
    right_child = build_itree(X_right, e + 1, l)

    return IsolationTreeNode(left=left_child, right=right_child,
                             split_attr=q, split_value=p)

def path_length(x, node, e=0):
    """データ点xの経路長を計算"""
    if node.left is None and node.right is None:
        # 葉ノード: 残りの期待経路長を加算
        return e + c_func(node.size)

    if x[node.split_attr] < node.split_value:
        return path_length(x, node.left, e + 1)
    else:
        return path_length(x, node.right, e + 1)

def c_func(n):
    """n個のデータに対するBSTの平均経路長"""
    if n <= 1:
        return 0
    if n == 2:
        return 1
    return 2 * (np.log(n - 1) + 0.5772156649) - 2 * (n - 1) / n

class IsolationForest:
    """Isolation Forest"""
    def __init__(self, n_trees=100, subsample_size=256):
        self.n_trees = n_trees
        self.subsample_size = subsample_size
        self.trees = []

    def fit(self, X):
        """学習(木の構築)"""
        n = X.shape[0]
        psi = min(self.subsample_size, n)
        self.psi = psi
        l = int(np.ceil(np.log2(psi)))
        self.trees = []
        for _ in range(self.n_trees):
            idx = np.random.choice(n, size=psi, replace=False)
            tree = build_itree(X[idx], e=0, l=l)
            self.trees.append(tree)

    def anomaly_score(self, X):
        """異常スコアを計算"""
        scores = np.zeros(X.shape[0])
        for i in range(X.shape[0]):
            avg_path = np.mean([path_length(X[i], tree) for tree in self.trees])
            scores[i] = 2 ** (-avg_path / c_func(self.psi))
        return scores

    def predict(self, X, threshold=0.5):
        """予測(1=異常, 0=正常)"""
        scores = self.anomaly_score(X)
        return (scores > threshold).astype(int)

この実装では、c_func が二分探索木の平均経路長 $c(n)$ を計算し、anomaly_score が正規化された異常スコア $s(\bm{x}, \psi) = 2^{-E[h(\bm{x})]/c(\psi)}$ を返します。

合成データでの実験

np.random.seed(42)

# 2次元の合成データ
n_normal = 500
n_anomaly = 20

# 正常データ: 2つのクラスター
cluster1 = np.random.randn(n_normal // 2, 2) * 0.5 + np.array([2, 2])
cluster2 = np.random.randn(n_normal // 2, 2) * 0.5 + np.array([-2, -2])
X_normal = np.vstack([cluster1, cluster2])

# 異常データ: ランダムに散らばる
X_anomaly = np.random.uniform(-6, 6, (n_anomaly, 2))

X_all = np.vstack([X_normal, X_anomaly])
labels = np.array([0]*n_normal + [1]*n_anomaly)

# Isolation Forest の適用
iforest = IsolationForest(n_trees=100, subsample_size=256)
iforest.fit(X_all)
scores = iforest.anomaly_score(X_all)

fig, axes = plt.subplots(1, 3, figsize=(18, 5))

# (a) データと異常スコア
ax = axes[0]
scatter = ax.scatter(X_all[:, 0], X_all[:, 1], c=scores, cmap="RdYlBu_r",
                     s=20, alpha=0.8, edgecolors="gray", linewidth=0.3)
plt.colorbar(scatter, ax=ax, label="Anomaly Score")
ax.set_xlabel("$x_1$", fontsize=12)
ax.set_ylabel("$x_2$", fontsize=12)
ax.set_title("Anomaly Scores (Isolation Forest)", fontsize=13)
ax.grid(True, alpha=0.3)

# (b) 異常スコアのヒストグラム
ax = axes[1]
ax.hist(scores[labels == 0], bins=30, alpha=0.7, color="blue",
        label="Normal", density=True)
ax.hist(scores[labels == 1], bins=15, alpha=0.7, color="red",
        label="Anomaly", density=True)
ax.axvline(0.5, color="green", linestyle="--", linewidth=2, label="s = 0.5")
ax.set_xlabel("Anomaly Score $s$", fontsize=12)
ax.set_ylabel("Density", fontsize=12)
ax.set_title("Score Distribution", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)

# (c) 平均経路長の分布
ax = axes[2]
avg_paths_normal = []
avg_paths_anomaly = []
for i in range(len(X_all)):
    avg_path = np.mean([path_length(X_all[i], tree) for tree in iforest.trees])
    if labels[i] == 0:
        avg_paths_normal.append(avg_path)
    else:
        avg_paths_anomaly.append(avg_path)

ax.hist(avg_paths_normal, bins=30, alpha=0.7, color="blue",
        label="Normal", density=True)
ax.hist(avg_paths_anomaly, bins=15, alpha=0.7, color="red",
        label="Anomaly", density=True)
ax.axvline(c_func(256), color="green", linestyle="--", linewidth=2,
           label=f"$c(\\psi)$ = {c_func(256):.1f}")
ax.set_xlabel("Average Path Length $E[h(x)]$", fontsize=12)
ax.set_ylabel("Density", fontsize=12)
ax.set_title("Path Length Distribution", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("isolation_forest.png", dpi=150, bbox_inches="tight")
plt.show()

このグラフから、Isolation Forestの異常検知の仕組みが直感的に理解できます。

  1. 左図(異常スコアのマップ): データ点を異常スコアで色分けしています。2つのクラスターの中心部(密集領域)は青色(低スコア = 正常)で、クラスターから離れた点は赤色(高スコア = 異常)です。クラスター間の空間に位置する点も中程度のスコアになっています。スコアのグラデーションが滑らかであることから、Isolation Forestが距離に対して連続的な異常度を与えていることがわかります。これはハードな境界を引くのではなく、「どれくらい異常か」という度合いを定量的に評価できることを意味しており、実用上非常に有用です

  2. 中央図(スコアの分布): 正常データ(青)のスコアは0.5未満に集中し、異常データ(赤)のスコアは0.5以上に広がっています。$s = 0.5$ の基準線が正常・異常の自然な境界になっていることがわかります。2つの分布が明確に分離されており、Isolation Forestがこの合成データに対して良好な検出性能を示していることが確認できます。ただし、分布間に一部重なりがあるのは、クラスター近傍に生成された異常データが比較的高い経路長を持つためです

  3. 右図(経路長の分布): 正常データの平均経路長は $c(\psi) \approx 10.2$ 付近に集中しているのに対し、異常データは経路長が短い(= 少ない分割で分離される)ことがわかります。$c(\psi)$(緑の点線)は「ランダムな場合の平均経路長」を表し、これが正規化の基準として機能しています。正常データの経路長が $c(\psi)$ 付近にピークを持つのは、密集した正常データの分離にほぼ $c(\psi)$ ステップが必要であることの反映です

実際に学習した異常スコアを空間全体に描くと、その振る舞いがよく見えます。

異常スコアのヒートマップ

正常データが密集する中心部はスコアが低く(青)、外周ほど高く(赤)なり、孤立した異常(緑の星)が高スコア領域に位置しています。スコアの分布を正常・異常で比べると、両者が明確に分離していることも確認できます。

異常スコアの分布

この実験ではROC-AUCは0.997に達し、孤立した異常をほぼ完璧に検出できました。

ハイパーパラメータの影響

Isolation Forestは比較的少ないハイパーパラメータで動作しますが、その設定がモデルの性能に与える影響を理解しておくことは重要です。

サブサンプルサイズ $\psi$

$\psi$ はIsolation Forestの最も重要なハイパーパラメータです。サブサンプルサイズは「各木がどれだけのデータを見て構築されるか」を決定し、検出性能と計算効率の両方に影響します。

  • $\psi$ が小さすぎる($< 64$): 各木のデータが少なく、安定性が低い。個々の木が見るデータが少なすぎるため、正常データの分布を十分に捉えられず、偽陽性が増加する可能性があります
  • $\psi$ が大きすぎる($> 512$): マスキング効果が発生し、密集した異常が検出されにくくなる。また計算コストも増加します。$c(\psi)$ も大きくなるため、深さ制限 $l$ も増え、木の構築に時間がかかります
  • 推奨値: $\psi = 256$ がデフォルトで、多くのケースで良好に機能する。原論文での実験でもこの値が推奨されています

サブサンプルサイズの効果

図のように、$\psi$ を大きくしても性能は早々に頭打ちになります。少数のサブサンプル(256程度)で十分な検出性能が得られることが、Isolation Forestの計算効率の源泉です。

木の数 $t$

木の数は異常スコアの安定性に影響します。各木はランダムに構築されるため、1本の木だけでは経路長にばらつきがあります。複数の木の平均を取ることで、このばらつきが平均化されスコアが安定します。$t = 100$ で多くの場合十分ですが、$t = 50$ でも大きな性能低下はありません。$t$ を増やすと計算時間は線形に増加しますが、精度の向上は $t$ の増加とともに逓減していき、やがて収束します。ランダムフォレストの分類精度と同様に、100本を超えると改善は微小になります。

特徴量のスケーリング

Isolation Forestの大きな利点は、特徴量のスケーリングが不要なことです。分割点は各特徴量の値の範囲 $[\min, \max]$ からランダムに選ばれるため、スケールの違いは自動的に吸収されます。これは距離ベースの手法(k-NN、One-Class SVM)にはない重要な利点です。

ただし注意点もあります。特徴量の値の範囲が極端に異なる場合、範囲が広い特徴量では分割点が値のない領域に設定されやすくなり、その分割が無駄になる可能性があります。たとえば、ある特徴量が $[0, 1]$ の範囲にほとんどのデータを持つが、1つの外れ値が $1000$ にある場合、$[0, 1000]$ の範囲からランダムに選ばれる分割点の多くが $[1, 1000]$ の「空白領域」に落ち、有効な分割にならないことがあります。このような極端なケースでは、事前に外れ値を除外するか、ロバストなスケーリングを適用することが有効です。

他の手法との比較

Isolation Forestの特性を他の代表的な異常検知手法と比較してみましょう。各手法には得意・不得意があり、問題の特性に応じて使い分けることが重要です。

特性 Isolation Forest Autoencoder One-Class SVM
計算量(学習) $O(t \psi \log \psi)$ $O(n \cdot \text{epochs} \cdot d^2)$ $O(n^2 d)$-$O(n^3)$
スケーリング不要 はい いいえ いいえ
高次元データ 良好 良好 次元の呪い
非線形パターン 良好 優れている カーネル依存
解釈性 経路長の分析 次元ごとの再構成誤差 サポートベクトル
大規模データ 非常に適している 適している 不向き

計算量の比較: Isolation Forestの計算量 $O(t\psi\log\psi)$ はデータ数 $n$ にほぼ依存しないため、100万件のデータでもデフォルト設定で数秒以内に学習が完了します。これに対して、One-Class SVMは $n^2$ から $n^3$ に比例する計算量を持つため、大規模データでは実用的でない場合があります。オートエンコーダはニューラルネットワークの学習が必要ですが、ミニバッチ学習により大規模データにも対応できます。

検出パターンの比較: オートエンコーダは正常データの低次元表現を学習するため、複雑な非線形マニフォールド上の異常も検出できますが、ネットワーク構造やハイパーパラメータの設計に専門知識が必要です。Isolation Forestはほぼデフォルト設定で動作するため、機械学習の専門家でないドメインエキスパートにも使いやすいという実務的な利点があります。

Isolation Forestは計算効率と汎用性のバランスに優れており、異常検知の「最初に試すべき手法」として広く使われています。まずIsolation Forestで異常検知のベースラインを作り、必要に応じてオートエンコーダなどのより複雑な手法に移行するというアプローチが実務では効果的です。

Isolation Forestの限界と発展

Isolation Forestは優れた手法ですが、いくつかの限界も知っておく必要があります。ここでは、その限界と、それを克服するための発展形について解説します。

限界1: 軸並行な分割

Isolation Treeの分割は常に特徴量の軸に平行です。つまり、1つの特徴量の値で分割を行います。このため、データが特徴量の軸に対して斜めの方向に異常性を持つ場合、検出が困難になることがあります。

たとえば、2次元データで正常データが $x_1 = x_2$ の直線状に分布し、異常データがこの直線から大きく外れている場合を考えます。軸並行な分割では、$x_1$ 方向にも $x_2$ 方向にも正常範囲内に見える異常データを分離するために多くの分割が必要になります。

軸並行分割の限界

図のように、斜めに相関した正常データに対して軸並行(縦横)の分割は効率が悪く、相関方向の異常を捉えにくくなります。これを後述のExtended Isolation Forestが改善します。

限界2: 局所的な異常の検出

Isolation Forestはグローバルな分離のしやすさを測定するため、局所的な異常(特定のクラスターの辺縁にあるデータ)の検出が苦手な場合があります。複数のクラスターが異なる密度を持つ場合、低密度クラスターの正常データが高密度クラスターの基準では「異常」に見えてしまうことがあります。

Extended Isolation Forest

上記の軸並行分割の限界を克服するために、Extended Isolation Forest(Hariri et al., 2019)が提案されました。Extended Isolation Forestでは、分割に使う超平面を軸に平行な方向に限定せず、ランダムな方向の超平面を使います。

具体的には、分割の条件を $x_q < p$(1つの特徴量で分割)から、ランダムな法線ベクトル $\bm{n}$ と切片 $p$ を用いた $\bm{n} \cdot \bm{x} < p$ に拡張します。これにより、斜め方向の異常パターンも効率的に検出できるようになります。

実務での使い方のポイント

Isolation Forestを実務で使う際のポイントをまとめておきます。

コンタミネーション率の設定: scikit-learnの IsolationForest では contamination パラメータで異常の割合を指定できます。この値を事前知識に基づいて設定することで、閾値を自動的に決定できます。たとえば、不正取引の割合が全体の1%程度であることがわかっている場合、contamination=0.01 と設定します。

特徴量エンジニアリング: Isolation Forestは特徴量のスケーリングが不要ですが、特徴量の選択は重要です。異常に無関係な特徴量が多いと、ランダムな分割がそれらの特徴量に費やされてしまい、検出性能が低下します。ドメイン知識に基づいた特徴量の選択が性能向上に寄与します。

結果の解釈: 異常と判定されたデータについて「なぜ異常なのか」を説明したい場合、各Isolation Treeで分離に使われた特徴量と分割値を分析することで、ある程度の解釈が可能です。頻繁に分離に使われた特徴量は、そのデータ点の異常性に大きく寄与していると考えられます。

これらの限界と実用上のポイントを理解しておくことで、Isolation Forestを適切に活用し、必要に応じて他の手法と組み合わせることができます。

まとめ

本記事では、Isolation Forestの理論と実装について解説しました。

  • Isolation Forestは「異常は分離しやすい」という直感に基づき、ランダムな分割で各データ点の経路長を測定する
  • 異常スコアは $s(\bm{x}, \psi) = 2^{-E[h(\bm{x})]/c(\psi)}$ で計算され、1に近いほど異常0に近いほど正常
  • $c(\psi)$ はBSTの平均経路長で、スコアの正規化に使われる
  • サブサンプリングにより $O(t \psi \log \psi)$ の計算量で動作し、大規模データに適している
  • 特徴量のスケーリングが不要で、高次元データにもロバスト

次のステップとして、以下の記事も参考にしてください。