LightGBMを用いて特徴量を正規化/標準化、対数変換するとscoreが変わるのか検証してみた。

#### 目次
- はじめに
- データの準備
- 実験と結果
- LightGBMのアルゴリズム：ヒストグラムベースと正規化/標準化の影響
- まとめ

## 本記事の対象者
- LightGBMを使ってモデルを作成する人
- 特徴量を正規化/標準化するか悩んでいる人

## はじめに
ファインディ株式会社、データソリューションチームの山家([@yamayafumiteru](https://twitter.com/yamayafumiteru))です。
前回は、複数ある特徴量のうち1つを1000倍した結果、木の構造も変わらず、評価指標に影響がないという記事を書きました。
LightGBMだと、1つの特徴量に対して何を掛けたとしても他の特徴量に影響を与えず※1、数値の大小関係で判断している為、変化がないという結果でした。
※1.Exclusive Feature Bundling という手法により複数の特徴量を1つの束として扱うことがあるが、他の特徴量のサイズにより結果の影響があるかは未検証です。

https://qiita.com/yamayafumiteru/items/3403c0d5bdd29a86d3ea

今回の記事では、特徴量の数値を正規化または標準化、対数変換した場合としていない場合で、予測スコアや生成される決定木に違いがあるかを検証します。

## データの準備
例のごとくTitanicのdataを使用します。
今回は精度を上げるのではなく、数値を変換した際の動作を確認したい為、欠損値と1歳未満を削除して検証します。
(Titanicのdataには、0.16歳などの赤ちゃんのデータも含まれるようです。)

```python
import numpy as np
import lightgbm as lgb
from sklearn.model_selection import KFold
from sklearn.preprocessing import StandardScaler, MinMaxScaler
from sklearn.datasets import fetch_openml
import pandas as pd

# Titanicデータセットをロード
data = fetch_openml(name='titanic', version=1, as_frame=True)

# データフレームに変換
df = pd.concat([data.data, data.target], axis=1)

# ageが欠損値または1未満の箇所を削除
df = df[(df['age'].notna()) & (df['age'] >= 1)]

# Xとyに分割、
X = df[['age']]
y = df['survived']
```
## 実験と結果
以下の4パターンで実験。
- 何も変化させない
- ageを正規化
- ageを標準化
- ageを対数変換

```python
def kfold_lgbm(X, y, n_splits=5):
    model = lgb.LGBMClassifier()

    # k-fold交差検証
    kfold = KFold(n_splits=n_splits, shuffle=True, random_state=42)
    scores = []
    models = []

    for train_index, test_index in kfold.split(X, y):
        X_train, X_test = pd.DataFrame(X).iloc[train_index], pd.DataFrame(X).iloc[test_index]
        y_train, y_test = y.iloc[train_index], y.iloc[test_index]

        model.fit(X_train, y_train)
        score = model.score(X_test, y_test)
        scores.append(score)
        models.append(model)
    avg_svore = sum(scores) / len(scores)
    return models, avg_svore

scaler_transforms = {
    "default": lambda x: x,
    "minmax_scaler": MinMaxScaler().fit_transform,
    "standard_scaler": StandardScaler().fit_transform,
    "log_transform": np.log1p
}


for method, transform in scaler_transforms.items():
    X_transformed = pd.DataFrame(transform(X))
    models, avg_svore = kfold_lgbm(X_transformed, y)
    lgb.plot_tree(models[0], figsize=(15, 7))
    print(f"{method}_score:",avg_svore)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304234/8b9d0195-c9db-43cd-f20b-ed54e4b1345d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304234/96ab1d14-5fc1-2de1-ba03-d77f1bd7825b.png)


長くなるのですべての木を出力するのは省略しますが、木の構造に違いがないものの、scoreではlog変換した際の結果だけ若干違うという結果になりました。
なぜこうなるのかは次の章で説明します。

## LightGBMのアルゴリズム：ヒストグラムベースと正規化/標準化、対数変換の影響
LightGBMは、決定木の分割点を決めるのにhistogram-basedのアルゴリズムを採用しています。論文 （[LightGBM: A Highly Efficient Gradient Boosting Decision Tree](https://papers.nips.cc/paper/2017/hash/6449f44a102fde848669bdd9eb6b76fa-Abstract.html))
このアルゴリズムでは、特徴量を(デフォルトで255の)ビンに離散化して、計算量を減らしています。
対数変換を適用するとデータの分布が変化し、ビンの境界が異なる場合があります。これにより、予測スコアや生成される決定木に影響が及んでいると考えます。
（認識が誤っている場合は、お手数ですがご連絡ください。）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304234/37730eb5-7e44-e582-e1c5-780baf79573f.png)

具体的なビンの区切り方はLightGBMの内部で決定されますが、データの分布を確認することでビンの区切り方のイメージを得ることができます。
（LightGBMはデフォルトでbinが255のようです。）

``` python
import matplotlib.pyplot as plt
for method, transform in scaler_transforms.items():
    if method in ["default", "log_transform"]:
        X_transformed = pd.DataFrame(transform(X))
        X_transformed.hist(figsize=(6, 4), bins=64)
        plt.tight_layout()
        plt.show()
```
上記のコードを実行して、defaultとlog_transformした際の結果をpltします。これによりビンの区切り方を視覚的に確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304234/7dd0b058-2597-0317-2d4c-e2d7c34d3304.png)


## まとめ
LightGBMにおいて特徴量の対数変換は、予測スコアや生成される決定木に少し影響を与えることがわかりました。
対数変換を適用することで分布が偏っている場合に正規分布に近づけられたり、外れた数値の影響を低減できてモデルの性能向上が見られる場合もあります。ただし、データの特性やタスクの性質によっては、対数変換が必ずしも効果的ではない場合もあります。結論として、特徴量の前処理手法はケースバイケースで適切に選択する必要があります。