[LightGBM] Optuna 最適化

# 概要
LightGBMをOptunaを使用して最適化する方法をまとめた記事です。
この記事はTunerやTunerCVなどの違いなどを一つのコードにまとめており、それぞれの結果を比較できます。
Optunaの使用方法の日本語の記事が少ないと感じたため、まとめてみました。
しかしながら、一度公式ドキュメントを一読することを強く推奨します。

また、optunaで最適化した後に再度学習を回さなければいけないのか？
という疑問があったので、それについても検証しています。
（実際には検証データを学習データに追加してスコアを上げることが多いので、基本的に再度学習させますが、、、）

この記事はPython APIを使用しています。

# 動作環境
* MacBook Pro (M1)
* optuna: 2.9.1  
* lightgbm: 3.3.2 
* scikit-learn: 1.0.2

# ソースコード
```
"""
公式githubから一部引用しています。
ソースコード引用元：　https://github.com/optuna/optuna-examples/tree/main/lightgbm
"""
import optuna.integration.lightgbm as lgb_o
import lightgbm as lgb
import numpy as np
import os

from lightgbm import early_stopping
from lightgbm import log_evaluation
import sklearn.datasets
from sklearn.model_selection import KFold
from sklearn.model_selection import train_test_split

from sklearn.metrics import accuracy_score # 正答率
from sklearn.metrics import log_loss # logloss
from sklearn.metrics import roc_auc_score # AUC

# 再度学習を回すか否か
RE_TRAIN=True
if RE_TRAIN:
    FILEPATH='eval_retrain.txt'
else:
    FILEPATH='eval.txt'

# boosting回数
num_boost = 200

def re_train(params, dtrain, dvalid):
    model = lgb.train(params, dtrain,
        valid_sets=dvalid,
        num_boost_round=num_boost,
        verbose_eval=False,
        callbacks=[early_stopping(100), log_evaluation(100)],
    )
    return model

def run_model(model_type):
    data, target = sklearn.datasets.load_breast_cancer(return_X_y=True)
    X_train, X_test, y_train, y_test = train_test_split(data, target,test_size=0.20, random_state=42)
    X_train, X_valid, y_train, y_valid = train_test_split(X_train, y_train,test_size=0.20, random_state=42)
    dtrain = lgb.Dataset(X_train, label=y_train)
    dvalid = lgb.Dataset(X_valid, label=y_valid)

    params = {
        "objective": "binary",
        "metric": "binary_logloss",
        "verbosity": -1,
        "boosting_type": "gbdt",
        "seed": 42
    }

    if model_type=='train':
        with open(FILEPATH, 'a') as f:
            print('\nLightgbm TRAIN', file=f)
            model = lgb.train(
                params,
                dtrain,
                valid_sets=dvalid,
                num_boost_round=num_boost,
                callbacks=[early_stopping(100), log_evaluation(100)],
            )
            print('Current parameters:\n', model.params, file=f)

    if model_type=='cv':
        with open(FILEPATH, 'a') as f:
            print('\nLightgbm CV', file=f)
        model = lgb.cv(
            params,
            dtrain,
            num_boost_round=num_boost,
            folds=KFold(n_splits=3),
            return_cvbooster=True,
            callbacks=[early_stopping(100), log_evaluation(100)],
        )

        # Display results
        print('Current parameters:\n', params)
        print('\nBest num_boost_round:', len(model['binary_logloss-mean']))
        print('Best CV score:', model['binary_logloss-mean'][-1])
        model = model['cvbooster']
        
        # print(cv_booster.boosters[0])
        # テストデータで予測する
        y_pred_prob = np.mean(model.predict(X_test, num_iteration=model.best_iteration), axis=0)
        y_pred = np.rint(y_pred_prob).astype(int)

    if model_type=='optuna_train':
        # train() is a wrapper function of LightGBMTuner
        with open(FILEPATH, 'a') as f:
            print("\nOptuna TRAIN", file=f)
            model = lgb_o.train(
                params,
                dtrain,
                valid_sets=dvalid,
                num_boost_round=num_boost,
                verbose_eval=False,
                callbacks=[early_stopping(100), log_evaluation(100)],
            )
            print('BEST PARAMS\n:', model.params, file=f)

            if RE_TRAIN:
                # 再度学習させる必要あり？
                model = re_train(model.params, dtrain, dvalid)

    if model_type=='optuna_tuner':
        with open(FILEPATH, 'a') as f:
            print("\nOptuna LightGBMTuner", file=f)
            model = lgb_o.LightGBMTuner(
                params,
                dtrain,
                valid_sets=dvalid,
                num_boost_round=num_boost,
                verbose_eval=False,
                callbacks=[early_stopping(100), log_evaluation(100)],
            )
            model.run()
            print('BEST PARAMS\n:', model.best_params, file=f)
            print('BEST SCORE\n:', model.best_score, file=f)
            
            if RE_TRAIN:
                # 再度学習させる必要あり？
                model = re_train(model.best_params, dtrain, dvalid)
                print('retrain params: ', model.params, file=f)
            else:
                model = model.get_best_booster()

    if model_type=='optuna_tunercv':
        with open(FILEPATH, 'a') as f:
            print("\nOptuna LightGBMTunerCV", file=f)
            model = lgb_o.LightGBMTunerCV(
                params,
                dtrain,
                num_boost_round=num_boost,
                verbose_eval=False,
                folds=KFold(n_splits=3),
                return_cvbooster=True,
                callbacks=[early_stopping(100), log_evaluation(100)],
            )
            model.run()
            print('BEST PARAMS\n:', model.best_params, file=f)
            print('BEST SCORE\n:', model.best_score, file=f)
            
            if RE_TRAIN:
                # 再度学習させる必要あり？
                model = re_train(model.best_params, dtrain, dvalid)
                print('retrain params: ', model.params, file=f)
                y_pred_prob = model.predict(X_test, num_iteration=model.best_iteration)
                y_pred = np.rint(y_pred_prob).astype(int)
            else:
                model = model.get_best_booster()
                y_pred_prob = np.mean(model.predict(X_test, num_iteration=model.best_iteration), axis=0)
                y_pred = np.rint(y_pred_prob).astype(int)

    # cvだけ平均を取る
    if 'cv' not in model_type:
        y_pred_prob = model.predict(X_test, num_iteration=model.best_iteration)
        y_pred = np.rint(y_pred_prob).astype(int)

    # kFold分返ってくる
    # print('X_test shape: ', y_test.shape)
    # print(y_pred_prob.shape)
    # print(y_pred)

    with open(FILEPATH, 'a') as f:
        print('==== EVALUATOIN ====', file=f)
        # モデル評価
        # acc : 正答率
        acc = accuracy_score(y_test, y_pred)
        print('Acc :', acc, file=f)

        # logloss 
        logloss =  log_loss(y_test,y_pred_prob) # 引数 : log_loss(正解クラス,[クラス0の予測確率,クラス1の予測確率])
        print('logloss :', logloss, file=f)

        # AUC 
        auc = roc_auc_score(y_test,y_pred_prob) # 引数 : roc_auc_score(正解クラス, クラス1の予測確率)
        print('AUC :', auc, file=f)
        print('='*16, file=f)

if os.path.exists(FILEPATH):
    os.remove(FILEPATH)

run_model('train')
run_model('cv')
print('===== optuna =====')
run_model('optuna_train')
run_model('optuna_tuner')
run_model('optuna_tunercv')

```

# コードの解説
詰め込みすぎました。クソ長コードですみません。
ここから端的に解説を行います。（各タイトルから公式ドキュメントに飛ぶ様になっています。）
## コード内で行っていること
以下に示しているAPIを片っぱしから回しています。
RE_TRAINが真の時に、最適化したパラメータを使用して再度学習を回します。
RE_TRAINが偽の時に、最適化したパラメータを使用して再度学習を回さずに、そのまま予測します。
これはoptunaがパラメータチューニングだけを行うのか、それとも求めたパラメータを元に学習も同時に行うのかがわからず、検証したいと考えたためです。

また、
* パラメータ
* ベストスコア
* 正答率
* logloss
* AUC
をeval.txt(RE_TRAIN==Falseの時)とeval_retrain.txt(RE_TRAIN==Trueの時)に書き出します。
動かす時は同様のファイルがディレクトリにないことを確認してください。

## [lightgbm.train()](https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.train.html)
シンプルなlightgbm。特に解説はなし。
## [lightgbm.cv()](https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.cv.html)
kFoldを設定することにより自動で交差検証を行ってくれる。
return_cvbooster=True を渡すことで、それぞれのboosterを返します。
最終的なスコアの算出時に、各boosterでの平均を求め、最終的なスコアとしました。
## [optuna.integration.lightgbm.train()](https://optuna.readthedocs.io/en/stable/reference/generated/optuna.integration.lightgbm.train.html#optuna.integration.lightgbm.train)
train()はLightGBMTunerのラッパーだそうです。
そのため、moduleの読み込み時にlightgbmからoptuna.integration.lightgbmに切り替えるだけで、パラメータチューニングを行うことが可能です。
## [optuna.integration.lightgbm.LightGBMTuner()](https://optuna.readthedocs.io/en/stable/reference/generated/optuna.integration.lightgbm.LightGBMTuner.html#optuna.integration.lightgbm.LightGBMTuner)
パラメータチューニングを行うクラスです。
ここで注意なのが、optuna.integration.lightgbm.train()は、lightgbm.boosterを返しますが、optuna.integration.lightgbm.LightGBMTuner()は、あくまでインスタンスを作成するだけです。
そのため、パラメータを取得するときに
LightGBMTuner_ins.params
は使用できません。代わりにattributeが用意されているので、
LightGBMTuner_ins.best_params
を使用しましょう。他使用できるメソッド等はドキュメントを読んでください。
## [optuna.integration.lightgbm.LightGBMTunerCV()](https://optuna.readthedocs.io/en/stable/reference/generated/optuna.integration.lightgbm.LightGBMTuner.html#optuna.integration.lightgbm.LightGBMTunerCV)
パラメータチューニングを行うクラスです。
LightGBMTuner()同様に、optuna.integration.lightgbm.train()は、lightgbm.boosterを返しますが、optuna.integration.lightgbm.LightGBMTuner()は、あくまでインスタンスを作成するだけです。
パラメータを取得する際も同様です。他使用できるメソッド等はドキュメントを読んでください。

なお、Tuner()とTunerCV（）は、公式ドキュメントにもある通り
LightGBMTunerはlightgbm.train()を呼び出しており、
LightGBMTunerCVはlightgbm.cv()を呼び出しています。

# 検証結果
```eval.txt

Lightgbm TRAIN
Current parameters:
 {'objective': 'binary', 'metric': 'binary_logloss', 'verbosity': -1, 'boosting_type': 'gbdt', 'seed': 42, 'num_iterations': 200, 'early_stopping_round': None}
==== EVALUATOIN ====
Acc : 0.9473684210526315
logloss : 0.12157752763635903
AUC : 0.9911562397641664
================

Lightgbm CV
==== EVALUATOIN ====
Acc : 0.956140350877193
logloss : 0.12930029372695256
AUC : 0.9918113331149688
================

Optuna TRAIN
BEST PARAMS
: {'objective': 'binary', 'metric': 'binary_logloss', 'verbosity': -1, 'boosting_type': 'gbdt', 'seed': 42, 'feature_pre_filter': False, 'lambda_l1': 0.0, 'lambda_l2': 0.0, 'num_leaves': 31, 'feature_fraction': 0.5, 'bagging_fraction': 0.7236027769602329, 'bagging_freq': 6, 'min_child_samples': 20, 'num_iterations': 200, 'early_stopping_round': None}
==== EVALUATOIN ====
Acc : 0.9473684210526315
logloss : 0.10430829385208068
AUC : 0.9963969865705864
================

Optuna LightGBMTuner
BEST PARAMS
: {'objective': 'binary', 'metric': 'binary_logloss', 'verbosity': -1, 'boosting_type': 'gbdt', 'seed': 42, 'feature_pre_filter': False, 'lambda_l1': 9.439447630161918e-08, 'lambda_l2': 1.5678728855488382e-06, 'num_leaves': 31, 'feature_fraction': 0.5, 'bagging_fraction': 1.0, 'bagging_freq': 0, 'min_child_samples': 20}
BEST SCORE
: 0.1444958674219025
==== EVALUATOIN ====
Acc : 0.956140350877193
logloss : 0.1084592702836402
AUC : 0.9937766131673764
================

Optuna LightGBMTunerCV
BEST PARAMS
: {'objective': 'binary', 'metric': 'binary_logloss', 'verbosity': -1, 'boosting_type': 'gbdt', 'seed': 42, 'feature_pre_filter': False, 'lambda_l1': 2.6579444199639224e-07, 'lambda_l2': 0.004157596949231762, 'num_leaves': 31, 'feature_fraction': 0.5479999999999999, 'bagging_fraction': 0.44586670180824783, 'bagging_freq': 3, 'min_child_samples': 20}
BEST SCORE
: 0.0597214668911544
==== EVALUATOIN ====
Acc : 0.9736842105263158
logloss : 0.06948869611698376
AUC : 0.9980347199475925
================
```

```eval_retrain.txt
Lightgbm TRAIN
Current parameters:
 {'objective': 'binary', 'metric': 'binary_logloss', 'verbosity': -1, 'boosting_type': 'gbdt', 'seed': 42, 'num_iterations': 200, 'early_stopping_round': None}
==== EVALUATOIN ====
Acc : 0.9473684210526315
logloss : 0.12157752763635903
AUC : 0.9911562397641664
================

Lightgbm CV
==== EVALUATOIN ====
Acc : 0.956140350877193
logloss : 0.12930029372695256
AUC : 0.9918113331149688
================

Optuna TRAIN
BEST PARAMS
: {'objective': 'binary', 'metric': 'binary_logloss', 'verbosity': -1, 'boosting_type': 'gbdt', 'seed': 42, 'feature_pre_filter': False, 'lambda_l1': 0.0, 'lambda_l2': 0.0, 'num_leaves': 31, 'feature_fraction': 0.5, 'bagging_fraction': 1.0, 'bagging_freq': 0, 'min_child_samples': 20, 'num_iterations': 200, 'early_stopping_round': None}
==== EVALUATOIN ====
Acc : 0.956140350877193
logloss : 0.11727340716495468
AUC : 0.9924664264657713
================

Optuna LightGBMTuner
BEST PARAMS
: {'objective': 'binary', 'metric': 'binary_logloss', 'verbosity': -1, 'boosting_type': 'gbdt', 'seed': 42, 'feature_pre_filter': False, 'lambda_l1': 0.00827426162025056, 'lambda_l2': 0.0001806019251201395, 'num_leaves': 31, 'feature_fraction': 0.5, 'bagging_fraction': 1.0, 'bagging_freq': 0, 'min_child_samples': 20}
BEST SCORE
: 0.14531042742601805
retrain params:  {'objective': 'binary', 'metric': 'binary_logloss', 'verbosity': -1, 'boosting_type': 'gbdt', 'seed': 42, 'feature_pre_filter': False, 'lambda_l1': 0.00827426162025056, 'lambda_l2': 0.0001806019251201395, 'num_leaves': 31, 'feature_fraction': 0.5, 'bagging_fraction': 1.0, 'bagging_freq': 0, 'min_child_samples': 20, 'num_iterations': 200, 'early_stopping_round': None}
==== EVALUATOIN ====
Acc : 0.956140350877193
logloss : 0.11239958378145458
AUC : 0.9934490664919751
================

Optuna LightGBMTunerCV
BEST PARAMS
: {'objective': 'binary', 'metric': 'binary_logloss', 'verbosity': -1, 'boosting_type': 'gbdt', 'seed': 42, 'feature_pre_filter': False, 'lambda_l1': 2.2322900519939083e-07, 'lambda_l2': 0.03743176352261013, 'num_leaves': 3, 'feature_fraction': 0.5, 'bagging_fraction': 0.503199378418598, 'bagging_freq': 5, 'min_child_samples': 20}
BEST SCORE
: 0.05893288476632618
retrain params:  {'objective': 'binary', 'metric': 'binary_logloss', 'verbosity': -1, 'boosting_type': 'gbdt', 'seed': 42, 'feature_pre_filter': False, 'lambda_l1': 2.2322900519939083e-07, 'lambda_l2': 0.03743176352261013, 'num_leaves': 3, 'feature_fraction': 0.5, 'bagging_fraction': 0.503199378418598, 'bagging_freq': 5, 'min_child_samples': 20, 'num_iterations': 200, 'early_stopping_round': None}
==== EVALUATOIN ====
Acc : 0.956140350877193
logloss : 0.10605827596168013
AUC : 0.9934490664919752
================
```

eval.txtとeval_retrain.txt内の
Optuna TRAIN　と　OptunaLightGBMTuner
BEST PARAMSとEVALUATIONを見比べてみてください。

再度学習させた方が良い結果が出ることがわかりました。（誤差ですが）
しかし、公式[github](https://github.com/optuna/optuna-examples/blob/main/lightgbm/lightgbm_tuner_simple.py)ではそのまま予測を行なっていることが確認できるので、どちらが良いのかわかりません。
有識者の皆様教えてください。

# おわりに
若干optunaとlightgbmのAPIに違いが見られ、混乱する方がいらっしゃるかもしれません。
そのうちの一人が僕です。
この記事がみなさまの力になることを願っています。