LightGBMのdartモードでEarlyStoppingを使う

LightGBMで`boosting="dart"`を指定すると、`early_stopping_rounds`を指定してもdartモードでは使えないという趣旨のwarningが表示され、使うことはできない。
dartでは、学習中に過去に作った木も変更されるため、スコアが悪化したときに以前のベストイテレーションで止めても、そのときのスコアを再現できないためである。
今回は、callback機能を利用して無理やりEarlyStoppingを実現させる。

# callbackクラス
[lightgbmのcallbackのソースコード](https://lightgbm.readthedocs.io/en/latest/_modules/lightgbm/callback.html)を参考に、次のようなクラスを作成した。

```python
import lightgbm as lgb
import pickle

class DartEarlyStopping(object):
    """DartEarlyStopping"""

    def __init__(self, data_name, monitor_metric, stopping_round):
        self.data_name = data_name
        self.monitor_metric = monitor_metric
        self.stopping_round = stopping_round
        self.best_score = None
        self.best_model = None
        self.best_score_list = []
        self.best_iter = 0

    def _is_higher_score(self, metric_score, is_higher_better):
        if self.best_score is None:
            return True
        return (self.best_score < metric_score) if is_higher_better else (self.best_score > metric_score)

    def _deepcopy(self, x):
        # copy.deepcopyではlightgbmのモデルは完全にコピーされないためpickleを使用
        return pickle.loads(pickle.dumps(x))

    def __call__(self, env):
        evals = env.evaluation_result_list
        for data, metric, score, is_higher_better in evals:
            if data != self.data_name or metric != self.monitor_metric:
                continue
            if not self._is_higher_score(score, is_higher_better):
                if env.iteration - self.best_iter > self.stopping_round:
                    # 終了させる
                    eval_result_str = '\t'.join([lgb.callback._format_eval_result(x) for x in self.best_score_list])
                    lgb.basic._log_info(f"Early stopping, best iteration is:\n[{self.best_iter+1}]\t{eval_result_str}") 
                    lgb.basic._log_info(f"You can get best model by \"DartEarlyStopping.best_model\"")
                    raise lgb.callback.EarlyStopException(self.best_iter, self.best_score_list)
                return
            # dartでは過去の木も更新されてしまうため、deepcopyしておく
            self.best_model = self._deepcopy(env.model)
            self.best_iter = env.iteration
            self.best_score_list = evals
            self.best_score = score
            return
        raise ValueError("monitoring metric not found")
```


各イテレーションで、スコア改善があればモデルをdeepcopyして保存しておくようにした。
ただし、[copy.deepcopyではlightgbmのモデルが完全にコピーされない問題](https://github.com/microsoft/LightGBM/issues/4085)があるため、pickleでエンコード/デコードさせた。
学習後に、`DartEarlyStopping.best_model`からベストモデルを取得して使うようにする。

# 利用例

```python
import pandas as pd
import numpy as np
from sklearn.metrics import mean_squared_error
# データ
np.random.seed(17)
X = np.random.rand(100,2)
Y = np.ravel(np.random.rand(100,1))
eval_X = np.random.rand(100,2)
eval_Y = np.ravel(np.random.rand(100,1))
data = lgb.Dataset(X, label=Y)
eval_data=lgb.Dataset(eval_X, label=eval_Y, reference=data)
# 学習
params = {
    "boosting": "dart",
    'objective': 'rmse',
    'metric': 'rmse',
    "seed":1,
}
des = DartEarlyStopping("valid_1", "rmse", 5)
model = lgb.train(
    params, data,
    valid_sets=[data, eval_data], 
    num_boost_round=100,
    callbacks=[des],
    verbose_eval=1,
)
model = des.best_model
print(f"{np.sqrt(mean_squared_error(eval_Y, model.predict(eval_X))):.6f}")
```

>[1]	training's rmse: 0.290185	valid_1's rmse: 0.273062
[2]	training's rmse: 0.288899	valid_1's rmse: 0.273025
[3]	training's rmse: 0.287789	valid_1's rmse: 0.272813
[4]	training's rmse: 0.286382	valid_1's rmse: 0.272106
[5]	training's rmse: 0.285237	valid_1's rmse: 0.271591
[6]	training's rmse: 0.284481	valid_1's rmse: 0.27179
[7]	training's rmse: 0.283597	valid_1's rmse: 0.271512
[8]	training's rmse: 0.282869	valid_1's rmse: 0.271293
[9]	training's rmse: 0.282278	valid_1's rmse: 0.271212
[10]	training's rmse: 0.282335	valid_1's rmse: 0.271227
[11]	training's rmse: 0.282359	valid_1's rmse: 0.271237
[12]	training's rmse: 0.281777	valid_1's rmse: 0.271276
[13]	training's rmse: 0.28188	valid_1's rmse: 0.271512
[14]	training's rmse: 0.281742	valid_1's rmse: 0.271591
Early stopping, best iteration is:
[9]	training's rmse: 0.282278	valid_1's rmse: 0.271212
You can get best model by "DartEarlyStopping.best_model"
0.271212

当然ですが`model = des.best_model`とすることで、ベストスコアと一致しました。
ちなみに、`model = des.best_model`を書かない場合のスコアは、一般に、学習中に出力されたスコアのどれとも一致しません。
なぜなら、14 stepまで学習する過程で過去の木が変更されており、9 stepまでしか使わない予測をするためです。
このスコアがベストスコアを上回る場合も下回る場合もあります。