俵言

しがない社会人が書く、勉強とかのこと。最近は機械学習や kaggle 関連がメイン。

小ネタ:LightGBM の callback が何を受け取るのか確認する

懲りずにまた LightGBM ネタです。前回*1が重すぎてしばらくブログ書くことは無いだろうと思っていたのですがふと思い付きました。

諸事情により LightGBM の callback を実装したくなったのですが、公式 docs の説明*2を読んだだけだと、

  • callbacks (list of callables or None, optional (default=None)) – List of callback functions that are applied at each iteration. See Callbacks in Python API for more information.


関数っぽいものを渡すんだろうけど何を受け取るのかよくわからんなということで、調べると共に備忘録を残すことにしました。

目次

callback 関数が受け取るもの

「lightgbm callback」で検索すると以下のような callback を実装した記事がいくつか出てきます(参考になりました、ありがとうございます!)。

amalog.hateblo.jp

yutori-datascience.hatenablog.com

qiita.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 って listlist なんだろうか、それとも dictlist なんだろうか?
公式の他の 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 の中身は tuplelist ですね。そしてそれぞれの 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_betterearly_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