K_DM Book
用語集
対象者 標準
対象者別の入口 →

対象者の設定はこのブラウザの中にだけ保存されます。

言語 JA

Isolation Forest

中級

04.17.2

Isolation Forest

最終更新 2020-02-12 読了時間 6 分
まとめ
Isolation Forest は、特徴量をランダムに選んでランダムな閾値で分割する操作を繰り返し、「何回の分割で 1 点だけに孤立するか」を異常の指標にする手法です。密集した点は何度切っても仲間が残るので深くまで分割が続き、外れた点は数回で孤立するため、浅い位置で孤立した点ほど異常スコアが高くなります。このページでは 365 日の人工データに 6 日分の異常値(特徴1 を 2.5、特徴2 を 0.3 に書き換え)を混ぜ、pd.get_dummies で曜日などのカテゴリ列をダミー化してから IsolationForest(n_estimators=100, contamination=0.01) で検出します。最後に plot_tree で 1 本目の木を描き、異常点が「特徴1 が大きい」という分岐で孤立していることを確かめます。

直感 #

教室の 30 人を「身長 165 cm 以上か」「誕生月が 6 月より前か」のように、適当な質問でランダムに 2 つに分けていくとします。大半の生徒は似た答えになるので、1 人だけに絞るには質問を 5〜6 回繰り返す必要があります。ところが身長 200 cm の生徒がいれば、「身長 190 cm 以上か」の 1 問で孤立します。Isolation Forest はこの「孤立するまでの質問数」をたくさんの木で平均し、少ない質問で孤立する点を異常と判定します。ラベルは要らず、距離や密度の計算もしないので、列が 25 本あっても 365 行なら 1 秒で終わります。

何が起きているか(ステップで見る) #

コードを読む前に、この手法が図の上で何をしているかを追ってみましょう。「再生」を押すか、スライダーを動かすと 1 手順ずつ進みます。

見るポイント:少ない回数で孤立する点ほど異常。外れ値は 2〜3 回、密集した点は何度切っても仲間が残る。

Isolation Forest #

Isolation Forest を使って異常値を検出してみます。時系列データの中に異常値を混ぜ、それが検出できるかどうか検証します。最後に異常値と判定したルールを可視化します。

1
2
3
4
5
6
import numpy as np
import pandas as pd
import seaborn as sns
from matplotlib import pyplot as plt
from sklearn.ensemble import IsolationForest
from sklearn.tree import plot_tree

人工データの作成 #

[11, 49, 149, 240, 300, 310] の日付に異常値を混ぜておきます。月や曜日のカテゴリ変数、天気の記号など数値でない特徴も含むデータで、数値の 3 列だけをプロットします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
rs = np.random.RandomState(365)
dates = pd.date_range("1 1 2016", periods=365, freq="D")

data = {}
data["月"] = [d.strftime("%m") for d in dates]
data["曜日"] = [d.strftime("%A") for d in dates]
data["特徴1"] = [np.sin(d.day / 50) + np.random.rand() for d in dates]
data["特徴2"] = [np.cos(d.day / 50) + np.random.rand() for d in dates]
data["特徴3"] = [3 * np.random.rand() + np.log(d.dayofyear) * 0.03 for d in dates]
data["特徴4"] = [np.random.choice(["☀", "☂", "☁"]) for d in dates]
column_names = list(data.keys())

anomaly_index = [11, 49, 149, 240, 300, 310]
anomaly_dates = [dates[i] for i in anomaly_index]
for i in anomaly_index:
    data["特徴1"][i] = 2.5
    data["特徴2"][i] = 0.3

data = pd.DataFrame(data, index=dates)

plt.figure(figsize=(10, 4))
sns.lineplot(data=data, palette="tab10", linewidth=2.5)
<Axes: >

人工データの作成の図

表の中身を確認します。

1
data

月曜日特徴1特徴2特徴3特徴4
2016-01-0101Friday0.4326781.6450271.118289☀
2016-01-0201Saturday0.0994631.6457340.289383☀
2016-01-0301Sunday0.2974901.6139582.499115☀
2016-01-0401Monday1.0430771.0299470.484240☀
2016-01-0501Tuesday0.1847971.3552652.795718☁
.....................
2016-12-2612Monday0.9142071.5803300.617349☂
2016-12-2712Tuesday1.2297531.5995992.605319☀
2016-12-2812Wednesday0.9361001.4083782.540507☀
2016-12-2912Thursday1.1565751.4286010.831889☀
2016-12-3012Friday1.3831851.5446291.416885☁

365 rows × 6 columns

カテゴリ変数の変換 #

「曜日」のような特徴を Isolation Forest で扱うために、pd.get_dummies で 0/1 のダミー変数に変換します。

1
2
X = pd.get_dummies(data)
X

特徴1特徴2特徴3月_01月_02...曜日_Thursday曜日_Tuesday曜日_Wednesday特徴4_☀特徴4_☁
2016-01-010.4326781.6450271.1182891000000...
2016-01-020.0994631.6457340.2893831000000...
2016-01-030.2974901.6139582.4991151000000...
2016-01-041.0430771.0299470.4842401000000...
2016-01-050.1847971.3552652.7957181000000...
....................................
2016-12-260.9142071.5803300.6173490000000...
2016-12-271.2297531.5995992.6053190000000...
2016-12-280.9361001.4083782.5405070000000...
2016-12-291.1565751.4286010.8318890000000...
2016-12-301.3831851.5446291.4168850000000...

365 rows × 25 columns

Isolation Forestの作成 #

100 本の木を作り、全 365 行のスコアを計算して「異常かどうか」の真偽値と連続値のスコアを data に書き戻します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ilf = IsolationForest(
    n_estimators=100,
    max_samples="auto",
    contamination=0.01,
    max_features=5,
    bootstrap=False,
    random_state=np.random.RandomState(365),
)

ilf.fit(X)

data["is_anomaly"] = ilf.predict(X) < 0
data["anomaly_score"] = ilf.decision_function(X)

検出した日付と正解の比較 #

異常値として検出したタイミングと、仕込んだ正解の位置を縦線で比べます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
plt.figure(figsize=(10, 4))
plt.title("検出箇所")
sns.lineplot(data=data[column_names], palette="tab10", linewidth=2.5)
for d in data[data["is_anomaly"]].index:
    plt.axvline(x=d, color="red", linewidth=4)

plt.figure(figsize=(10, 4))
plt.title("正解")
sns.lineplot(data=data[column_names], palette="tab10", linewidth=2.5)
for d in anomaly_dates:
    plt.axvline(x=d, color="black", linewidth=4)

異常値として検出したタイミングと正解を比較しますの図

異常値として検出したタイミングと正解を比較しますの図

異常値のルール #

100 本のうち 1 本目の木を深さ 3 まで描きます。サンプル数(samples)が 1 の葉が一番右にあり、それが特徴1 による分岐の先にあることが分かります。実際、今回の異常値は特徴1 が 2.5 と大きすぎる値のときに孤立しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
plt.figure(figsize=(24, 8))
plot_tree(
    ilf.estimators_[0],
    feature_names=column_names,
    filled=True,
    fontsize=13,
    max_depth=3,
    precision=2,
    rounded=True,
)

plt.show()

実際、今回の異常値は特徴1が大きすぎる値のときに異常値になりやすいですの図

使いどころと落とし穴 #

Isolation Forest は距離を計算しないので列数が増えても速く、スケールの違う列を混ぜても動きますが、このページのように 0/1 のダミー列が 22 本もあると、分割に選ばれる列の大半がダミーになって異常が埋もれます。カテゴリ列は本当に必要なものだけ残すか、max_features を全列にして特徴1・2 が毎回候補に入るようにします。contamination は「何割を異常と呼ぶか」を決めるだけで検出力は変えないので、異常の割合が分からないときは decision_function のスコアを見てヒストグラムの切れ目で閾値を決めます。時系列に使うときは、このページのように各時点を独立な行として扱うため、「前日との差」や「移動平均からのずれ」を列に足さないと、季節的に高い値を異常と誤判定しがちです。