俵言

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

小ネタ:LightGBM の objective を metric から消し去る

今回は知ってる人が沢山いそうという意味で誰得な小ネタです。でも個人的には気付きにくかったので備忘録がてら書きます。

kaggle 等の分析コンペにおいてとりあえず使っとけとなる GBDT ですが、ときに自作の目的(損失)関数・評価関数を用いて学習させたくなるときがあります。 例えば LightGBM だと lightgbm.trainfobjfeval という引数が存在し、これらに自作関数を渡すことが可能です。

今回のネタはタスクの評価指標が特殊なときに、それを自作して学習中の 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 のページ*6feval のところにもよくよく見ると記載があります。(これはこの記事を書いているときに発見しました。知らなかった...)

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 the metric parameter to the string "None" in params.


というわけで、paramsmetricstring で "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 は公開しておいたのでご参考まで。

www.kaggle.com

同じ悩みを持った人の目に入ることを祈って。

おまけ:でも 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 に複数の自作関数を渡せるのは以下の もみじあめ さんの記事を読んで知りました。ありがとうございます!

blog.amedama.jp

もっといい方法があるんじゃないかと疑っているので、知っている方は是非お教えください🙇

*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 にたどり着く