はじめに
LightGBMはlgb.trainのinit_model引数に既存モデルを渡すと、そのモデルを起点に学習を続けられます。すでにある木はそのまま残し、後ろに新しい木を追加します。
データが少しずつ届く逐次学習(オンライン学習)で、毎回すべてのデータを学習し直す代わりに、新しく届いた分だけ木を足して更新する、といった使い方ができます。この記事で挙動を実測します。
学習を継続する
まず、100回学習したモデルにinit_modelで100回追加してみます。init_modelを使うときは、Datasetをfree_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)
序盤〜中盤は、逐次学習がフル再学習と同等か、わずかに良い精度で追随しています。一方、データが溜まってくる終盤では、全データをまとめて学習するフル再学習が精度で上回ります。逐次学習では、序盤に少ないデータで作った木が後まで残る(追加された木は前の木を書き換えない)ためです。
その代わり、逐次学習の学習コストはずっと小さくなります。各更新で新しいバッチだけを処理すればよく、この例では累積学習時間が約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を使うときはDatasetをfree_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 構造保持・葉のみ更新)