小ネタ:LightGBM の objective を metric から消し去る
今回は知ってる人が沢山いそうという意味で誰得な小ネタです。でも個人的には気付きにくかったので備忘録がてら書きます。
kaggle 等の分析コンペにおいてとりあえず使っとけとなる GBDT ですが、ときに自作の目的(損失)関数・評価関数を用いて学習させたくなるときがあります。
例えば LightGBM だと lightgbm.train
にfobj
、feval
という引数が存在し、これらに自作関数を渡すことが可能です。
今回のネタはタスクの評価指標が特殊なときに、それを自作して学習中の early stopping に使いたいようなときに必要になる話だと思っています。
目次
feval だけ設定すると"事故"る
前回同様 Kaggle Notebooks 上で Titanic コンペ*1のデータを用いて試してみます。 この記事の説明のために最低限必要なライブラリのインポートは以下。
import numpy as np import lightgbm as lgb
ここでは Titanic コンペの評価仕様である二値分類での accuracy (正解率) を定義します*2。
def binary_accuracy_for_lgbm( preds: np.ndarray, data: lgb.Dataset, threshold: float=0.5, ) -> Tuple[str, float, bool]: """Calculate Binary Accuracy""" label = data.get_label() weight = data.get_weight() pred_label = (preds > threshold).astype(int) acc = np.average(label == pred_label, weights=weight) # # eval_name, eval_result, is_higher_better return 'my_bin_acc', acc, True
それでは早速学習で使ってみましょう。
MODEL_PARAMS_LGB = { "objective": "binary", # <= set objective "eta": 0.01, "max_depth": -1, "seed": RANDOM_SEED, "num_threads": NUM_THREADS, "verbose": -1 } FIT_PARAMS_LGB = {"num_boost_round": 10000, "early_stopping_rounds": 100, "verbose_eval":100} lgb_tr = lgb.Dataset(X_tr, y_tr) lgb_val = lgb.Dataset(X_val, y_val) model = lgb.train( MODEL_PARAMS_LGB, lgb_tr, **FIT_PARAMS_LGB, valid_names=['train', 'valid'], valid_sets=[lgb_tr, lgb_val], fobj=None, feval=binary_accuracy_for_lgbm # <= set custom metric function )
めんどくさいので説明していない変数が沢山ありますが、ここでは objecvtive
(学習に使う損失関数)に binary
(binary_logloss
) を、 feval
に自前で定義した binary_accuracy_for_lgbm
を設定したことだけわかってくださればOKです。
期待する動作としては評価を binary_accuracy_for_lgbm
で行うことですが、出力を確認してみると...
Training until validation scores don't improve for 100 rounds [100] train's binary_logloss: 0.419515 train's my_bin_acc: 0.848315 valid's binary_logloss: 0.477141 valid's my_bin_acc: 0.837989 Early stopping, best iteration is: [91] train's binary_logloss: 0.431586 train's my_bin_acc: 0.84691 valid's binary_logloss: 0.486198 valid's my_bin_acc: 0.837989
my_bin_acc
が渡した自作関数の値なのですが、なんか binary_logloss
も出ちゃってますね🤔
「これの何が問題なのか?」と思われるのかもしれないのですが、lightgbm の仕様では複数の metric があった場合、いずれかの metric が改善しなくなったら early stopping がかかります *3 。上の例では自作した metric である my_bin_acc
が先に改善しなくなったからいいですが、逆パターンもありえます*4。
自作関数で early stopping かけるためにわざわざ実装して渡したのに、別の metric でやられてしまっては意味がありません。
解決策
実は LightGBM の公式 docs の Metric Parameters の項*5にちゃんと記載されています。
- metric(s) to be evaluated on the evaluation set(s)
- "" (empty string or not specified) means that metric corresponding to specified objective will be used (this is possible only for pre-defined objective functions, otherwise no evaluation metric will be added)
- "None" (string, not a None value) means that no metric will be registered, aliases: na, null, custom
- ...
上の失敗例で何が起こっていたかというと、一個目の箇条書きにあるように metric
になにも渡さない場合は objective
(今回は binary logloss
)に対応したものが metric
に使用されていたということでした。なので、二個目の箇条書きで書かれているように使わないことを明示するのが必要だったという話。
training API のページ*6の feval
のところにもよくよく見ると記載があります。(これはこの記事を書いているときに発見しました。知らなかった...)
For binary task, the preds is probability of positive class (or margin in case of specified
fobj
). For multi-class task, the preds is group by class_id first, then group by row_id. If you want to get i-th row preds in j-th class, the access way is preds[j * num_data + i]. To ignore the default metric corresponding to the used objective, set themetric
parameter to the string"None"
inparams
.
というわけで、params
の metric
に string で "None" を渡せばOKです。やってみましょう!
MODEL_PARAMS_LGB = { "objective": "binary", # <= set objective "metric" : "None", # <= set `None` by `string` "eta": 0.01, "max_depth": -1, "seed": RANDOM_SEED, "num_threads": NUM_THREADS, "verbose": -1 } FIT_PARAMS_LGB = {"num_boost_round": 10000, "early_stopping_rounds": 100, "verbose_eval":100} lgb_tr = lgb.Dataset(X_tr, y_tr) lgb_val = lgb.Dataset(X_val, y_val) model = lgb.train( MODEL_PARAMS_LGB, lgb_tr, **FIT_PARAMS_LGB, valid_names=['train', 'valid'], valid_sets=[lgb_tr, lgb_val], fobj=None, feval=binary_accuracy_for_lgbm # <= set custom metric function )
以下のようになります。
Training until validation scores don't improve for 100 rounds [100] train's my_bin_acc: 0.848315 valid's my_bin_acc: 0.837989 Early stopping, best iteration is: [91] train's my_bin_acc: 0.84691 valid's my_bin_acc: 0.837989
自作の関数のみが metric として使用されました🙌
おわりに
コンペで特殊な評価指標が選ばれているときに使いどころは割とあるのかなと。
ただ、今回みたいに閾値で score が変わる metric だと使う際には少し注意が必要で*7、そのような場合は「連続的な metric で early stopping => 後処理で閾値調整」のほうが良いかもしれません。とは言っても結局ケースバイケースになっちゃいますが。
今回も notebook は公開しておいたのでご参考まで。
同じ悩みを持った人の目に入ることを祈って。
おまけ:でも loss も確認したくない?
「とは言え学習に使ってる objective (に対応する metric )の値も確認したいよね」となるのは自然な発想です。
複数の metric があるときに early stopping に使う metric を指定する方法があります。それは、 first_metric_only
を指定すること。名前の通り先頭の metric を使って early stopping を行ってくれます。
早速やってみましょう。
MODEL_PARAMS_LGB = { "objective": "binary", # <= set objective "first_metric_only": True, # <= set first_metric_only "eta": 0.01, "max_depth": -1, "seed": RANDOM_SEED, "num_threads": NUM_THREADS, "verbose": -1 } FIT_PARAMS_LGB = {"num_boost_round": 10000, "early_stopping_rounds": 100, "verbose_eval":100} lgb_tr = lgb.Dataset(X_tr, y_tr) lgb_val = lgb.Dataset(X_val, y_val) model = lgb.train( MODEL_PARAMS_LGB, lgb_tr, **FIT_PARAMS_LGB, valid_names=['train', 'valid'], valid_sets=[lgb_tr, lgb_val], fobj=None, feval=binary_accuracy_for_lgbm # <= set custom metric function )
出力は以下。
Training until validation scores don't improve for 100 rounds [100] train's binary_logloss: 0.419515 train's my_bin_acc: 0.848315 valid's binary_logloss: 0.477141 valid's my_bin_acc: 0.837989 [200] train's binary_logloss: 0.342075 train's my_bin_acc: 0.867978 valid's binary_logloss: 0.437745 valid's my_bin_acc: 0.832402 [300] train's binary_logloss: 0.296524 train's my_bin_acc: 0.886236 valid's binary_logloss: 0.43306 valid's my_bin_acc: 0.832402 Early stopping, best iteration is: [285] train's binary_logloss: 0.302844 train's my_bin_acc: 0.884831 valid's binary_logloss: 0.432815 valid's my_bin_acc: 0.826816 Evaluated only: binary_logloss
確かに 先頭の binary_logloss
を使ったようです。あれ、でもやりたかったのは my_bin_acc
を使って early stopping しつつ binary_logloss
を確認することですよね...?
これに関して色々やってみたのですが、とりあえず僕が試した限りだと組み込みの metric の方が順番が先になってしまうようです。
なので、とりあえず思いついた方法としては自分で(組み込みにあるものを)わざわざ実装して、
def binary_logloss_for_lgbm( preds: np.ndarray, data: lgb.Dataset ) -> Tuple[str, float, bool]: """Calculate Binary Logloss""" label = data.get_label() weight = data.get_weight() p_dash = (1 - label) + (2 * label - 1) * preds loss_by_example = - np.log(p_dash) loss = np.average(loss_by_example, weights=weight) # # eval_name, eval_result, is_higher_better return 'my_lnloss', loss, False
metric の指定は "None"
にした上で、目的としている自作関数(binary_accuracy_for_lgbm
)の後ろに上の binary_logloss_for_lgbm
を追加します。
MODEL_PARAMS_LGB = { "objective": "binary", # <= set objective "first_metric_only": True, # <= set first_metric_only "metric" : "None", # <= set `None` by `string` "eta": 0.01, "max_depth": -1, "seed": RANDOM_SEED, "num_threads": NUM_THREADS, "verbose": -1 } FIT_PARAMS_LGB = {"num_boost_round": 10000, "early_stopping_rounds": 100, "verbose_eval":100} lgb_tr = lgb.Dataset(X_tr, y_tr) lgb_val = lgb.Dataset(X_val, y_val) model = lgb.train( MODEL_PARAMS_LGB, lgb_tr, **FIT_PARAMS_LGB, valid_names=['train', 'valid'], valid_sets=[lgb_tr, lgb_val], fobj=None, feval=lambda preds, data : [ # <= set custom metric functions binary_accuracy_for_lgbm(preds, data), binary_logloss_for_lgbm(preds, data) # <= adding my logloss ]
すると以下のように目的を達成できるのですが、何か無駄なことをしている感がありますね🤔
Training until validation scores don't improve for 100 rounds [100] train's my_bin_acc: 0.848315 train's my_lnloss: 0.419515 valid's my_bin_acc: 0.837989 valid's my_lnloss: 0.477141 Early stopping, best iteration is: [91] train's my_bin_acc: 0.84691 train's my_lnloss: 0.431586 valid's my_bin_acc: 0.837989 valid's my_lnloss: 0.486198 Evaluated only: my_bin_acc
因みに feval
に複数の自作関数を渡せるのは以下の もみじあめ さんの記事を読んで知りました。ありがとうございます!
もっといい方法があるんじゃないかと疑っているので、知っている方は是非お教えください🙇
*1:https://www.kaggle.com/c/titanic
*2:自作関数の実装方法の詳細については割愛。
*3:おまけに書いておきますが回避方法はあります
*4:そもそも loss と metric が綺麗に相関するのが理想ですがそれはそれ
*5:https://lightgbm.readthedocs.io/en/latest/Parameters.html#metric-parameters
*6:https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.train.html#lightgbm.train
*7:上の例だと実は threshold を 0.6 にして渡した方が良い score にたどり着く