LoginSignup
144
127

LightGBMのearly_stoppingの仕様が変わったので、使用法を調べてみた

Last updated at Posted at 2022-05-15

LightGBMとearly_stopping

LightGBMは2022年現在、回帰問題において最も広く用いられている学習器の一つであり、機械学習を学ぶ上で避けては通れない手法と言えます。

LightGBMの一機能であるearly_stoppingは学習を効率化できる(詳細は後述)人気機能ですが、この度使用方法に大きな変更があったようなので、内容を記事にまとめたいと思います

変更の概要

early_stoppingを使用するためには、元来は学習実行メソッド(train()またはfit())にearly_stopping_rounds引数を指定していましたが、2021年の年末(こちらのissues)よりこの指定法は非推奨となり、コールバック関数lightgbm.early_stopping()で指定する方式へと変更になりました。

新たな方式であるコールバック関数によるearly_stoppingの指定法に関して、現状では情報が少なく

「どう使えばいいのか分からん!」

と思われている方も多いかと思うので、使用法を調べた結果を本記事にまとめます。

TL;DR

2024/1現在、実装のお手軽さとXGBoostとの共通化の観点から、本記事でおまけのように紹介している「第三の指定方法個人的にはベストな実装方法だと考えております。

旧指定方法の現状

2022/5現在、early_stopping_rounds引数を使用すると、以下のようなUserWarningが発生します。

※なお、PythonでLightGBMを使用するためには「Training API」(train()メソッド)と「Scikit-Learn API」(fit()メソッド)の2種類の実装方法が存在しますが、本記事では両者について検証していきます(両APIの概要については後述

Training APIの場合

lightgbm/engine.py:181: UserWarning: 'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM.
 Pass 'early_stopping()' callback via 'callbacks' argument instead.
  _log_warning("'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM. "

Scikit-Learn APIの場合

lightgbm/sklearn.py:726: UserWarning: 'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM.
 Pass 'early_stopping()' callback via 'callbacks' argument instead.
  _log_warning("'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM. "

メッセージを見ると分かる通り、early_stopping_rounds引数はそのうち使えなくなる可能性が高い(最新版のマスターブランチでは既に削除済)ため、今後はコールバック関数を用いた新たな指定方法を用いる必要がありそうです。

early_stoppingとは?

LightGBMやXGBoostは、学習に「勾配ブースティング決定木」と呼ばれる手法が用いられています。詳しくは以下の記事が分かりやすいですが、

学習手順を超シンプルにまとめると
前回の学習結果に基づき、誤判定を減らすよう再学習を繰り返す
という処理が行われています。

ここで重要となるのが、「学習(イテレーション)を何回繰り返すか」です。

early_stoppingを使用しない場合、Training APIではnum_boost_round引数、Scikit-Learn APIではn_estimators引数で指定した固定回数だけ学習が繰り返されます
最適な学習回数はデータセットによって変わるため、多くのデータセットで性能を発揮するためには指定する学習回数(イテレーション数)を増やす必要がありますが、学習回数を増やすと所要時間が増えてしまうため、性能と所要時間のトレードオフが生じてしまいます。

また学習回数を増やしすぎると学習データにモデルが過剰に適合する「過学習」が生じやすくなるため、過学習と未学習のトレードオフも発生します。

これらのトレードオフを最適化するために用いられるのがearly_stoppingで、

評価用データで一定回数連続でスコアが改善しなかったら、指定した学習回数に達しなくとも学習を打ち切る

というフローが加わります(この「一定回数」をearly_stopping_rounds引数で、「評価用データ」をvalid_setsまたはeval_set引数で指定します)
これによりデータセットが変わっても「性能がサチる学習回数 ≒ これ以上続けても性能向上が見込めない学習回数」で動的に学習を打ち切る事ができ、効率的な学習を実現できます。

参考までにearly_stopping使用時の学習フローをまとめると、以下のようになります。

・early_stopping使用時のLightGBM学習フロー

なお、early_stopping使用時は通例、num_boost_round引数(Scikit-Learn APIではn_estimators引数)に大きな値(例: 10000)を指定し、常にearly_stoppingで学習が終了するようにフローが組まれます。

また、early_stopping_roundsの数ですが、一般的には(筆者の経験とGoogle検索した結果)10〜100前後が指定されることが多いようです。本記事では例として10を使用することとします。

early_stoppingの実装方法

旧指定方法(early_stopping_rounds引数)、現在の推奨方法(コールバック関数early_stopping())それぞれについて、実装方法を解説します。

旧指定方法 ('early_stopping_rounds'引数)

非推奨となったearly_stopping_rounds引数での実装方法を、Training API、Scikit-Learn APIそれぞれについて示します

Training APIの場合

学習実行メソッドtrain()early_stopping_rounds引数に学習を打ち切るイテレーション数(前記「一定回数」に相当)を指定することで、early_stoppingを実現できます。

TrainingAPI+旧指定方法(early_stopping_rounds)での実装法
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
import lightgbm as lgb
# データセット読込(カリフォルニア住宅価格)
TARGET_VARIABLE = 'price'  # 目的変数名
USE_EXPLANATORY = ['MedInc', 'AveOccup', 'Latitude', 'HouseAge']  # 説明変数名
california_housing = pd.DataFrame(np.column_stack((fetch_california_housing().data, fetch_california_housing().target)),
        columns = np.append(fetch_california_housing().feature_names, TARGET_VARIABLE))
california_housing = california_housing.sample(n=1000, random_state=42)  # データ数多いので1000にサンプリング
y = california_housing[TARGET_VARIABLE].values  # 目的変数のnumpy配列
X = california_housing[USE_EXPLANATORY].values  # 説明変数のnumpy配列
# テストデータと学習データ分割
X_train_raw, X_test, y_train_raw, y_test = train_test_split(X, y, test_size=0.25, random_state=42)
# early_stopping用の評価データをさらに分割
X_train, X_valid, y_train, y_valid = train_test_split(X_train_raw, y_train_raw, test_size=0.25, random_state=42)

###### ここからがLightGBMの実装 ######
# データをDatasetクラスに格納
dtrain = lgb.Dataset(X_train, label=y_train)  # 学習用
dvalid = lgb.Dataset(X_valid, label=y_valid)  # early_stopping用
# 使用するパラメータ
params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
         'metric': 'rmse',  # 学習時に使用する評価指標(2クラス分類の場合'binary_logloss'等、多クラス分類の場合'multi_logloss'等)
         'random_state': 42,  # 乱数シード
         'boosting_type': 'gbdt',  # boosting_type
         'verbose': -1  # これを指定しないと`No further splits with positive gain, best gain: -inf`というWarningが表示される
         }
verbose_eval = 0  # この数字を1にすると学習時のスコア推移がコマンドライン表示される
# early_stoppingを指定してLightGBM学習
gbm = lgb.train(params, dtrain,
                valid_sets=[dvalid],  # early_stoppingの評価用データ
                num_boost_round=10000,  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
                early_stopping_rounds=10,
                verbose_eval=verbose_eval
                )

# スコア(RMSE)算出
y_pred = gbm.predict(X_test)
score = mean_squared_error(y_true=y_test, y_pred=y_pred, squared=False)
print(f'RMSE={score}')
実行結果
lightgbm/engine.py:181: UserWarning: 'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM.
 Pass 'early_stopping()' callback via 'callbacks' argument instead.
  _log_warning("'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM. "
:
(中略)
:
RMSE=0.722485359165361

UserWarningは出ていますが、処理自体は正常に実行できていることが分かります。
(ただし、前述のように将来的に動作しなくなる恐れあり)

Scikit-Learn APIの場合

学習実行メソッドfit()early_stopping_rounds引数に学習を打ち切るイテレーション数を指定することで、early_stoppingを実現できます。

Scikit-LearnAPI+旧指定方法(early_stopping_rounds)での実装法
:
データ読込データ分割処理は省略上記コードと同じ
:
###### ここからがLightGBMの実装 ######
# 使用するパラメータ
params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
         'random_state': 42,  # 乱数シード
         'boosting_type': 'gbdt',  # boosting_type
         'n_estimators': 10000  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
         }
verbose_eval = 0  # この数字を1にすると学習時のスコア推移がコマンドライン表示される
# early_stoppingを指定してLightGBM学習
lgbr = lgb.LGBMRegressor(**params)
lgbr.fit(X_train, y_train, 
         eval_metric='rmse',  # early_stoppingの評価指標(学習用の'metric'パラメータにも同じ指標が自動入力される)
         eval_set=[(X_valid, y_valid)],
         early_stopping_rounds=10,
         verbose=verbose_eval
         )

# スコア(RMSE)算出
y_pred = lgbr.predict(X_test)
score = mean_squared_error(y_true=y_test, y_pred=y_pred, squared=False)
print(f'RMSE={score}')
実行結果
lightgbm/sklearn.py:726: UserWarning: 'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM.
 Pass 'early_stopping()' callback via 'callbacks' argument instead.
  _log_warning("'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM. "
:
(中略)
:
RMSE=0.722485359165361

UserWarningは出ていますが、処理自体は正常に実行できていることが分かります。
(ただし、前述のように将来的に動作しなくなる恐れあり)

現在の推奨方法 (コールバック関数'early_stopping()')

2022/5現在の推奨手法であるコールバック関数lightgbm.early_stopping()での実装方法を、Training API、Scikit-Learn APIそれぞれについて示します

Training APIの場合

学習実行メソッドtrain()early_stopping_rounds引数の代わりに、callbacks引数にコールバック関数lightgbm.early_stopping()をリスト指定することで、early_stoppingを実現できます。

TrainingAPI+新指定方法(コールバック関数)での実装法
:
データ読込データ分割処理は省略上記コードと同じ
:
###### ここからがLightGBMの実装 ######
# データをDatasetクラスに格納
dtrain = lgb.Dataset(X_train, label=y_train)  # 学習用
dvalid = lgb.Dataset(X_valid, label=y_valid)  # early_stopping用
# 使用するパラメータ
params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
         'metric': 'rmse',  # 学習時に使用する評価指標(2クラス分類の場合'binary_logloss'等、多クラス分類の場合'multi_logloss'等)
         'random_state': 42,  # 乱数シード
         'boosting_type': 'gbdt',  # boosting_type
         'verbose': -1  # これを指定しないと`No further splits with positive gain, best gain: -inf`というWarningが表示される
         }
verbose_eval = 0  # この数字を1にすると学習時のスコア推移がコマンドライン表示される
# early_stoppingを指定してLightGBM学習
gbm = lgb.train(params, dtrain,
                valid_sets=[dvalid],  # early_stoppingの評価用データ
                num_boost_round=10000,  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
                callbacks=[lgb.early_stopping(stopping_rounds=10, 
                                verbose=True), # early_stopping用コールバック関数
                           lgb.log_evaluation(verbose_eval)] # コマンドライン出力用コールバック関数
                )

# スコア(RMSE)算出
y_pred = gbm.predict(X_test)
score = mean_squared_error(y_true=y_test, y_pred=y_pred, squared=False)
print(f'RMSE={score}')
実行結果
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[43]	valid_0's rmse: 0.588869	valid_0's l2: 0.346766
RMSE=0.722485359165361

Warningが出ておらず、かつearly_stopping_round引数での指定時と同スコア=同じ処理を実現できていることが分かります。

なお、コールバック関数lightgbm.early_stopping()の引数stopping_roundsには旧手法のearly_stopping_roundsに相当する数を入力します。
またverbose引数をTrueに指定することで、何回目の学習でスコアが最大となったか(early_stoppingで学習が終了したイテレーション - stopping_rounds)を表示することができます。
上例では43回目の学習でスコアが最大となり、そこから10回連続でスコアが改善せずに53回目で処理が打ち切られたことが分かります。

コールバック関数log_evaluation()はコマンドライン出力の詳細さを指定するためのメソッドで、上例のようにcallbacks引数のリストに追加すると結果の詳細確認ができて便利ですが、追加しなくともearly_stoppingの動作自体は正常に実行できます。

Scikit-Learn APIの場合

学習実行メソッドfit()early_stopping_rounds引数の代わりに、callbacks引数にコールバック関数lightgbm.early_stopping()をリスト指定することで、early_stoppingを実現できます。

Scikit-LearnAPI+新指定方法(コールバック関数)での実装法
:
データ読込データ分割処理は省略上記コードと同じ
:
###### ここからがLightGBMの実装 ######
# 使用するパラメータ
params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
         'random_state': 42,  # 乱数シード
         'boosting_type': 'gbdt',  # boosting_type
         'n_estimators': 10000  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
         }
verbose_eval = 0  # この数字を1にすると学習時のスコア推移がコマンドライン表示される
# early_stoppingを指定してLightGBM学習
lgbr = lgb.LGBMRegressor(**params)
lgbr.fit(X_train, y_train, 
         eval_metric='rmse',  # early_stoppingの評価指標(学習用の'metric'パラメータにも同じ指標が自動入力される)
         eval_set=[(X_valid, y_valid)],
         callbacks=[lgb.early_stopping(stopping_rounds=10, 
                        verbose=True), # early_stopping用コールバック関数
                    lgb.log_evaluation(verbose_eval)] # コマンドライン出力用コールバック関数
         )

# スコア(RMSE)算出
y_pred = lgbr.predict(X_test)
score = mean_squared_error(y_true=y_test, y_pred=y_pred, squared=False)
print(f'RMSE={score}')
実行結果
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[43]	valid_0's rmse: 0.588869	valid_0's l2: 0.346766
RMSE=0.722485359165361

Warningが出ておらず、かつearly_stopping_round引数での指定時と同スコア=同じ処理を実現できていることが分かります。

コールバック関数や各引数の意味はTraining APIの場合と同様です

クロスバリデーション実行時の挙動

クロスバリデーションでモデル評価を行う際は、early_stoppingの実装に更なる注意が必要となります。

以下のように様々な実装方法がありますが、計算結果が異なるパターンがあったり、そのままではうまく動作しないパターン(×部分が相当)があったりするので、適切な実装法を選択する必要があります。

旧指定方法
('early_stopping_rounds'引数)
現在の推奨方法
(コールバック関数'early_stopping()')
Training API
(スクラッチ実装)
Training API
(lightgbm.cvメソッド)
○ (他方式とは別計算法) ○ (他方式とは別計算法)
Scikit-Learn API
(スクラッチ実装)
Scikit-Learn API
(cross_val_scoreメソッド)
× (自作メソッドで対応) × (自作メソッドで対応)

たくさん種類があって

「どれ使えばええねん!」

と思われた方も多いかと思いますが、
スクラッチだと実装の手間が増える&並列処理が効かず速度面でも不利なので、

基本 → lightgbm.cv()メソッド
どうしてもScikit-LearnのAPIを使用したい場合 → cross_val_scoreメソッド改

の方法が良いかと思います(表中の太字部分に相当)
以下詳説します

なお、クロスバリデーションなしの場合ではearly_stopping用の評価データをテストデータとは別に準備(LightGBMの亜種であるsklearn.ensemble.HistGradientBoostingRegressorvalidation_fractionのデータ分割仕様に準拠)していましたが、本項ではクロスバリデーションのテストデータをearly_stopping用の評価データにも使用(lightgbm.cvのデータ分割仕様に準拠)していることにご注意ください。

 気付かれた方もいるかと思いますが、クロスバリデーションありの場合ではearly_stopping評価用データとスコア評価用のデータが同一となっています。これでパラメータチューニング等を行うとearly_stopping用のデータがリークした状態となっており、「弱い過学習」とも言えるような状態になってしまいます。本記事では公式のlightgbm.cv()メソッドの仕様に準拠してこの分割方法を採用しています(定性的ですが、私の経験上もこの分割法で過学習により未知データに対する性能が極端に低下する現象は観測されませんでした)。
 リークを気にされる方は、クロスバリデーションのスコア評価用データとは別にearly_stopping用のデータを確保してください。
後述の自作ライブラリによる対処も参照ください)

旧指定方法 ('early_stopping_rounds'引数)

Training API (スクラッチ実装)の場合

Scikit-Learnのデータ分割用クラスを使用してsplit()メソッドでデータを分割し、for文でFoldごとにTraining APIでスコアを求めるスクラッチな手法です。

TrainingAPI+旧指定方法(early_stopping_rounds)+スクラッチ実装のクロスバリデーション
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.metrics import mean_squared_error
import lightgbm as lgb
from sklearn.model_selection import KFold
# データセット読込(カリフォルニア住宅価格)
TARGET_VARIABLE = 'price'  # 目的変数名
USE_EXPLANATORY = ['MedInc', 'AveOccup', 'Latitude', 'HouseAge']  # 説明変数名
california_housing = pd.DataFrame(np.column_stack((fetch_california_housing().data, fetch_california_housing().target)),
    columns = np.append(fetch_california_housing().feature_names, TARGET_VARIABLE))
california_housing = california_housing.sample(n=1000, random_state=42)  # データ数多いので1000にサンプリング
y = california_housing[TARGET_VARIABLE].values  # 目的変数のnumpy配列
X = california_housing[USE_EXPLANATORY].values  # 説明変数のnumpy配列

# クロスバリデーション用のScikit-Learnクラス(5分割KFold)
cv = KFold(n_splits=5, shuffle=True, random_state=42)

# クロスバリデーションのデータ分割
scores=[]
for i, (train, test) in enumerate(cv.split(X, y)):
    ###### ここからがLightGBMの実装 ######
    # データをDatasetクラスに格納
    dtrain = lgb.Dataset(X[train], label=y[train])  # 学習データ
    dvalid = lgb.Dataset(X[test], label=y[test])  # early_stopping用(テストデータを使用)
    # 使用するパラメータ
    params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
             'metric': 'rmse',  # 学習時に使用する評価指標(2クラス分類の場合'binary_logloss'等、多クラス分類の場合'multi_logloss'等)
             'random_state': 42,  # 乱数シード
             'boosting_type': 'gbdt',  # boosting_type
             'verbose': -1  # これを指定しないと`No further splits with positive gain, best gain: -inf`というWarningが表示される
             }
    verbose_eval = 0  # この数字を1にすると学習時のスコア推移がコマンドライン表示される
    # early_stoppingを指定してLightGBM学習
    gbm = lgb.train(params, dtrain,
                    valid_sets=[dvalid],  # early_stoppingの評価用データ
                    num_boost_round=10000,  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
                    early_stopping_rounds=10,
                    verbose_eval=verbose_eval
                    )

    # スコア(RMSE)算出
    y_pred = gbm.predict(X[test])
    score = mean_squared_error(y_true=y[test], y_pred=y_pred, squared=False)
    scores.append(score)
print(f'RMSE={scores} \nRMSE mean={np.mean(scores)}')
実行結果
lightgbm/engine.py:181: UserWarning: 'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM.
 Pass 'early_stopping()' callback via 'callbacks' argument instead.
  _log_warning("'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM. "
:
(中略)
:
RMSE=[0.738983559441704, 0.6150024788535946, 0.6803952077929987, 0.6445438943448907, 0.6913201783978795] 
RMSE mean=0.6740490637662134

UserWarningは出ていますが、処理自体は正常に実行できていることが分かります(データ分割後の処理はクロスバリデーションなしのTraining APIの場合と同じなので当然といえば当然ですが)

なお注意点として、コールバック関数はtrain()メソッドを1回実行する度に初期化する必要があることです(Scikit-Learn APIのfit()メソッド等も同様です)
for文の外側でコールバック関数を定義しないようにご注意ください(初期化されないまま複数の学習が走って正常に処理されなくなる)

Training API (lightgbm.cv()メソッド)の場合

LightGBMには、クロスバリデーションの全Fold一括でスコアを計算するlightgbm.cv()というメソッドが用意されています。

lightgbm.cv()メソッドの実装は、以下のように通常の学習に用いられるtrain()メソッドと近いAPIで実現可能であり、スクラッチの場合よりも短いコードで実装できます。
valid_sets引数の指定が不要となることにご注意ください。代わりにクロスバリデーションのテストデータがearly_stoppingの評価用データに使用されます)

TrainingAPI+旧指定方法(early_stopping_rounds)+lightgbm.cv()メソッドのクロスバリデーション
:
データ読込処理は省略上記コードと同じ
:
# クロスバリデーション用のScikit-Learnクラス(5分割KFold)
cv = KFold(n_splits=5, shuffle=True, random_state=42)

###### ここからがLightGBMの実装 ######
# データをDatasetクラスに格納
dcv = lgb.Dataset(X, label=y)  # クロスバリデーション用
# 使用するパラメータ
params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
         'metric': 'rmse',  # 学習時に使用する評価指標(2クラス分類の場合'binary_logloss'等、多クラス分類の場合'multi_logloss'等)
         'random_state': 42,  # 乱数シード
         'boosting_type': 'gbdt',  # boosting_type
         'verbose': -1  # これを指定しないと`No further splits with positive gain, best gain: -inf`というWarningが表示される
         }
verbose_eval = 0  # この数字を1にすると学習時のスコア推移がコマンドライン表示される
# early_stoppingを指定してLightGBMをクロスバリデーション
cv_result = lgb.cv(params, dcv,
                num_boost_round=10000,  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
                early_stopping_rounds=10,
                verbose_eval=verbose_eval,
                folds=cv
                )
print(cv_result)
print(f'RMSE mean={cv_result["rmse-mean"][-1]}')

以下の記事で詳細に解説されていますが、

前述のスクラッチ実装後述のScikit-Learn cross_validate()メソッドでは
「各Foldごとにearly_stoppingを適用して学習→スコア平均を算出」
という手順で処理が進むのに対し、

lightgbm.cv()メソッドでは
「全Foldのスコア平均をイテレーション毎に算出→スコア平均にearly_stoppingを適用」
という手順で処理が進むため、他方式とは異なる最終スコアが計算されます

他方式と比べたメリットデメリットは上記参考リンクに記載されていますが、一長一短といった感じのようです。

実行結果
lightgbm/engine.py:577: UserWarning: 'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM.
 Pass 'early_stopping()' callback via 'callbacks' argument instead.
  _log_warning("'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM. "
:
(中略)
:
{'rmse-mean': [1.092268418453115, 1.0310450173717558, 0.9795686341063112, 
0.9357050975378067, 0.898026355776955, 0.8653287143548599, 0.8361953918654839, 
0.8118105375696019, 0.7913531404345221, 0.7740533884697003, 0.7581006340525287, 
0.7457239616368891, 0.734370054531101, 0.7257891055825861, 0.7187968572833688, 
0.711875704872069, 0.7061541973418042, 0.7016815498175433, 0.6974989018815776, 
0.6953583144477753, 0.6934006796597723, 0.6913382320507665, 0.6891147218487653, 
0.6873271643189067, 0.6859662264261306, 0.6848257970905169, 0.6834265660057884, 
0.6821396043772722, 0.6813493570473369, 0.6802932554692094, 0.6794027048145301, 
0.6784863031615622, 0.6777672012337602, 0.6775112510272382, 0.6767615074384222],
'rmse-stdv': [0.047163893109934095, 0.04330692544819591, 0.0412303842717346, 
0.03918788111501026, 0.037610483543590825, 0.03572218937059543, 0.03549682540172055, 
0.03524086095052791, 0.03624560680763654, 0.037586214743957506, 0.03928402431293732, 
0.0378483690801823, 0.03818586654066806, 0.03869355751568724, 0.03910746326923766, 
0.03936895674923746, 0.03947841587505398, 0.039117262719758225, 0.03959543152820096, 
0.03881841849115544, 0.03881540132987051, 0.03799963684609526, 0.0369718932140366, 
0.03680779639424503, 0.03670904081679501, 0.03661096377218453, 0.03642053463285243, 
0.03642331278429394, 0.036879608212898304, 0.03692147800332548, 0.03672366274081491, 
0.03609096933230052, 0.03617965035489701, 0.0362180844993717, 0.03719491159161849]}
RMSE mean=0.6767615074384222

出力結果の'rmse-mean'は、イテレーション毎のスコア平均を表しています。
上例では'rmse-mean'の要素数が35個であることから、35番目のイテレーションでスコア平均が最良となり、そこから10イテレーション連続でスコア平均が改善しなかったため、45回目のイテレーションでearly_stoppingにより学習が打ち切られたことが分かります。

Scikit-Learn API (スクラッチ実装)の場合

Scikit-Learnのデータ分割用クラスを使用してsplit()メソッドでデータを分割し、FoldごとにScikit-Learn APIでスコアを求めるスクラッチな手法です。

Scikit-LearnAPI+旧指定方法(early_stopping_rounds)+スクラッチ実装のクロスバリデーション
:
データ読込処理は省略上記コードと同じ
:
# クロスバリデーション用のScikit-Learnクラス(5分割KFold)
cv = KFold(n_splits=5, shuffle=True, random_state=42)

# クロスバリデーションのデータ分割
scores=[]
for i, (train, test) in enumerate(cv.split(X, y)):
    ###### ここからがLightGBMの実装 ######
    # 使用するパラメータ
    params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
            'random_state': 42,  # 乱数シード
            'boosting_type': 'gbdt',  # boosting_type
            'n_estimators': 10000  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
            }
    verbose_eval = 0  # この数字を1にすると学習時のスコア推移がコマンドライン表示される
    # early_stoppingを指定してLightGBM学習
    lgbr = lgb.LGBMRegressor(**params)
    lgbr.fit(X[train], y[train], 
            eval_metric='rmse',  # early_stoppingの評価指標(学習用の'metric'パラメータにも同じ指標が自動入力される)
            eval_set=[(X[test], y[test])],
            early_stopping_rounds=10,
            verbose=verbose_eval
            )

    # スコア(RMSE)算出
    y_pred = lgbr.predict(X[test])

    score = mean_squared_error(y_true=y[test], y_pred=y_pred, squared=False)
    scores.append(score)
print(f'RMSE={scores} \nRMSE mean={np.mean(scores)}')
実行結果
lightgbm/engine.py:181: UserWarning: 'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM.
 Pass 'early_stopping()' callback via 'callbacks' argument instead.
  _log_warning("'early_stopping_rounds' argument is deprecated
 and will be removed in a future release of LightGBM. "
:
(中略)
:
RMSE=[0.738983559441704, 0.6150024788535946, 0.6803952077929987, 0.6445438943448907, 0.6913201783978795] 
RMSE mean=0.6740490637662134

Training APIのスクラッチ実装と同スコアになっており、同様の処理が実現できていることが分かります

Scikit-Learn API (cross_val_scoreメソッド)の場合

Scikit-Learnにはcross_val_score()という、クロスバリデーションでスコアを計算するためのメソッドが存在し、これを利用することでクロスバリデーションを簡単に実装することができます。

しかしこのcross_val_score()、クロスバリデーション分割後のデータをearly_stoppingの評価用データであるeval_setに渡せないため、lightgbm.cv()メソッドのときのようにテストデータからearly_stoppingを判定することができません

また前述のようにコールバック関数は学習を1回実行するごとに初期化する必要がありますが、cross_val_score()メソッド内ではこの初期化が行われていないため、初期化されないまま複数の学習が走って正常に処理されなくなります。

そこでクロスバリデーション分割後のテストデータをeval_setに渡せるようにしたメソッドcross_val_score_eval_set()新たに自作しました。
内部的には現在の推奨方法(コールバック関数)でearly_stoppingを実現しているため、詳細は後述します。
以下リンクを参照ください

現在の推奨方法 (コールバック関数'early_stopping()')

Training API (スクラッチ実装)の場合

クロスバリデーションなしの場合と同様、学習実行メソッドtrain()early_stopping_rounds引数の代わりに、callbacks引数にコールバック関数lightgbm.early_stopping()をリスト指定することで、early_stoppingを実現できます。

TrainingAPI+新指定方法(コールバック関数)+スクラッチ実装のクロスバリデーション
:
データ読込処理は省略上記コードと同じ
:
# クロスバリデーション用のScikit-Learnクラス(5分割KFold)
cv = KFold(n_splits=5, shuffle=True, random_state=42)

# クロスバリデーションのデータ分割
scores=[]
for i, (train, test) in enumerate(cv.split(X, y)):
    ###### ここからがLightGBMの実装 ######
    # データをDatasetクラスに格納
    dtrain = lgb.Dataset(X[train], label=y[train])  # 学習データ
    dvalid = lgb.Dataset(X[test], label=y[test])  # early_stopping用(テストデータを使用)
    # 使用するパラメータ
    params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
             'metric': 'rmse',  # 学習時に使用する評価指標(2クラス分類の場合'binary_logloss'等、多クラス分類の場合'multi_logloss'等)
             'random_state': 42,  # 乱数シード
             'boosting_type': 'gbdt',  # boosting_type
             'verbose': -1  # これを指定しないと`No further splits with positive gain, best gain: -inf`というWarningが表示される
             }
    verbose_eval = 0  # この数字を1にすると学習時のスコア推移がコマンドライン表示される
    # early_stoppingを指定してLightGBM学習
    gbm = lgb.train(params, dtrain,
                    valid_sets=[dvalid],  # early_stoppingの評価用データ
                    num_boost_round=10000,  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
                    callbacks=[lgb.early_stopping(stopping_rounds=10, 
                                verbose=True), # early_stopping用コールバック関数
                           lgb.log_evaluation(verbose_eval)] # コマンドライン出力用コールバック関数
                    )

    # スコア(RMSE)算出
    y_pred = gbm.predict(X[test])
    score = mean_squared_error(y_true=y[test], y_pred=y_pred, squared=False)
    scores.append(score)
print(f'RMSE={scores} \nRMSE mean={np.mean(scores)}')
実行結果
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[30]	valid_0's rmse: 0.738984
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[37]	valid_0's rmse: 0.615002
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[43]	valid_0's rmse: 0.680395
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[36]	valid_0's rmse: 0.644544
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[36]	valid_0's rmse: 0.69132
RMSE=[0.738983559441704, 0.6150024788535946, 0.6803952077929987, 0.6445438943448907, 0.6913201783978795] 
RMSE mean=0.6740490637662134

UserWarningが消えており、かつ旧実装方法の時と同スコアとなっており、同様の処理が実現できていることが分かります

Training API (lightgbm.cv()メソッド)の場合

上例と同様、クロスバリデーション実行メソッドcv()early_stopping_rounds引数の代わりに、callbacks引数にコールバック関数lightgbm.early_stopping()をリスト指定することで、early_stoppingを実現できます。

TrainingAPI+新指定方法(コールバック関数)+lightgbm.cv()メソッドのクロスバリデーション
:
データ読込処理は省略上記コードと同じ
:
# クロスバリデーション用のScikit-Learnクラス(5分割KFold)
cv = KFold(n_splits=5, shuffle=True, random_state=42)

###### ここからがLightGBMの実装 ######
# データをDatasetクラスに格納
dcv = lgb.Dataset(X, label=y)  # クロスバリデーション用
# 使用するパラメータ
params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
         'metric': 'rmse',  # 学習時に使用する評価指標(2クラス分類の場合'binary_logloss'等、多クラス分類の場合'multi_logloss'等)
         'random_state': 42,  # 乱数シード
         'boosting_type': 'gbdt',  # boosting_type
         'verbose': -1  # これを指定しないと`No further splits with positive gain, best gain: -inf`というWarningが表示される
         }
verbose_eval = 0  # この数字を1にすると学習時のスコア推移がコマンドライン表示される
# early_stoppingを指定してLightGBMをクロスバリデーション
cv_result = lgb.cv(params, dcv,
                num_boost_round=10000,  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
                folds=cv,
                callbacks=[lgb.early_stopping(stopping_rounds=10, 
                                verbose=True), # early_stopping用コールバック関数
                           lgb.log_evaluation(verbose_eval)] # コマンドライン出力用コールバック関数
                )
print(cv_result)
print(f'RMSE mean={cv_result["rmse-mean"][-1]}')
実行結果
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[35]	cv_agg's rmse: 0.676762 + 0.0371949
{'rmse-mean': [1.092268418453115, 1.0310450173717558, 0.9795686341063112, 0.9357050975378067, 0.898026355776955, 0.8653287143548599, 0.8361953918654839, 0.8118105375696019, 0.7913531404345221, 0.7740533884697003, 0.7581006340525287, 0.7457239616368891, 0.734370054531101, 0.7257891055825861, 0.7187968572833688, 0.711875704872069, 0.7061541973418042, 0.7016815498175433, 0.6974989018815776, 0.6953583144477753, 0.6934006796597723, 0.6913382320507665, 0.6891147218487653, 0.6873271643189067, 0.6859662264261306, 0.6848257970905169, 0.6834265660057884, 0.6821396043772722, 0.6813493570473369, 0.6802932554692094, 0.6794027048145301, 0.6784863031615622, 0.6777672012337602, 0.6775112510272382, 0.6767615074384222], 'rmse-stdv': [0.047163893109934095, 0.04330692544819591, 0.0412303842717346, 0.03918788111501026, 0.037610483543590825, 0.03572218937059543, 0.03549682540172055, 0.03524086095052791, 0.03624560680763654, 0.037586214743957506, 0.03928402431293732, 0.0378483690801823, 0.03818586654066806, 0.03869355751568724, 0.03910746326923766, 0.03936895674923746, 0.03947841587505398, 0.039117262719758225, 0.03959543152820096, 0.03881841849115544, 0.03881540132987051, 0.03799963684609526, 0.0369718932140366, 0.03680779639424503, 0.03670904081679501, 0.03661096377218453, 0.03642053463285243, 0.03642331278429394, 0.036879608212898304, 0.03692147800332548, 0.03672366274081491, 0.03609096933230052, 0.03617965035489701, 0.0362180844993717, 0.03719491159161849]}
RMSE mean=0.6767615074384222

UserWarningが消えており、かつ旧指定方法の時と同スコアとなっており、同様の処理が実現できていることが分かります

また35回目のイテレーションで最大値となった(45回目でearly_stoppingが適用された)ことが表示されており、より結果が見やすくなっています。

Scikit-Learn API (スクラッチ実装)の場合

クロスバリデーションなしの場合と同様、学習実行メソッドfit()early_stopping_rounds引数の代わりに、callbacks引数にコールバック関数lightgbm.early_stopping()をリスト指定することで、early_stoppingを実現できます。

Scikit-LearnAPI+新指定方法(コールバック関数)+スクラッチ実装のクロスバリデーション
:
データ読込処理は省略上記コードと同じ
:
# クロスバリデーション用のScikit-Learnクラス(5分割KFold)
cv = KFold(n_splits=5, shuffle=True, random_state=42)

# クロスバリデーションのデータ分割
scores=[]
for i, (train, test) in enumerate(cv.split(X, y)):
    ###### ここからがLightGBMの実装 ######
    # 使用するパラメータ
    params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
            'random_state': 42,  # 乱数シード
            'boosting_type': 'gbdt',  # boosting_type
            'n_estimators': 10000  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
            }
    verbose_eval = 0  # この数字を1にすると学習時のスコア推移がコマンドライン表示される
    # early_stoppingを指定してLightGBM学習
    lgbr = lgb.LGBMRegressor(**params)
    lgbr.fit(X[train], y[train], 
            eval_metric='rmse',  # early_stoppingの評価指標(学習用の'metric'パラメータにも同じ指標が自動入力される)
            eval_set=[(X[test], y[test])],
            callbacks=[lgb.early_stopping(stopping_rounds=10, 
                                verbose=True), # early_stopping用コールバック関数
                           lgb.log_evaluation(verbose_eval)] # コマンドライン出力用コールバック関数
            )

    # スコア(RMSE)算出
    y_pred = lgbr.predict(X[test])

    score = mean_squared_error(y_true=y[test], y_pred=y_pred, squared=False)
    scores.append(score)
print(f'RMSE={scores} \nRMSE mean={np.mean(scores)}')
実行結果
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[30]	valid_0's rmse: 0.738984	valid_0's l2: 0.546097
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[37]	valid_0's rmse: 0.615002	valid_0's l2: 0.378228
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[43]	valid_0's rmse: 0.680395	valid_0's l2: 0.462938
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[36]	valid_0's rmse: 0.644544	valid_0's l2: 0.415437
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[36]	valid_0's rmse: 0.69132	valid_0's l2: 0.477924
RMSE=[0.738983559441704, 0.6150024788535946, 0.6803952077929987, 0.6445438943448907, 0.6913201783978795] 
RMSE mean=0.6740490637662134

旧方式での実装時、およびTraining APIのスクラッチ実装の時と同スコアになっており、同様の処理が実現できていることが分かります。

Scikit-Learn API (cross_val_scoreメソッド)の場合

繰り返しとなりますが、Scikit-Learnにはcross_val_score()という、クロスバリデーションでスコアを計算するためのメソッドが存在し、これを利用することでクロスバリデーションを簡単に実装することができます。

しかしこのcross_val_score()、クロスバリデーション分割後のデータをearly_stoppingの評価用データであるeval_setに渡せないため、lightgbm.cv()メソッドのときのようにテストデータからearly_stoppingを判定することができません

そこでクロスバリデーション分割後のテストデータをeval_setに渡せるようにしたメソッドcross_val_score_eval_set()新たに自作し、こちらの記事で紹介したライブラリseaborn-anlayzerに追加しました。以下に使用例を記載します。

Scikit-LearnAPI+新指定方法(コールバック関数)+cross_val_score_eval_setでクロスバリデーション
:
データ読込処理は省略上記コードと同じ
:
# クロスバリデーション用のScikit-Learnクラス(5分割KFold)
cv = KFold(n_splits=5, shuffle=True, random_state=42)

###### ここからがLightGBMの実装 ######
from seaborn_analyzer._cv_eval_set_old import cross_val_score_eval_set
# 使用するパラメータ
params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
        'random_state': 42,  # 乱数シード
        'boosting_type': 'gbdt',  # boosting_type
        'n_estimators': 10000  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
        }
verbose_eval = 0  # この数字を1にすると学習時のスコア推移がコマンドライン表示される
# early_stoppingを指定してLightGBM学習
lgbr = lgb.LGBMRegressor(**params)
# クロスバリデーション内部で`fit()`メソッドに渡すパラメータ
fit_params = {'eval_metric':'rmse',
              'eval_set':[(X, y)],
              'early_stopping_rounds': 10,
              'verbose': verbose_eval}
# クロスバリデーション実行
scores = cross_val_score_eval_set(
        eval_set_selection='test',  # 'test'と指定するとテストデータを'eval_set'に渡せる
        estimator=lgbr,  # 学習器
        X=X, y=y,  # クロスバリデーション分割前のデータを渡す
        scoring='neg_root_mean_squared_error',  # RMSE(の逆数)を指定
        cv=cv, verbose=verbose_eval, fit_params=fit_params
        )
print(f'RMSE={scores} \nRMSE mean={np.mean(scores)}')
実行結果
RMSE=[-0.73898356 -0.61500248 -0.68039521 -0.64454389 -0.69132018] 
RMSE mean=-0.6740490637662134

Training APIおよびScikit-Learn APIのスクラッチ実装と同スコアになっており、同様の処理がループを実装することなく実現できていることが分かります。

またAPI的には旧方式であるearly_stopping_rounds引数を使用していますが(XGBoost等とAPIを統一するためにあえてこちらを使用)、内部的には現在の推奨方式であるコールバック関数early_stopping()に変換しているため、UserWarningも出ません。

また前述のようにコールバック関数は学習を1回実行するごとに初期化する必要がありますが、こちらも学習実行ごとに初期化するよう内部的に実装しています。

LightGBM単体でクロスバリデーションしたい際にはlightgbm.cv()メソッドの方が使い勝手が良いですが、cross_val_score_eval_set()メソッドはLightGBM以外のScikit-Learn学習器(SVM, XGBoost等)にもそのまま適用できるため、後述のようにAPIの共通化を図りたい際にご活用頂ければと思います。

【参考】 Training APIとScikit-Learn APIの差

本記事でも逐次触れましたが、LightGBMにはTraining APIとScikit-Learn APIという2種類の実装方式が存在します。

どちらも広く用いられており、LightGBMの使用法を学ぶ上で混乱の一因となっているため、両者の違いについて触れたいと思います。

コードを見るとScikit-Learn APIも内部的にTraining APIを実行しているため、両者の学習器としての動作は同一ですが、データの入力形式や使用方法に大きな差があります。

以下に主な差異をまとめました

Training API Scikit-Learn API
使用するデータセットの型 Datasetクラスに格納する必要がある numpy配列(ndarray)をそのまま渡せる
学習用メソッド名 train()メソッド fit()メソッド
使用する評価指標 学習時のtrain()メソッドのparams引数中に、metricsとして指定 学習時のfit()メソッドのeval_metric引数に渡す
学習の最大回数 学習用メソッドtrain()num_boost_round引数に渡す LGBMRegressor, LGBMClassifierクラス初期化時のn_estimators引数に渡す
early_stopping判定の評価用データ 学習用メソッドtrain()valid_sets引数に渡す 学習用メソッドfit()eval_set引数に渡す
クロスバリデーション用メソッド名 cv()メソッド なし (前述の自作メソッドで対応可)

両者の使い分けですが、

・LightGBM単体で使用したい場合 → Training APIの方が使い勝手が良い
・他の学習器(SVM、XGBoost等)と併用したい場合 → APIが統一できるScikit-Learn APIの方が使い勝手が良い

のように認識するのが適切かと思います。

mlxtend等の可視化ライブラリもScikit-Learnの学習器を渡す形式が多く、そういう意味でScikit-Learn APIの方が汎用性が広いと言えます。
一方で前述のようにScikit-Learn APIはクロスバリデーションとearly_stoppingを同時に使用できないという弱点を抱えており(今回は自作メソッドで対応しましたが、本家LightGBMでも類似機能の議論が行われているようです)クロスバリデーションを前提としたパラメータチューニング等の用途には現時点ではTraining APIの方が向いていると言えそうです。

参考までに、LightGBM公式ではパラメータチューニングの一例としてOptunaのLightGBMチューニング用クラスの使用が紹介されています(サンプルコード

【参考】 第3のearly_stopping指定方法

本記事で紹介した旧指定方法(early_stopping_rounds引数)、現在の推奨方法(コールバック関数early_stopping())以外にも、実は第3のearly_stopping指定方法が存在します。

それは学習実行メソッドtrain())のparams引数にearly_stopping_roundという値を指定する方法です(同様にeval_metricmetricという名前に変えてparams引数の中に含めます)

例えば、クロスバリデーションなしのTraining API(こちらのケースと同様の処理)では、以下のように指定します

TrainingAPI+第3の指定方法('params'引数の'early_stopping_round')での実装法
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
import lightgbm as lgb
# データセット読込(カリフォルニア住宅価格)
TARGET_VARIABLE = 'price'  # 目的変数名
USE_EXPLANATORY = ['MedInc', 'AveOccup', 'Latitude', 'HouseAge']  # 説明変数名
california_housing = pd.DataFrame(np.column_stack((fetch_california_housing().data, fetch_california_housing().target)),
    columns = np.append(fetch_california_housing().feature_names, TARGET_VARIABLE))
california_housing = california_housing.sample(n=1000, random_state=42)  # データ数多いので1000にサンプリング
y = california_housing[TARGET_VARIABLE].values  # 目的変数のnumpy配列
X = california_housing[USE_EXPLANATORY].values  # 説明変数のnumpy配列
# テストデータと学習データ分割
X_train_raw, X_test, y_train_raw, y_test = train_test_split(X, y, test_size=0.25, random_state=42)
# early_stopping用の評価データをさらに分割
X_train, X_valid, y_train, y_valid = train_test_split(X_train_raw, y_train_raw, test_size=0.25, random_state=42)

###### ここからがLightGBMの実装 ######
# データをDatasetクラスに格納
dtrain = lgb.Dataset(X_train, label=y_train)  # 学習用
dvalid = lgb.Dataset(X_valid, label=y_valid)  # early_stopping用
# 使用するパラメータ
params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
         'metric': 'rmse',  # 学習時に使用する評価指標(2クラス分類の場合'binary_logloss'等、多クラス分類の場合'multi_logloss'等)
         'random_state': 42,  # 乱数シード
         'boosting_type': 'gbdt',  # boosting_type
         'verbose': -1,  # これを指定しないと`No further splits with positive gain, best gain: -inf`というWarningが表示される
         'early_stopping_round': 10  # ここでearly_stoppingを指定
         }
# early_stoppingを指定してLightGBM学習
gbm = lgb.train(params, dtrain,
                valid_sets=[dvalid],  # early_stoppingの評価用データ
                num_boost_round=10000  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
                )

# スコア(RMSE)算出
y_pred = gbm.predict(X_test)
score = mean_squared_error(y_true=y_test, y_pred=y_pred, squared=False)
print(f'RMSE={score}')

同様に、クロスバリデーションなしのScikit-Learn API(こちらのケースと同様の処理)では、以下のように指定します
LGBMRegressorクラスのコンストラクタにearly_stopping_roundが渡される)

Scikit-LearnAPI+第3の指定方法('params'引数の'early_stopping_round')での実装法
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
import lightgbm as lgb
# データセット読込(カリフォルニア住宅価格)
TARGET_VARIABLE = 'price'  # 目的変数名
USE_EXPLANATORY = ['MedInc', 'AveOccup', 'Latitude', 'HouseAge']  # 説明変数名
california_housing = pd.DataFrame(np.column_stack((fetch_california_housing().data, fetch_california_housing().target)),
    columns = np.append(fetch_california_housing().feature_names, TARGET_VARIABLE))
california_housing = california_housing.sample(n=1000, random_state=42)  # データ数多いので1000にサンプリング
y = california_housing[TARGET_VARIABLE].values  # 目的変数のnumpy配列
X = california_housing[USE_EXPLANATORY].values  # 説明変数のnumpy配列
# テストデータと学習データ分割
X_train_raw, X_test, y_train_raw, y_test = train_test_split(X, y, test_size=0.25, random_state=42)
# early_stopping用の評価データをさらに分割
X_train, X_valid, y_train, y_valid = train_test_split(X_train_raw, y_train_raw, test_size=0.25, random_state=42)

###### ここからがLightGBMの実装 ######
# 使用するパラメータ
params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
          'metric': 'rmse',  # 学習時に使用する評価指標(2クラス分類の場合'binary_logloss'等、多クラス分類の場合'multi_logloss'等)
          'random_state': 42,  # 乱数シード
          'boosting_type': 'gbdt',  # boosting_type
          'n_estimators': 10000,  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
          'verbose': -1,  # これを指定しないと`No further splits with positive gain, best gain: -inf`というWarningが表示される
          'early_stopping_round': 10  # ここでearly_stoppingを指定
          }
# early_stoppingを指定してLightGBM学習
lgbr = lgb.LGBMRegressor(**params)
lgbr.fit(X_train, y_train, 
         eval_set=[(X_valid, y_valid)]
         )

# スコア(RMSE)算出
y_pred = lgbr.predict(X_test)
score = mean_squared_error(y_true=y_test, y_pred=y_pred, squared=False)
print(f'RMSE={score}')

公式のAPIリファレンスでも補足扱いで紹介されている方法ですが、クロスバリデーション時のコールバック関数の初期化問題を回避できる方法の一つではあるので、紹介させて頂きました。

第3のearly_stopping指定方法 + クロスバリデーション

先ほどのcross_val_score改メソッドと第3のearly_stopping指定方法を組み合わせて、Scikit-Learn APIでearly_stoppingを有効化(early_stopping用データにはテストデータを使用)してクロスバリデーションを実施する方法も実装しました。seaborn_analyzer.cross_val_score_eval_set()メソッドとして実装しています。

validation_fraction引数に'cv'と渡すと、先ほどのcross_val_score改メソッドと同様にクロスバリデーションのテストデータをearly_stopping用データ(eval_set)に動的に渡せます。

lightgbm_sklearn_api_3rd_crossvalscore_cv.py
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import KFold
import lightgbm as lgb
from seaborn_analyzer import cross_val_score_eval_set

# データセット読込(カリフォルニア住宅価格)
TARGET_VARIABLE = 'price'  # 目的変数名
USE_EXPLANATORY = ['MedInc', 'AveOccup', 'Latitude', 'HouseAge']  # 説明変数名
california_housing = pd.DataFrame(np.column_stack((fetch_california_housing().data, fetch_california_housing().target)),
    columns = np.append(fetch_california_housing().feature_names, TARGET_VARIABLE))
california_housing = california_housing.sample(n=1000, random_state=42)  # データ数多いので1000にサンプリング
y = california_housing[TARGET_VARIABLE].values  # 目的変数のnumpy配列
X = california_housing[USE_EXPLANATORY].values  # 説明変数のnumpy配列

# クロスバリデーション用のScikit-Learnクラス(5分割KFold)
cv = KFold(n_splits=5, shuffle=True, random_state=42)

###### ここからがLightGBMの実装 ######
# 使用するパラメータ
params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
          'metric': 'rmse',  # 学習時に使用する評価指標(2クラス分類の場合'binary_logloss'等、多クラス分類の場合'multi_logloss'等)
          'random_state': 42,  # 乱数シード
          'boosting_type': 'gbdt',  # boosting_type
          'n_estimators': 10000,  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
          'verbose': -1,  # これを指定しないと`No further splits with positive gain, best gain: -inf`というWarningが表示される
          'early_stopping_round': 10  # ここでearly_stoppingを指定
          }
# early_stoppingを指定してLightGBM学習
lgbr = lgb.LGBMRegressor(**params)
# クロスバリデーション内部で`fit()`メソッドに渡すパラメータ
fit_params = {'eval_set':[(X, y)]
              }
# クロスバリデーション実行
scores = cross_val_score_eval_set(
        validation_fraction='cv',  # 'cv'と指定するとテストデータを'eval_set'に渡せる
        estimator=lgbr,  # 学習器
        X=X, y=y,  # クロスバリデーション分割前のデータを渡す
        scoring='neg_root_mean_squared_error',  # RMSE(の逆数)を指定
        cv=cv, fit_params=fit_params
        )
print(f'RMSE={scores} \nRMSE mean={np.mean(scores)}')
実行結果
RMSE=[-0.73898356 -0.61500248 -0.68039521 -0.64454389 -0.69132018] 
RMSE mean=-0.6740490637662134

第3のearly_stopping指定方法 + クロスバリデーション(リークを完全に防ぐ)

直前で紹介したseaborn_analyzer.cross_val_score_eval_set()メソッドのvalidation_fraction引数に'cv'と渡すと、前述のようにearly_stopping評価用データとスコア評価用のデータが同一となっており、early_stopping用のデータがリークした状態となっています(下図の上側の図が実際のデータ分割方法です)

image.png

これを防ぐためには、上図の下側の図のようにearly_stopping用のデータを学習データから分割することが有効です(Scikit-learnのGradientBoostingRegressorvalidation_fraction引数と同様の分割法)。seaborn_analyzer.cross_val_score_eval_set()メソッドでも、validation_fraction引数にfloat型の値を渡すことで、上図の下側の図のようにリークを完全に防いだ分割法を実現できるようにしました。学習データからvalidation_fraction引数で指定した割合だけearly_stopping用データ(eval_data)に分割されます(eval_dataに指定されたデータは学習データから除外される)

以下、具体的な実装法です(前の例のvalidation_fraction引数をfloat型に変えただけです)

lightgbm_sklearn_api_3rd_crossvalscore_cv.py
import pandas as pd
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import KFold
import lightgbm as lgb
from seaborn_analyzer import cross_val_score_eval_set

# データセット読込(カリフォルニア住宅価格)
TARGET_VARIABLE = 'price'  # 目的変数名
USE_EXPLANATORY = ['MedInc', 'AveOccup', 'Latitude', 'HouseAge']  # 説明変数名
california_housing = pd.DataFrame(np.column_stack((fetch_california_housing().data, fetch_california_housing().target)),
    columns = np.append(fetch_california_housing().feature_names, TARGET_VARIABLE))
california_housing = california_housing.sample(n=1000, random_state=42)  # データ数多いので1000にサンプリング
y = california_housing[TARGET_VARIABLE].values  # 目的変数のnumpy配列
X = california_housing[USE_EXPLANATORY].values  # 説明変数のnumpy配列

# クロスバリデーション用のScikit-Learnクラス(5分割KFold)
cv = KFold(n_splits=5, shuffle=True, random_state=42)

###### ここからがLightGBMの実装 ######
# 使用するパラメータ
params = {'objective': 'regression',  # 最小化させるべき損失関数(2クラス分類の場合'binary'、多クラス分類の場合'multiclass')
          'metric': 'rmse',  # 学習時に使用する評価指標(2クラス分類の場合'binary_logloss'等、多クラス分類の場合'multi_logloss'等)
          'random_state': 42,  # 乱数シード
          'boosting_type': 'gbdt',  # boosting_type
          'n_estimators': 10000,  # 最大学習サイクル数。early_stopping使用時は大きな値を入力
          'verbose': -1,  # これを指定しないと`No further splits with positive gain, best gain: -inf`というWarningが表示される
          'early_stopping_round': 10  # ここでearly_stoppingを指定
          }
# early_stoppingを指定してLightGBM学習
lgbr = lgb.LGBMRegressor(**params)
# クロスバリデーション内部で`fit()`メソッドに渡すパラメータ
fit_params = {'eval_set':[(X, y)]
              }
# クロスバリデーション実行
scores = cross_val_score_eval_set(
        validation_fraction=0.3,  # floatで指定した割合で学習データから'eval_set'を分割する(同時に学習データからeval_setの分が除外される)
        estimator=lgbr,  # 学習器
        X=X, y=y,  # クロスバリデーション分割前のデータを渡す
        scoring='neg_root_mean_squared_error',  # RMSE(の逆数)を指定
        cv=cv, fit_params=fit_params
        )
print(f'RMSE={scores} \nRMSE mean={np.mean(scores)}')

過学習絶対に許さないマンを目指すのであれば、現状この方法がベストに近いかと思います。

2023/6追記 XGBoostのScikit-learn APIとの互換性

XGBoost1.6より、XGBoostのScikit-learn APIでもでもこの第3の指定方法と同様に、XGBRegressorクラスのコンストラクタにearly_stopping_rounds引数を渡すことが、early stoppingの推奨指定方法となりました。

XGBoostでは本記事の第1および第2の方法であるfit()メソッドのearly_stopping_rounds引数やコールバック関数が非推奨となっている事から、XGBoostのScikit-learn APIとの互換性の観点では、この第3の指定方法がベストとなると考えられます。

サンプルコード

本記事で使用したサンプルコードは以下のGitHubリポジトリにまとめております

クロスバリデーション     API 旧指定方法
('early_stopping_rounds'
引数)
現在の推奨方法
(コールバック関数
'early_stopping()')
第3の方法
(param['early_stopping_round']引数)
なし Training API lightgbm_training_api_old.py lightgbm_training_api_new.py lightgbm_training_api_3rd.py
なし Scikit-Learn API lightgbm_sklearn_api_old.py lightgbm_sklearn_api_new.py lightgbm_sklearn_api_3rd.py
あり Training API
(スクラッチ実装)
lightgbm_training_api_cv_scratch_old.py lightgbm_training_api_cv_scratch_new.py
あり Training API
(lightgbm.cvメソッド)
lightgbm_training_api_cv_lgbcv_old.py lightgbm_training_api_cv_lgbcv_new.py
あり Scikit-Learn API
(スクラッチ実装)
lightgbm_sklearn_api_cv_old.py lightgbm_sklearn_api_cv_new.py
あり Scikit-Learn API
(cross_val_scoreメソッド)
- lightgbm_sklearn_api_cv_crossvalscore.py lightgbm_sklearn_api_3rd_crossvalscore.py
144
127
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
144
127