Ml-tips

【LightGBM】feature_importanceのsplitとgainの違い(デフォルトの罠)

はじめに

LightGBMにはfeature_importanceという、どの特徴量が効いているかを教えてくれる便利な機能があります。 ですがこの重要度、importance_typeという引数によって計算方法が2種類あり、splitgainとで測っているものが違います。この違いを意識せずデフォルトのまま使っていると、「無関係なノイズ特徴量が最重要」という誤った結論を出しかねません。

今回はこのsplitgainの違いを、実際にランキングがひっくり返る例で見ていきます。 なお同じLightGBMの機能として、以前単調性制約の記事も書いているので、合わせてどうぞ。

2種類の重要度:split と gain

LightGBMのfeature_importanceが取れる重要度は次の2つです。

importance_type 意味
split(デフォルト) その特徴量が分岐(split)に使われた回数
gain その特徴量による分岐で得られた損失の改善量(gain)の合計

ざっくり言うと、

  • split … 「何回使われたか」の回数カウント
  • gain … 「どれだけ精度に貢献したか」の貢献度の合計

です。 ここで意識しておきたいのが、デフォルトはsplitという点です。splitは回数を数えているだけなので、「たくさん分岐に使われるが、1回1回の貢献は小さい特徴量」ほど値が大きく出ます。 特に連続値で値の種類が多い(高カーディナリティな)特徴量は、木がノイズにフィットする過程で何度も分岐に使われるため、splitでは高く出やすい、という性質があります。

実験:ランキングがひっくり返る例

本当にそんなことが起きるのか、わざとらしいデータで試してみます。 用意する特徴量は3つです。

  • strong … ターゲットを強く決める本命の特徴量
  • noise_highcard … ターゲットと無関係だが、連続値でほぼ全部がユニークな値(高カーディナリティ)
  • binary … 情報はあるが2値なので、そもそも分岐に使える回数が少ない
import numpy as np
import lightgbm as lgb

rng = np.random.RandomState(0)
n = 3000

strong = rng.randn(n)                          # 本命
noise_highcard = rng.randn(n)                  # 無関係なノイズ(高カーディナリティ)
binary = rng.randint(0, 2, n).astype(float)    # 2値

y = 3.0*strong + 1.0*binary + rng.randn(n)*0.5  # noise_highcard は y に関与しない

X = np.column_stack([strong, noise_highcard, binary])
names = ["strong", "noise_highcard", "binary"]

ds = lgb.Dataset(X, y, feature_name=names)
params = dict(objective="regression", num_leaves=31, learning_rate=0.05,
              min_child_samples=20, verbose=-1)
model = lgb.train(params, ds, num_boost_round=300)

ターゲットystrongbinaryから作っていて、noise_highcard一切関与していません。 つまり本来の重要度は strong > binary > noise_highcard(ほぼゼロ) のはずです。

作ったデータで、各特徴量とyの関係を散布図にして確認しておきましょう。

  • strongyときれいな右肩上がりの関係。まさに本命の特徴量
  • noise_highcard … 雲のように広がっているだけで、yとは無関係
  • binary … 0と1の2グループ。パッと見では差が小さいですが、1のほうがわずかにyが高く、小さいながら本物の効果があります

見た目からも、効いているのはstrongnoise_highcardは無関係、というのは明らかですね。 では、この直感とLightGBMの重要度が一致するか見てみましょう。

imp_split = model.feature_importance(importance_type="split")
imp_gain  = model.feature_importance(importance_type="gain")

print("feature         split   gain")
for nm, s, g in zip(names, imp_split, imp_gain):
    print(f"{nm:15s} {s:6d} {g:12.1f}")
feature         split   gain
strong            4106     261605.6
noise_highcard    4157       1365.1
binary             737       7362.9

ランキングにするとこうなります。

print("split:", [names[i] for i in np.argsort(-imp_split)])
print("gain :", [names[i] for i in np.argsort(-imp_gain)])
split: ['noise_highcard', 'strong', 'binary']
gain : ['strong', 'binary', 'noise_highcard']

見事に結果が割れました。

  • split(デフォルト)では、無関係なはずのnoise_highcardが堂々の1位。回数だけ見るとstrongより多く分岐に使われています。
  • 一方gainではstrongが圧倒的で、noise_highcardは最下位。貢献度で見ればほぼゼロです。

noise_highcardyと何の関係もないのに、splitだと最重要に見えてしまう。これが「デフォルトの罠」です。

なぜ split が水増しされるのか(木を1本覗く)

なぜこんなことが起きるのか、学習された決定木を1本取り出して、どの特徴量で分岐しているかを色分けしてみます。

赤がstrong、緑がnoise_highcard、青がbinaryです。 根の近くの**大事な分岐はstrong(赤)が担っていますが、その下に続く細かい枝はほとんどがnoise_highcard(緑)**になっています。この木1本だけでもnoise_highcardは23回分岐に使われており、strongの6回を大きく上回っています。

木はstrongで大まかに予測したあと、残った誤差(ノイズ)をnoise_highcardの細かい分岐で無理やり説明しようとします。高カーディナリティな連続値はいくらでも分割点を作れるので、こうして分岐の回数だけがどんどん積み上がっていきます。 とはいえ1回1回の分岐の貢献(gain)はごくわずかなのでgainでは下位のまま。一方、回数を数えるsplitでは上位に来てしまう——これが罠の正体です。

どちらを使うべきか

基本的にはgainを使うのがおすすめです。 「その特徴量が予測精度にどれだけ貢献したか」を見たいケースがほとんどなので、回数ではなく貢献度で見るgainのほうが解釈を誤りにくいです。

なお、splitがデフォルトなのはBooster.feature_importanceだけでなく、scikit-learn API(LGBMRegressor / LGBMClassifier)の.feature_importances_でも同じです。

# sklearn API の場合は、モデル作成時に importance_type を指定する
model = lgb.LGBMRegressor(importance_type="gain")

.feature_importances_をそのまま使っている場合、知らないうちにsplitの値を見ている可能性があるので注意しましょう。

まとめ

  • LightGBMのfeature_importanceにはsplitgainの2種類がある
    • split … 分岐に使われた回数(デフォルト)
    • gain … 分岐による損失改善量の合計(貢献度)
  • デフォルトのsplitは、高カーディナリティなノイズ特徴量を過大評価する罠がある
  • 基本はgainで見るのが安全。sklearn APIならimportance_type="gain"を指定する

特徴量選択や「効いている特徴量の考察」で重要度を見るときは、splitgainのどちらを見ているか必ず意識しましょう。同じLightGBMの単調性制約の記事も、ドメイン知識をモデルに入れる話として合わせて読むのがおすすめです。

参考リンク