【LightGBM】init_modelで学習を継続する(逐次学習)

はじめに

LightGBMはlgb.traininit_model引数に既存モデルを渡すと、そのモデルを起点に学習を続けられます。すでにある木はそのまま残し、後ろに新しい木を追加します。

データが少しずつ届く逐次学習(オンライン学習)で、毎回すべてのデータを学習し直す代わりに、新しく届いた分だけ木を足して更新する、といった使い方ができます。この記事で挙動を実測します。

学習を継続する

まず、100回学習したモデルにinit_modelで100回追加してみます。init_modelを使うときは、Datasetfree_raw_data=Falseで作る必要があります(継続学習のため元データを保持する)。

import numpy as np
import lightgbm as lgb

def make(n, seed=0):
    rng = np.random.RandomState(seed)
    X = rng.uniform(-2, 2, (n, 8))
    y = (np.sin(X[:,0]*2) + X[:,1]**2 - X[:,2] + 0.3*X[:,3]*X[:,4]
         + 0.5*X[:,5] - X[:,6]**2*0.2 + rng.randn(n)*0.3)
    return X, y

X, y = make(30000)
te = np.arange(len(X)) < 6000
Xte, yte, Xtr, ytr = X[te], y[te], X[~te], y[~te]

params = dict(objective="regression", num_leaves=31, learning_rate=0.05,
              min_child_samples=20, verbose=-1, seed=0)
dtr = lgb.Dataset(Xtr, ytr, free_raw_data=False)   # init_model には free_raw_data=False

m100 = lgb.train(params, dtr, num_boost_round=100)
m_cont = lgb.train(params, dtr, num_boost_round=100, init_model=m100)  # 100本追加
print("m100:", m100.num_trees(), " 継続後:", m_cont.num_trees())
m100: 100  継続後: 200

num_boost_round追加する木の本数です。100本のモデルに100本足して、合計200本になりました。

100+100 は 200 と一致するか

同じデータ・同じパラメータで「100回 → 100回追加」した結果は、最初から200回学習したモデルと一致するはずです。確認します。

m200 = lgb.train(params, dtr, num_boost_round=200)
rmse = lambda p, t: float(np.sqrt(np.mean((p - t) ** 2)))

p_cont, p200 = m_cont.predict(Xte), m200.predict(Xte)
print(f"継続(100+100) RMSE={rmse(p_cont, yte):.5f}")
print(f"一括(200)     RMSE={rmse(p200, yte):.5f}")
print(f"予測の最大差   = {np.max(np.abs(p_cont - p200)):.2e}")
継続(100+100) RMSE=0.41386
一括(200)     RMSE=0.41386
予測の最大差   = 3.81e-05

RMSEは一致し、予測値の差も1e-5のオーダー(浮動小数点の丸め程度)です。init_modelが学習状態を正しく引き継いで木を足していることがわかります。追加する木は、現在のモデルの予測に対する残差(勾配)を使って学習されます。

この一致は、裏を返せば重要なことを意味します。同じデータをそのまま継続するだけなら、最初から200回学習するのと何も変わりません。手元に全データが揃っているなら、init_modelを使う理由はなく、num_boost_round=200で一度に学習すればよいだけです。init_modelが効くのは、追加する回で最初の学習時にはなかった新しいデータを使うときです。次にその状況を見ます。

逐次学習:新しいデータで木を足す

本来の使いどころは、データがバッチで届く状況です。届くたびにinit_modelでそのバッチ分だけ木を足す方法(逐次)と、毎回それまでの全データで一から学習し直す方法(フル再学習)を比べます。学習データを6バッチに分け、各バッチで80本ずつ扱います。

Xtr8, ytr8 = Xtr, ytr
idx = np.array_split(np.arange(len(Xtr8)), 6)   # 6バッチ
K = 80

# A) 逐次: init_model で新バッチのみ学習して木を追加
model = None
for i in range(6):
    Xb, yb = Xtr8[idx[i]], ytr8[idx[i]]
    db = lgb.Dataset(Xb, yb, free_raw_data=False)
    model = lgb.train(params, db, num_boost_round=K, init_model=model)
    print(f"batch {i+1}: trees={model.num_trees()}  RMSE={rmse(model.predict(Xte), yte):.4f}")

# B) フル再学習: 毎回それまでの全データで学習(木の総数は揃える)
for i in range(6):
    seen = np.concatenate(idx[:i+1])
    d = lgb.Dataset(Xtr8[seen], ytr8[seen], free_raw_data=False)
    m = lgb.train(params, d, num_boost_round=K*(i+1))

5シード平均のテストRMSEと、累積の学習時間は次の通りです。

batch : 逐次(init_model) | フル再学習   (test RMSE)
  1   :   0.5983         |   0.5983     (trees=80)
  2   :   0.4476         |   0.4549     (trees=160)
  3   :   0.3863         |   0.3973     (trees=240)
  4   :   0.3659         |   0.3683     (trees=320)
  5   :   0.3608         |   0.3508     (trees=400)
  6   :   0.3586         |   0.3408     (trees=480)

累積学習時間: 逐次=1.76s  フル再学習=6.25s (3.6x)

lightgbm-init-model

序盤〜中盤は、逐次学習がフル再学習と同等か、わずかに良い精度で追随しています。一方、データが溜まってくる終盤では、全データをまとめて学習するフル再学習が精度で上回ります。逐次学習では、序盤に少ないデータで作った木が後まで残る(追加された木は前の木を書き換えない)ためです。

その代わり、逐次学習の学習コストはずっと小さくなります。各更新で新しいバッチだけを処理すればよく、この例では累積学習時間が約3.6分の1でした。フル再学習は届くたびに全データを学習し直すので、データが増えるほど差は開きます。精度をわずかに譲る代わりに更新を安く速くするのが、逐次学習の位置づけです。

どんなときに使うか

前述の通り、最初から全データが揃っていて再学習も現実的なら、一括で学習した方が精度は良く、init_modelを使う必要はありません。init_modelが意味を持つのは、次のように「一括で200回学習する」が選べない状況です。

  • 新しいデータが後から届く:学習した時点では手元になかったデータが増えていく。集め直して一から学習し直す代わりに、届いた分だけ木を足して更新できる。
  • 過去データを保持しない・できない:データ量・保持ポリシー・プライバシーなどで古い生データを持ち続けられない場合、そもそも全データでのフル再学習は不可能。init_modelなら手元の新しいバッチだけで更新を続けられる。
  • 更新を安く速くしたい:全データの再学習はデータが増えるほど重くなる。更新コストを新バッチ分に抑えたいとき、逐次学習が候補になる。

逆に言えば、これらに当てはまらない(全データを保持していて再学習も軽い)なら、無理に逐次学習にせず一括で学習し直すのが素直です。

予測時間は木の本数に比例する

木を足すほど推論は遅くなります。予測にかかる時間は、おおよそ木の本数に比例します。24万行への予測時間を木の本数を変えて測ると次の通りです。

trees= 50:    66 ms
trees=100:   177 ms
trees=200:   348 ms
trees=400:   590 ms
trees=800:  1105 ms

逐次学習で更新を重ねると木は増え続けるので、推論のレイテンシとモデルサイズは徐々に大きくなります。用途に応じて、追加する本数を抑える・古いモデルを定期的に作り直すなどの検討が要ります。

refit との違い

既存モデルを新データで更新する方法として、LightGBMにはrefitもあります。両者は仕組みが異なります。

init_model refit
木の構造 保持(変えない) 保持(変えない)
更新の仕方 新しい木を追加する 既存の木の葉の値だけを更新する
木の本数 増える 変わらない
予測時間 増える 変わらない
向く場面 データを追加して学習を進めたい 構造は保ったまま新データに軽く合わせたい

init_modelはモデルを成長させる(木を足す)更新、refitは大きさを変えずに微調整する更新、と整理できます。

注意点

  • init_modelを使うときはDatasetfree_raw_data=Falseで作ります。作らないとCannot set predictor after freed raw dataというエラーになります。
  • num_boost_round追加する木の本数です(合計本数ではありません)。
  • init_modelにはBoosterオブジェクトのほか、save_modelで保存したモデルファイルのパスも渡せます。
  • 継続する回だけlearning_rateを変えることもできます(後半の木を小さめの学習率で足す、など)。
  • 追加する木は前の木を書き換えません。逐次学習を重ねると木が増え、推論時間・モデルサイズが大きくなります。

まとめ

  • init_modelに既存モデルを渡すと、木を保持したまま新しい木を追加して学習を継続できる
  • 同じデータでの「100+100」は「一括200」と一致する。つまり全データが揃っているなら一括学習で十分で、init_modelの出番は新しいデータが後から届く/古いデータを保持しないといった、一括学習が選べない場面
  • そうした逐次学習では、フル再学習に精度で肉薄しつつ学習コストを大きく下げられる(本例で約3.6倍速)。ただしデータが溜まると精度はフル再学習に譲る
  • 木が増えるため予測時間は木の本数に比例して伸びる
  • 葉だけを更新するrefitとは別物(構造保持・木を追加 vs 構造保持・葉のみ更新)

参考リンク