Isolation Forestの理論と実装をわかりやすく解説

異常検知(Anomaly Detection)は、データの中から「通常とは異なる」パターンを見つけ出すタスクであり、不正検知、故障予知、ネットワーク侵入検知など幅広い分野で応用されています。多くの異常検知手法が「正常データのモデルを構築し、そこから逸脱するデータを異常と見なす」アプローチをとる中、Isolation Forest は全く異なる発想に基づいています。

Isolation Forest は、「異常なデータは少数で、かつ特徴空間上で孤立している」という性質を直接利用します。異常点は少ない分割回数で他のデータから分離(isolate)できるという直感に基づき、ランダムな分割による木構造のパス長を異常スコアとして用いるのが特徴です。

本記事の内容

  • Isolation Forest の基本原理とアルゴリズム
  • iTree(Isolation Tree)の構築手順
  • パス長 $h(\bm{x})$ と異常スコア $s(\bm{x}, n)$ の数学的導出
  • 正規化定数 $c(n)$ の調和数による計算
  • スコアの解釈とサブサンプリングの効果
  • Extended Isolation Forest の概要
  • Python でのスクラッチ実装と scikit-learn との比較

前提知識

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

Isolation Forest の基本原理

異常点の性質

異常検知において、異常点(anomaly)は次の2つの性質を持つと仮定されます。

  1. 少数性(minority): 異常点はデータ全体の中でごく少数である
  2. 孤立性(isolation): 異常点は正常データの密集した領域から離れている

Isolation Forest はこれらの性質を直接利用します。特徴空間をランダムに再帰的に分割していくと、孤立した異常点は少ない分割回数で他のデータから分離できるのに対し、正常データの密集した領域内のデータ点は多くの分割が必要になります。

直感的な理解

2次元平面上にデータ点が散布されている状況を考えましょう。密集した正常データの中にポツンと離れた異常点があるとします。ランダムに特徴量を選び、ランダムに分割値を選んで空間を2分割していくと、孤立した異常点は1〜2回の分割で他のデータから分離されます。一方、密集領域のデータ点は何度も分割を繰り返さないと個々のデータ点を分離できません。

この「分離に必要な分割回数」がIsolation Forestの異常スコアの基礎になります。

iTree(Isolation Tree)の構築

アルゴリズム

iTree は以下のアルゴリズムで構築されます。

Algorithm: iTree(X, e, l)
Input: X (データ集合), e (現在の木の深さ), l (深さ上限)
Output: iTree のノード

1: if e ≥ l or |X| ≤ 1 then
2:    return ExternalNode(Size = |X|)
3: else
4:    q ← X の特徴量からランダムに1つ選択
5:    p ← [min(X_q), max(X_q)] の範囲から一様ランダムに分割値を選択
6:    X_l ← {x ∈ X : x_q < p}     // 左部分木のデータ
7:    X_r ← {x ∈ X : x_q ≥ p}     // 右部分木のデータ
8:    return InternalNode(
9:       Left = iTree(X_l, e+1, l),
10:      Right = iTree(X_r, e+1, l),
11:      SplitAttribute = q,
12:      SplitValue = p
13:   )

深さ上限 $l$ の設定

深さ上限 $l$ はサブサンプルサイズ $\psi$ に基づいて設定されます。

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

これは、バランスの取れた二分木の平均的な高さに対応します。異常点のパス長は短いため、深さ上限を超える前に分離されます。正常点のパス長は長くなりますが、正確な値は必要なく、相対的に長いことがわかれば十分です。

iForest(Isolation Forest)の構成

iForest はアンサンブル手法であり、複数の iTree から構成されます。

Algorithm: iForest(X, t, ψ)
Input: X (全データ集合), t (木の本数), ψ (サブサンプルサイズ)
Output: t 本の iTree のアンサンブル

1: Forest ← ∅
2: for i = 1 to t do
3:    X' ← X から ψ 個をサンプリング(非復元抽出)
4:    l ← ⌈log₂(ψ)⌉
5:    Forest ← Forest ∪ {iTree(X', 0, l)}
6: return Forest

推奨されるデフォルトパラメータは $t = 100$(木の本数)、$\psi = 256$(サブサンプルサイズ)です。

パス長の定義

パス長 $h(\bm{x})$

あるデータ点 $\bm{x}$ の iTree におけるパス長 $h(\bm{x})$ は、$\bm{x}$ がルートノードから外部ノード(葉ノード)に到達するまでに通過するエッジの数として定義されます。

外部ノードに到達する前に深さ上限 $l$ に達した場合は、そのノード内に残されたデータ数 $|X|$ から推定されるパス長の補正項 $c(|X|)$ を加えます。これは、もし分割を続けていたらどれだけ深くなるかの期待値です。

$$ h(\bm{x}) = e + c(\text{Size}) $$

ここで $e$ は外部ノードに到達するまでの実際の深さ、Size は外部ノード内のデータ数です。Size = 1 の場合は $c(1) = 0$ とします。

平均パス長 $E[h(\bm{x})]$

iForest は $t$ 本の iTree のアンサンブルなので、$\bm{x}$ の平均パス長を計算します。

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

ここで $h_i(\bm{x})$ は $i$ 番目の iTree における $\bm{x}$ のパス長です。

正規化定数 $c(n)$ の導出

Binary Search Tree (BST) との対応

正規化定数 $c(n)$ は、$n$ 個のデータを含むBST(Binary Search Tree)における探索の平均パス長に基づいて定義されます。これはiTreeの構造がBSTに類似していることに由来します。

BSTにおいて、$n$ 個のキーを含む木でランダムなキーを探索する際の平均比較回数(平均パス長)は、以下の漸化式で定義されます。

漸化式の導出

$n$ 個のデータを持つBSTの平均パス長を $c(n)$ とすると、ルートノードでの分割により、左右の部分木にはそれぞれ $0, 1, \dots, n-1$ 個のデータが入ります。各分割が等確率で起こると仮定すると、

$$ c(n) = \frac{2}{n}\sum_{i=1}^{n-1} c(i) + \frac{2(n-1)}{n} $$

ただし $c(0) = 0, c(1) = 0$ です。

第2項の $\frac{2(n-1)}{n}$ は、根から各外部ノードへの遷移に1ステップ加わることの補正です。

調和数による閉じた形の導出

上記の漸化式を解きます。$c(n)$ に $n$ を掛けると、

$$ nc(n) = 2\sum_{i=1}^{n-1} c(i) + 2(n-1) $$

同様に $n-1$ の場合を考えると、

$$ (n-1)c(n-1) = 2\sum_{i=1}^{n-2} c(i) + 2(n-2) $$

両者の差をとると、

$$ \begin{align} nc(n) – (n-1)c(n-1) &= 2c(n-1) + 2 \\ nc(n) &= (n+1)c(n-1) + 2 \end{align} $$

両辺を $n(n+1)$ で割ると、

$$ \frac{c(n)}{n+1} = \frac{c(n-1)}{n} + \frac{2}{n(n+1)} $$

ここで $a_n = \frac{c(n)}{n+1}$ とおくと、

$$ a_n = a_{n-1} + \frac{2}{n(n+1)} $$

部分分数分解により、

$$ \frac{2}{n(n+1)} = 2\left(\frac{1}{n} – \frac{1}{n+1}\right) $$

$a_1 = \frac{c(1)}{2} = 0$ から出発してテレスコーピングすると、

$$ \begin{align} a_n &= a_1 + \sum_{k=2}^{n} \frac{2}{k(k+1)} \\ &= 2\sum_{k=2}^{n} \left(\frac{1}{k} – \frac{1}{k+1}\right) \\ &= 2\left(\frac{1}{2} – \frac{1}{n+1}\right) \\ &= 1 – \frac{2}{n+1} \end{align} $$

したがって、

$$ c(n) = (n+1)a_n = (n+1)\left(1 – \frac{2}{n+1}\right) = n + 1 – 2 = n – 1 $$

しかし、これは正確ではありません。実はBSTの平均パス長は調和数 $H_n$ を用いて次のように表されます。BSTにおいて、ランダムな挿入順序での平均パス長(失敗探索の平均比較回数)は、

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

ここで $H(i)$ は第 $i$ 調和数です。

$$ H(i) = \sum_{k=1}^{i} \frac{1}{k} = \ln(i) + \gamma + O\left(\frac{1}{i}\right) $$

$\gamma \approx 0.5772$ はEuler-Mascheroni定数です。

導出の詳細

$n$ 個の要素を持つBSTでの失敗探索(unsuccessful search)の平均パス長は、内部パス長(Internal Path Length)$I(n)$ と外部パス長(External Path Length)$E(n)$ の関係から求められます。

二分木において、外部パス長と内部パス長の間には次の関係が成り立ちます。

$$ E(n) = I(n) + 2n $$

ランダムBSTの平均内部パス長は

$$ E[I(n)] = 2n H(n) – 4n + 2 = 2n\sum_{k=1}^{n}\frac{1}{k} – 4n + 2 $$

よって平均外部パス長は

$$ E[E(n)] = 2nH(n) – 4n + 2 + 2n = 2nH(n) – 2n + 2 $$

失敗探索の平均パス長は、外部ノード数 $n+1$ で割ったものに等しいですが、Isolation Forest の文脈では $c(n)$ を以下の実用的な近似式で定義します。

$$ \boxed{c(n) = 2H(n-1) – \frac{2(n-1)}{n}} $$

$n$ が大きい場合、$H(n-1) \approx \ln(n-1) + \gamma$ より、

$$ c(n) \approx 2\ln(n-1) + 2\gamma – \frac{2(n-1)}{n} \approx 2\ln(n-1) + 0.1544 $$

異常スコアの定義と導出

異常スコア $s(\bm{x}, n)$

データ点 $\bm{x}$ の異常スコアは、平均パス長 $E[h(\bm{x})]$ を正規化定数 $c(n)$ で正規化したものとして定義されます。

$$ \boxed{s(\bm{x}, n) = 2^{-\frac{E[h(\bm{x})]}{c(n)}}} $$

この定義の動機を理解しましょう。

スコアの3つの極限

場合1: $E[h(\bm{x})] \to 0$(非常に短いパス長)

$$ s(\bm{x}, n) = 2^{-0/c(n)} = 2^0 = 1 $$

パス長が非常に短い、つまりすぐに分離されるデータ点は異常スコアが1に近づきます。これは明確な異常を意味します。

場合2: $E[h(\bm{x})] \to c(n)$(平均的なパス長)

$$ s(\bm{x}, n) = 2^{-c(n)/c(n)} = 2^{-1} = 0.5 $$

パス長がBSTの平均パス長と同じ場合、スコアは0.5になります。これは異常とも正常とも判断しにくいグレーゾーンです。

場合3: $E[h(\bm{x})] \to n – 1$(最大パス長)

$$ s(\bm{x}, n) = 2^{-(n-1)/c(n)} \to 0 \quad (n \to \infty) $$

パス長が非常に長い、つまり分離が困難なデータ点はスコアが0に近づきます。これは密集した正常データを意味します。

スコアの解釈のまとめ

スコア範囲 パス長 解釈
$s \approx 1$ 非常に短い 明確な異常点
$s > 0.5$ 平均より短い 異常の可能性あり
$s \approx 0.5$ 平均的 判断困難(境界)
$s < 0.5$ 平均より長い 正常の可能性が高い
$s \approx 0$ 非常に長い 明確な正常点(密集)

サブサンプリングの効果

Isolation Forest の重要な設計要素はサブサンプリングです。全データ $n$ 個からサブサンプル $\psi$ 個を抽出して各 iTree を構築します。

スワンプ(Swamping)問題の解消

スワンプとは、正常データが異常として誤検知される問題です。データ数が多い場合、密集した正常データの「端」にあるデータ点が正常データの塊に「飲み込まれ」て異常と判断されることがあります。

サブサンプリングにより、各iTreeは少数のデータのみで構築されるため、密集領域の影響が軽減され、スワンプが起こりにくくなります。

マスキング(Masking)問題の解消

マスキングとは、異常点が互いに近くに存在する場合(異常クラスタ)、互いをマスクし合って検出が困難になる問題です。

サブサンプリングにより、各iTreeには異常クラスタの一部しか含まれないため、個々の異常点がより孤立した形で現れ、検出しやすくなります。

サブサンプルサイズ $\psi$ の選択

サブサンプルサイズ $\psi$ は重要なハイパーパラメータです。

  • $\psi$ が小さすぎる: 木の構造が不安定になり、スコアの分散が大きくなる
  • $\psi$ が大きすぎる: スワンプ・マスキング問題が顕在化する
  • 推奨値: $\psi = 256$ は多くの場合で良好な性能を示す

Extended Isolation Forest

標準 Isolation Forest の制限

標準の Isolation Forest は、各分割で1つの特徴量に対して軸平行な分割を行います。このため、特徴量間の相関が強いデータでは最適な分割ができない場合があります。

例えば、2次元データが $y = x$ の直線に沿って分布している場合、軸平行な分割ではこの構造を効率的に捉えることができません。

Extended Isolation Forest のアイデア

Extended Isolation Forest(Hariri et al., 2019)は、軸平行な分割の代わりにランダムな超平面で分割を行います。

分割条件は、ランダムな法線ベクトル $\bm{n}$ とランダムなオフセット $p$ を用いて、

$$ \bm{n} \cdot \bm{x} < p $$

として定義されます。ここで $\bm{n}$ の各成分は標準正規分布から生成されます。

この拡張により、データの局所的な構造をより柔軟に捉えることができ、特に高次元データや相関のあるデータで性能が向上します。

Python でのスクラッチ実装

iTree と iForest の実装

import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import roc_auc_score, roc_curve

# ===== 1. Isolation Tree ノードの定義 =====
class ITreeNode:
    """Isolation Tree のノード"""
    def __init__(self, is_external=False, size=0, left=None, right=None,
                 split_attr=None, split_value=None):
        self.is_external = is_external
        self.size = size
        self.left = left
        self.right = right
        self.split_attr = split_attr
        self.split_value = split_value

def build_itree(X, current_depth, depth_limit):
    """Isolation Tree を再帰的に構築"""
    n_samples, n_features = X.shape

    # 終了条件: 深さ上限に到達 or データが1点以下
    if current_depth >= depth_limit or n_samples <= 1:
        return ITreeNode(is_external=True, size=n_samples)

    # ランダムに特徴量を選択
    split_attr = np.random.randint(n_features)

    # 選択した特徴量の範囲内でランダムに分割値を選択
    x_min = X[:, split_attr].min()
    x_max = X[:, split_attr].max()

    # 全て同じ値の場合は外部ノードを返す
    if x_min == x_max:
        return ITreeNode(is_external=True, size=n_samples)

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

    # データを分割
    left_mask = X[:, split_attr] < split_value
    right_mask = ~left_mask

    # 再帰的に部分木を構築
    left_tree = build_itree(X[left_mask], current_depth + 1, depth_limit)
    right_tree = build_itree(X[right_mask], current_depth + 1, depth_limit)

    return ITreeNode(
        is_external=False,
        left=left_tree,
        right=right_tree,
        split_attr=split_attr,
        split_value=split_value
    )

# ===== 2. 正規化定数 c(n) の計算 =====
def harmonic_number(n):
    """第n調和数を計算"""
    if n <= 0:
        return 0
    return np.sum(1.0 / np.arange(1, n + 1))

def c_factor(n):
    """正規化定数 c(n) = 2H(n-1) - 2(n-1)/n"""
    if n <= 1:
        return 0
    if n == 2:
        return 1
    return 2.0 * harmonic_number(n - 1) - 2.0 * (n - 1) / n

# 正規化定数の値の確認
for n in [2, 5, 10, 50, 100, 256, 1000]:
    print(f"c({n}) = {c_factor(n):.4f}")
# ===== 3. パス長の計算 =====
def path_length(x, tree, current_depth=0):
    """データ点 x の iTree におけるパス長を計算"""
    if tree.is_external:
        # 外部ノード: 現在の深さ + 残りデータによる期待パス長
        return current_depth + c_factor(tree.size)

    # 内部ノード: 分割条件に基づいて左右に進む
    if x[tree.split_attr] < tree.split_value:
        return path_length(x, tree.left, current_depth + 1)
    else:
        return path_length(x, tree.right, current_depth + 1)

# ===== 4. Isolation Forest クラス =====
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 = []
        self.c_n = None

    def fit(self, X):
        """モデルの学習(iTree のアンサンブルを構築)"""
        n_samples = X.shape[0]
        psi = min(self.subsample_size, n_samples)
        depth_limit = int(np.ceil(np.log2(psi)))
        self.c_n = c_factor(psi)

        self.trees = []
        for _ in range(self.n_trees):
            # サブサンプリング(非復元抽出)
            indices = np.random.choice(n_samples, size=psi, replace=False)
            X_sub = X[indices]

            # iTree の構築
            tree = build_itree(X_sub, 0, depth_limit)
            self.trees.append(tree)

        return self

    def anomaly_score(self, X):
        """異常スコアを計算: s(x, n) = 2^{-E[h(x)]/c(n)}"""
        scores = np.zeros(X.shape[0])
        for i in range(X.shape[0]):
            # 全ての木での平均パス長
            mean_path = np.mean([
                path_length(X[i], tree) for tree in self.trees
            ])
            # 異常スコア
            scores[i] = 2 ** (-mean_path / self.c_n)
        return scores

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

print("Isolation Forest クラスの定義完了")
# ===== 5. 2次元データでの異常検知 =====
np.random.seed(42)

# 正常データ: 2つのガウス分布クラスタ
n_normal = 300
X_normal1 = np.random.randn(n_normal // 2, 2) * 0.5 + np.array([2, 2])
X_normal2 = np.random.randn(n_normal // 2, 2) * 0.5 + np.array([-2, -2])
X_normal = np.vstack([X_normal1, X_normal2])

# 異常データ: 広い範囲にランダムに散布
n_anomaly = 30
X_anomaly = np.random.uniform(-6, 6, size=(n_anomaly, 2))

# データの結合
X_all = np.vstack([X_normal, X_anomaly])
y_true = 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) 元データ
axes[0].scatter(X_normal[:, 0], X_normal[:, 1], c='blue', s=10, alpha=0.6, label='Normal')
axes[0].scatter(X_anomaly[:, 0], X_anomaly[:, 1], c='red', s=30, marker='x', label='Anomaly')
axes[0].set_title('Original Data')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# (b) 異常スコアのヒートマップ
xx, yy = np.meshgrid(np.linspace(-7, 7, 100), np.linspace(-7, 7, 100))
X_grid = np.column_stack([xx.ravel(), yy.ravel()])
scores_grid = iforest.anomaly_score(X_grid)
Z = scores_grid.reshape(xx.shape)

contour = axes[1].contourf(xx, yy, Z, levels=20, cmap='RdYlBu_r')
plt.colorbar(contour, ax=axes[1], label='Anomaly Score')
axes[1].scatter(X_normal[:, 0], X_normal[:, 1], c='blue', s=5, alpha=0.5)
axes[1].scatter(X_anomaly[:, 0], X_anomaly[:, 1], c='red', s=20, marker='x')
axes[1].set_title('Anomaly Score Heatmap')
axes[1].grid(True, alpha=0.3)

# (c) スコアのヒストグラム
axes[2].hist(scores[y_true == 0], bins=30, alpha=0.7, label='Normal', density=True, color='blue')
axes[2].hist(scores[y_true == 1], bins=30, alpha=0.7, label='Anomaly', density=True, color='red')
axes[2].axvline(x=0.5, color='black', linestyle='--', label='Threshold=0.5')
axes[2].set_xlabel('Anomaly Score')
axes[2].set_ylabel('Density')
axes[2].set_title('Score Distribution')
axes[2].legend()
axes[2].grid(True, alpha=0.3)

plt.suptitle('Isolation Forest: 2D Anomaly Detection', fontsize=14)
plt.tight_layout()
plt.show()
# ===== 6. scikit-learn との比較 =====
from sklearn.ensemble import IsolationForest as SklearnIF

# scikit-learn の Isolation Forest
sklearn_if = SklearnIF(n_estimators=100, max_samples=256, random_state=42,
                        contamination=0.1)
sklearn_if.fit(X_all)
sklearn_scores = -sklearn_if.score_samples(X_all)  # 符号を反転(高いほど異常)
sklearn_scores_normalized = (sklearn_scores - sklearn_scores.min()) / (
    sklearn_scores.max() - sklearn_scores.min()
)

# ROC曲線の比較
fpr_custom, tpr_custom, _ = roc_curve(y_true, scores)
fpr_sklearn, tpr_sklearn, _ = roc_curve(y_true, sklearn_scores_normalized)

auc_custom = roc_auc_score(y_true, scores)
auc_sklearn = roc_auc_score(y_true, sklearn_scores_normalized)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# ROC曲線
axes[0].plot(fpr_custom, tpr_custom, 'b-', lw=2, label=f'Custom IF (AUC={auc_custom:.4f})')
axes[0].plot(fpr_sklearn, tpr_sklearn, 'r--', lw=2, label=f'sklearn IF (AUC={auc_sklearn:.4f})')
axes[0].plot([0, 1], [0, 1], 'k--', lw=1, alpha=0.5)
axes[0].set_xlabel('False Positive Rate')
axes[0].set_ylabel('True Positive Rate')
axes[0].set_title('ROC Curve Comparison')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# スコアの散布図(2手法の比較)
axes[1].scatter(scores[y_true == 0], sklearn_scores_normalized[y_true == 0],
                c='blue', s=10, alpha=0.5, label='Normal')
axes[1].scatter(scores[y_true == 1], sklearn_scores_normalized[y_true == 1],
                c='red', s=30, marker='x', label='Anomaly')
axes[1].set_xlabel('Custom IF Score')
axes[1].set_ylabel('sklearn IF Score (normalized)')
axes[1].set_title('Score Comparison: Custom vs sklearn')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

print(f"Custom IF AUC: {auc_custom:.4f}")
print(f"sklearn IF AUC: {auc_sklearn:.4f}")
# ===== 7. 高次元データでの異常検知 =====
np.random.seed(123)
n_features = 10

# 正常データ: 高次元ガウス分布
n_normal_hd = 500
X_normal_hd = np.random.randn(n_normal_hd, n_features) * 0.5

# 異常データ: 一部の特徴量に大きな値
n_anomaly_hd = 50
X_anomaly_hd = np.random.randn(n_anomaly_hd, n_features) * 0.5
# 異常点はランダムな2-3特徴量に大きな値を持つ
for i in range(n_anomaly_hd):
    n_corrupt = np.random.randint(2, 4)
    corrupt_features = np.random.choice(n_features, n_corrupt, replace=False)
    X_anomaly_hd[i, corrupt_features] += np.random.choice([-1, 1], n_corrupt) * np.random.uniform(3, 5, n_corrupt)

X_hd = np.vstack([X_normal_hd, X_anomaly_hd])
y_true_hd = np.array([0] * n_normal_hd + [1] * n_anomaly_hd)

# 異常検知
iforest_hd = IsolationForest(n_trees=100, subsample_size=256)
iforest_hd.fit(X_hd)
scores_hd = iforest_hd.anomaly_score(X_hd)

# scikit-learnとの比較
sklearn_if_hd = SklearnIF(n_estimators=100, max_samples=256, random_state=42)
sklearn_if_hd.fit(X_hd)
sklearn_scores_hd = -sklearn_if_hd.score_samples(X_hd)
sklearn_scores_hd_norm = (sklearn_scores_hd - sklearn_scores_hd.min()) / (
    sklearn_scores_hd.max() - sklearn_scores_hd.min()
)

# ROC曲線
fpr_c_hd, tpr_c_hd, _ = roc_curve(y_true_hd, scores_hd)
fpr_s_hd, tpr_s_hd, _ = roc_curve(y_true_hd, sklearn_scores_hd_norm)
auc_c_hd = roc_auc_score(y_true_hd, scores_hd)
auc_s_hd = roc_auc_score(y_true_hd, sklearn_scores_hd_norm)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

axes[0].plot(fpr_c_hd, tpr_c_hd, 'b-', lw=2, label=f'Custom IF (AUC={auc_c_hd:.4f})')
axes[0].plot(fpr_s_hd, tpr_s_hd, 'r--', lw=2, label=f'sklearn IF (AUC={auc_s_hd:.4f})')
axes[0].plot([0, 1], [0, 1], 'k--', lw=1, alpha=0.5)
axes[0].set_xlabel('False Positive Rate')
axes[0].set_ylabel('True Positive Rate')
axes[0].set_title(f'ROC Curve ({n_features}D Data)')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# スコア分布
axes[1].hist(scores_hd[y_true_hd == 0], bins=30, alpha=0.7, label='Normal',
             density=True, color='blue')
axes[1].hist(scores_hd[y_true_hd == 1], bins=30, alpha=0.7, label='Anomaly',
             density=True, color='red')
axes[1].set_xlabel('Anomaly Score')
axes[1].set_ylabel('Density')
axes[1].set_title(f'Score Distribution ({n_features}D)')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

print(f"\n高次元データ({n_features}次元)の結果:")
print(f"  Custom IF AUC: {auc_c_hd:.4f}")
print(f"  sklearn IF AUC: {auc_s_hd:.4f}")
# ===== 8. サブサンプルサイズの影響分析 =====
subsample_sizes = [32, 64, 128, 256, 512]
auc_results = []

for psi in subsample_sizes:
    aucs = []
    for trial in range(10):
        iforest_psi = IsolationForest(n_trees=100, subsample_size=psi)
        iforest_psi.fit(X_all)
        scores_psi = iforest_psi.anomaly_score(X_all)
        aucs.append(roc_auc_score(y_true, scores_psi))
    auc_results.append({
        'psi': psi,
        'mean_auc': np.mean(aucs),
        'std_auc': np.std(aucs)
    })

# 結果の可視化
psis = [r['psi'] for r in auc_results]
mean_aucs = [r['mean_auc'] for r in auc_results]
std_aucs = [r['std_auc'] for r in auc_results]

fig, ax = plt.subplots(figsize=(8, 5))
ax.errorbar(psis, mean_aucs, yerr=std_aucs, marker='o', capsize=5, linewidth=2)
ax.set_xlabel('Subsample Size (ψ)', fontsize=12)
ax.set_ylabel('AUC-ROC', fontsize=12)
ax.set_title('Effect of Subsample Size on Detection Performance', fontsize=14)
ax.set_xscale('log', base=2)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

print("\nサブサンプルサイズと検出性能:")
print(f"{'ψ':<8} {'平均AUC':<12} {'標準偏差':<12}")
print("-" * 32)
for r in auc_results:
    print(f"{r['psi']:<8} {r['mean_auc']:<12.4f} {r['std_auc']:<12.4f}")

まとめ

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

  • 基本原理: 異常点は少数で孤立しているため、ランダムな分割で少ないステップで分離できる
  • iTreeの構築: 特徴量と分割値をランダムに選び、再帰的にデータを分割する
  • 異常スコア: $s(\bm{x}, n) = 2^{-E[h(\bm{x})]/c(n)}$ で定義され、$s \approx 1$ が異常、$s \approx 0.5$ が境界、$s \approx 0$ が正常を示す
  • 正規化定数: $c(n) = 2H(n-1) – 2(n-1)/n$ はBSTの平均パス長に基づく
  • サブサンプリング: スワンプ・マスキング問題を解消し、計算効率も向上させる
  • スクラッチ実装: scikit-learn と同等のAUC性能を達成できることを確認した

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