小ネタ:LightGBM の callback が何を受け取るのか確認する
懲りずにまた LightGBM ネタです。前回*1が重すぎてしばらくブログ書くことは無いだろうと思っていたのですがふと思い付きました。
諸事情により LightGBM の callback を実装したくなったのですが、公式 docs の説明*2を読んだだけだと、
関数っぽいものを渡すんだろうけど何を受け取るのかよくわからんなということで、調べると共に備忘録を残すことにしました。
目次
callback 関数が受け取るもの
「lightgbm callback」で検索すると以下のような callback を実装した記事がいくつか出てきます(参考になりました、ありがとうございます!)。
yutori-datascience.hatenablog.com
これらの記事で書かれているように、受け取るのは lightgbm.callback.CallbackEnv
という名前の collections.namedtuple
です。
公式の GitHub の callback の実装*3 にも記載があります。
# Callback environment used by callbacks CallbackEnv = collections.namedtuple( "LightGBMCallbackEnv", ["model", "params", "iteration", "begin_iteration", "end_iteration", "evaluation_result_list"])
で、名前から何が入ってるかはおおよそ想像がつくのですが、微妙に形式が気になる奴があります。evaluation_result_list
って list
の list
なんだろうか、それとも dict
の list
なんだろうか?
公式の他の callback 関数の実装から何となく形状はわかるのですが、ちゃんと把握した方が自分で実装するときに助かる気がします。
百聞は一見に如かず
まあこれは色々調べるよりも取り出した方が早いと思ったので、受け取った CallbackEnv
をそのまま格納する callback 関数を実装することにしました。
def store_env(storing_list: List[None]) -> Callable[[lgb.callback.CallbackEnv], None]: """Create a callback for simply storing lightgbm.callback.CallbackEnv""" def _callback(env: lgb.callback.CallbackEnv) -> None: """Define callback function.""" storing_list.append([ env, env.model.__copy__() # <= その時点での model を copy(内部実装により deepcopy される). ]) _callback.order = 20 return _callback
この関数は callback 関数を返す関数です*4。callback 関数そのものは、storing_list
の中に学習中に受け取る Callbackenv
と、あと諸事情によりその時点の model の copy を格納する関数となっています。
試すデータは何でも良かったのですが、scikit-learn でデータが読み込めて楽なので前回使った Iris Dataset を学習させてみます。
MODEL_PARAMS_LGB = { "objective": "multiclass", 'num_class': 3, "first_metric_only": True, "eta": 0.01, "max_depth": -1, "seed": 1086, "num_threads": 4, "verbose": -1 } FIT_PARAMS_LGB = {"num_boost_round": 5, "verbose_eval":1} env_list = [] # <= Callbackenv 格納するリストを準備 my_callback = store_env(env_list) # <= callback 関数を生成 model = lgb.train( MODEL_PARAMS_LGB, lgb_tr, **FIT_PARAMS_LGB, valid_sets=[lgb_tr, lgb_val], valid_names=['train', 'valid'], feval=multi_class_accuracy_for_lgbm, callbacks=[ my_callback # <= 自作の callback を渡す. ] )
個々の変数については触れませんが、とりあえず callbacks
に自作した callback 関数を渡していることだけ理解していただければ大丈夫です。今回は num_boost_round
で指定しているように 5回だけ iteration を回しました。
学習の経過の出力は以下。
[1] train's multi_logloss: 1.08513 train's my_macc: 0.348214 valid's multi_logloss: 1.09218 valid's my_macc: 0.289474 Finished loading model, total used 1 iterations [2] train's multi_logloss: 1.07291 train's my_macc: 0.625 valid's multi_logloss: 1.07875 valid's my_macc: 0.605263 Finished loading model, total used 2 iterations [3] train's multi_logloss: 1.0609 train's my_macc: 0.625 valid's multi_logloss: 1.06554 valid's my_macc: 0.605263 Finished loading model, total used 3 iterations [4] train's multi_logloss: 1.04909 train's my_macc: 0.625 valid's multi_logloss: 1.05256 valid's my_macc: 0.605263 Finished loading model, total used 4 iterations [5] train's multi_logloss: 1.03748 train's my_macc: 0.901786 valid's multi_logloss: 1.0398 valid's my_macc: 1 Finished loading model, total used 5 iterations
見慣れない Finished loading model, total used X iterations
というのがありますが、これは model を copy してるせいです*5*6。
準備した env_list
には [ CallbackEnv
, callback 関数が呼ばれた時点の model の copy ] の形の list
が合計5個入っています。順番に中身を確認していきましょう。
model
まず型を確認します。
check_type = pd.DataFrame([ [type(tmp_env.model), type(tmp_model)] for tmp_env, tmp_model in env_list ], columns=["CallbackEnv.model", "copy by iteration"]) check_type
CallbackEnv.model | copy by iteration | |
---|---|---|
0 | <class 'lightgbm.basic.Booster'> |
<class 'lightgbm.basic.Booster'> |
1 | <class 'lightgbm.basic.Booster'> |
<class 'lightgbm.basic.Booster'> |
2 | <class 'lightgbm.basic.Booster'> |
<class 'lightgbm.basic.Booster'> |
3 | <class 'lightgbm.basic.Booster'> |
<class 'lightgbm.basic.Booster'> |
4 | <class 'lightgbm.basic.Booster'> |
<class 'lightgbm.basic.Booster'> |
CallbackEnv
に .model
でアクセスして確かに model (lightgbm.basic.Booster
) が得られています。tmp_model
の方は iteration ごとに同じく .model
でアクセスして copy したものなので当然型は一緒です。
次に、 iteration ごとの model で予測した際の validation loss を確認してみましょう。
val_loss = pd.DataFrame([ [ log_loss(y_val, tmp_env.model.predict(X_val)), # env から取得した model での予測 log_loss(y_val, tmp_model.predict(X_val)), # その時点で copy した model で予測 ] for tmp_env, tmp_model in env_list ], columns=["CallbackEnv.model", "copy by iteration"]) val_loss
CallbackEnv.model | copy by iteration | |
---|---|---|
0 | 1.0398 | 1.09218 |
1 | 1.0398 | 1.07875 |
2 | 1.0398 | 1.06554 |
3 | 1.0398 | 1.05256 |
4 | 1.0398 | 1.0398 |
あれ、CallbackEnv
の方は全部一緒ですね?
CallbackEnv
に入っている model
はあくまで学習中の model への参照です。なので一番最後の iteration のものと全部一致しています。一方 copy の方は iteration ごとに copy を取っているので、当然ながら iteration ごとの loss になっていることが学習ログと比較しても分かると思います。 以下のように オブジェクト id を確認してみてもわかりますね。
obj_id = pd.DataFrame([ [id(tmp_env.model), id(tmp_model)] for tmp_env, tmp_model in env_list ], columns=["CallbackEnv.model", "copy by iteration"]) obj_id
CallbackEnv.model | copy by iteration | |
---|---|---|
0 | 139880858128056 | 139880850531440 |
1 | 139880858128056 | 139880850531496 |
2 | 139880858128056 | 139880850530432 |
3 | 139880858128056 | 139880850530768 |
4 | 139880858128056 | 139880850530824 |
LightGBM で early_stopping
を使う場合は、おそらくですが、時間を巻き戻して(そこまで増やした木を削って) model を返してくれるんだと思います。一々 model を保存しないで良いのは楽ですね!
ただ early_stopping
が使えない boosting_type
も存在していて*7、これが僕が calllback 関数を使いたくなった理由だったりします。
params
単純に lightgbm.train
に渡した params
が入っているだけです。
for tmp_env, tmp_model in env_list: print(tmp_env.params)
{'objective': 'multiclass', 'num_class': 3, 'eta': 0.01, 'max_depth': -1, 'seed': 1086, 'num_threads': 4, 'verbose': -1} {'objective': 'multiclass', 'num_class': 3, 'eta': 0.01, 'max_depth': -1, 'seed': 1086, 'num_threads': 4, 'verbose': -1} {'objective': 'multiclass', 'num_class': 3, 'eta': 0.01, 'max_depth': -1, 'seed': 1086, 'num_threads': 4, 'verbose': -1} {'objective': 'multiclass', 'num_class': 3, 'eta': 0.01, 'max_depth': -1, 'seed': 1086, 'num_threads': 4, 'verbose': -1} {'objective': 'multiclass', 'num_class': 3, 'eta': 0.01, 'max_depth': -1, 'seed': 1086, 'num_threads': 4, 'verbose': -1}
object id からも全部同一のものだとわかります*8。
for tmp_env, tmp_model in env_list: print(id(tmp_env.params))
139880850451768 139880850451768 139880850451768 139880850451768 139880850451768
iteration
, begin_iteration
, end_iteration
これは単純に数字(float)なので出力してみます。
iterations = pd.DataFrame([ [tmp_env.iteration, tmp_env.begin_iteration, tmp_env.end_iteration] for tmp_env, tmp_model in env_list ], columns=["iteration", "begin_iteration", "end_iteration"]) iterations
iteration | begin_iteration | end_iteration | |
---|---|---|---|
0 | 0 | 0 | 5 |
1 | 1 | 0 | 5 |
2 | 2 | 0 | 5 |
3 | 3 | 0 | 5 |
4 | 4 | 0 | 5 |
順番に その時の iteration (0-index)、初めの iteration、学習が終わる iteration ( num_boost_round
で指定したもの ) ですね。ちょっとわからないんですが、学習を途中から再開したりすると変わったりするのかもしれません。
evaluation_result_list
はい、今回のメインです。0 番目の中身を見てみます。
env_list[0][0].evaluation_result_list
[('train', 'multi_logloss', 1.0851334684728389, False), ('train', 'my_macc', 0.3482142857142857, True), ('valid', 'multi_logloss', 1.0921836073274485, False), ('valid', 'my_macc', 0.2894736842105263, True)]
完全に理解した。evaluation_result_list
の中身は tuple
の list
ですね。そしてそれぞれの tuple
の中身は
(valid_set
の 名前, 指標の名前, 指標の値, is_higher_better
)
となっています。"valid_set
の 名前" と言っているのは、lightgbm.train
に渡している名前です。上の方に書いてたやつを再掲すると
model = lgb.train( MODEL_PARAMS_LGB, lgb_tr, **FIT_PARAMS_LGB, valid_sets=[lgb_tr, lgb_val], # <= valid_set を list で渡す valid_names=['train', 'valid'], # <= それぞれの valid_set の名前を与える feval=multi_class_accuracy_for_lgbm, callbacks=[ my_callback # <= 自作の callback を渡す. ] )
のように引数 valid_names
で渡しています。残りの "指標の名前", "指標の値", is_higher_better
はそのままですね。is_higher_better
は early_stopping
のために高い方がいいのか低い方がいいのか示すやつです。
Custom Metric を実装している方はこの形式に馴染みがあると思います*9。
というわけで、lightgbm.callback.CallbackEnv
の正体が明らかになりました🙌
おわりに
callback を実装している方はみんな形式をご存知なんだろうなと思ったのですが、callback をどう実装するかがメインな記事(まあ実際そっちが重要なので...)しか見つけられなかったので書きました。どこかに普通に書いてある情報である可能性は否めないですが、自分用の備忘録的な意味が強いから良いかなって。
因みに暫定策で model.__copy__()
してるところがあったと思いますが、一々 load するのはなんか嫌なので str の状態で保持する*10のが良い気がします。
「この lightgbm.callback.CallbackEnv
の中身結局何なんや?」ってなった人の目に留まれば幸いです。ではでは。
余談
ところで、公式実装の callback 関数はクロージャ機能*11を用いて実装されています。上の僕のお試し callback 関数もそれに倣いました。
もちろん callback 関数は Callable かつ lightgbm.callback.CallbackEnv
を受け取れれば何でも良いようなので、class で実装してメンバ変数に情報を格納しても良いんですよね。どっちがいいんでしょう?
こういうのうまく使い分けれていないのですが、流儀の問題 (関数型 or オブジェクト指向型 のどっちが好みか) なのか、それともいい感じの設計指針があるのか、気になります。
*1:https://tawara.hatenablog.com/entry/2020/05/14/120016
*2:https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.train.html#lightgbm.train
*3:https://github.com/microsoft/LightGBM/blob/master/python-package/lightgbm/callback.py#L32
*4:理由は詳しく知らないのですが、ハイパラ等を引数として渡すのに便利だからなのか callback 関数はこの形式で実装されることが多い。
*5:内部的には model を新しく初期化してその時点の状態をロードしているようです
*6:https://github.com/microsoft/LightGBM/blob/master/python-package/lightgbm/basic.py#L1850
*7:https://github.com/Microsoft/LightGBM/issues/1893
*8:途中で書き換えると変わったりするんですかね?
*9:というかこの形式に合わせるように実装してねってことなんでしょうけども
*10:https://github.com/microsoft/LightGBM/blob/master/python-package/lightgbm/basic.py#L1849
*11:https://www.lifewithpython.com/2014/09/python-use-closures.html