決定木は本当に変換に依存しないのか?
決定木をベースとしたモデル(RandomForestやXGBoost、LightGBMなど)は正規化などの前処理が必要ないと言われています。
理由として挙げられるのは「決定木は特徴量の大小関係のみに着目しており、値自体には意味がないから」というものです。
先日もkaggler-jaというSlackチャンネルでこの話が出ました(細かく読まなくて大丈夫です)。
自分で質問に答えておいて、「本当か?」という疑問が湧いてきたので実験してみることにしました。
結論から言うと、StandardScalerやMinMaxScalerなどの各特徴量について線形な変換に対しては結果は変化しないけど、logをかけたりするとちょっと変わるということが分かりました。
決定木のアルゴリズム(ざっくり)
決定木を作成する際のおおまかな流れは以下のとおりです。
- 目的変数(target)をうまく分離できる特徴量と、分割境界を決める
- サンプルを基準よりも小さい群と大きい群に分ける
- それぞれの群を繰り返し分割していく
以下に例を示します。この例はhttps://mathwords.net/ketteigiに紹介されていたもので、とてもわかりやすい例だと思います。
図の各点はある1日を表します。赤い点は「暑い」日、青い点は「暑くない」日です。 たとえば温度が27度で湿度が40%の日は暑くないと判定されます。
このデータから以下のように決定木のモデルが作成されたとします。
先程の図に決定境界を重ねると以下のようになります。
ここで、温度をケルビン表示にし、湿度を%ではなくて割合に換算したとします。
温度の決定境界は298Kと303K、湿度の決定境界は0.6に変化しますが、分類の結果自体は変化しないであろうことが想像できます。 これが「決定木は特徴量の値自体には意味がなく、大小関係にこそ意味がある」と僕が考えていた理由です。
それでは、実験をして本当に大小関係だけに意味があるのか確かめてみましょう。
実験
タイタニックのデータセットを利用し、RandomForestとLightGBMについて実験を行います。
実験の準備
まずはデータの準備です。
# データロード train = pd.read_csv('input/train.csv') # かんたんな特徴量作成 train['CabinRank'] = train.Cabin.str[0] train['FamilySize'] = train.SibSp + train.Parch train.drop(['PassengerId', 'Name', 'Ticket', 'Cabin'], axis=1, inplace=True) # 文字列をラベルに変換 enc = OrdinalEncoder() X_train = enc.fit_transform(train.drop('Survived', axis=1)).astype(float) y_train = train.Survived.values # 欠損は平均値で補完 X_train.Age.fillna(X_train.Age.mean(), inplace=True)
一部を確認するとこんな感じになっています。
このデータに対して、
- 変換をかけない
- 平均0、標準偏差1に変換(
StandardScaler
) - 最小0、最大1に変換(
MinMaxScaler
) - 25%点を-1、75%点を1に変換(
RobustScaler
) log(1+x)
(log1p
)の対数変換
の5種類の変換を行って5-fold交差検証の精度を確認します。 実験の条件は以下のとおりです。
params = { 'n_estimators': 100, 'random_state': 0 } cv = KFold(n_splits=5, random_state=0, shuffle=True)
RandomForest
StandardScaler
やMinMaxScaler
を利用する際は、sklearn.pipeline
内のmake_pipeline
を使います。
これは、各モデルを学習する際、パイプラインに含まれる順番にfit
を行ってくれるというものです。
result_df = pd.DataFrame() # そのまま model = RandomForestClassifier(**params) result_df['initial'] = cross_val_score(model, X_train, y_train, cv=cv) # 平均0、標準偏差1 model = make_pipeline( StandardScaler(), RandomForestClassifier(**params) ) result_df['StandardScaler'] = cross_val_score(model, X_train, y_train, cv=cv) # 最小0、最大1 model = make_pipeline( MinMaxScaler(), RandomForestClassifier(**params) ) result_df['MinMaxScaler'] = cross_val_score(model, X_train, y_train, cv=cv) # 25%点を-1、75%点を1 model = make_pipeline( RobustScaler(), RandomForestClassifier(**params) ) result_df['RobustScaler'] = cross_val_score(model, X_train, y_train, cv=cv) # log(1+x) model = RandomForestClassifier(**params) result_df['log1p'] = cross_val_score(model, np.log1p(X_train), y_train, cv=cv) # 結果を表示 display(result_df.T)
5-foldでの交差検証の結果は以下のとおりです。
表を見ると、StandardScaler
などの線形変換を用いた際は結果が変化せず、 log1p
の変換を行った場合のみ結果が変わっていることがわかります。
この理由としては、RandomForestの分割境界には特徴量の中点が用いられることが挙げられます。
中点で分割が行われているので、データの間隔が変化すると新規データに対しては結果が変わりうるということがわかります。
上の図は、右の2点の間で分割が行われた場合を表しています。log1p
をかける前はテストデータが分割境界の左に来ていたにもかかわらず、log1p
をかけた後は右に来ている事がわかります。これが結果のぶれを生んでいるのだと考えられます。
LightGBM
result_df = pd.DataFrame() # そのまま model = lgb.LGBMClassifier(**params) result_df['initial'] = cross_val_score(model, X_train, y_train, cv=cv) # 平均0、標準偏差1 model = make_pipeline( StandardScaler(), lgb.LGBMClassifier(**params) ) result_df['StandardScaler'] = cross_val_score(model, X_train, y_train, cv=cv) # 最小0、最大1 model = make_pipeline( MinMaxScaler(), lgb.LGBMClassifier(**params) ) result_df['MinMaxScaler'] = cross_val_score(model, X_train, y_train, cv=cv) # 25%点を-1、75%点を1 model = make_pipeline( RobustScaler(), lgb.LGBMClassifier(**params) ) result_df['RobustScaler'] = cross_val_score(model, X_train, y_train, cv=cv) # log(1+x) model = lgb.LGBMClassifier(**params) result_df['log1p'] = cross_val_score(model, np.log1p(X_train), y_train, cv=cv) # 結果を表示 display(result_df.T)
5-foldでの交差検証の結果は以下のとおりです。
どのような変換を行っても結果は変化しませんでした。これは、LightGBM内部で行われているbinningによるものと考えられます。
LightGBMではモデルの学習を高速化するため、各特徴量をデフォルト255段階のヒストグラムに変換しています。 木の分割はヒストグラムのビン単位で行われるので、分割境界の位置が制限されます。 分割境界の位置が制限されていると、検証用データが境界付近に来る確率が下がるので、結果が変わりづらくなると考えられます。
(ちなみにrandom_seed
によってはlog1p
での結果が他と異なることもあるので、LightGBMだからといって絶対に結果が変わらないということではないようです)
まとめ
決定木をベースとした手法はStandardScaler
などの線形な変換に対しては依存しないことが分かりました。
RandomForestでは、分割境界が特徴量の中点に設定されることから、log1p
などの非線形変換については結果が微妙に変わることがあるようです。
LightGBMでは内部でbinningが行われている関係上、RandomForest以上に変換に対して頑健になることが分かりました。
なにごとも自分で検証してみると面白いということに気付かされました。
kaggler-jaでは日々活発に議論や質問が行われています。beginners-helpという初心者用のチャンネルもあるので、Kaggleを始めたての方も、これから始めるよ!という方もどしどし参加してください! 僕もできる限り質問に答えます!
今回の実験のノートブックはGitHubにありますので、追試などがしたい方はそちらをご覧ください。
間違いを見つけた方は、Twitterなどで教えていただけると幸いです。