はじめに
学習済みのモデルを新しいデータに合わせたいとき、普通は最初から学習し直します。木を一から作り直すので、それなりに時間がかかります。
LightGBMにはrefit()というメソッドがあります。これは木の構造(どの特徴量をどの閾値で分割するか)はそのままに、葉の値だけを新しいデータで更新するという機能です。木を作り直さないので高速で、既存モデルを新しいデータへ手早く適応させたいときに使えます。
この記事では、その挙動と使いどころを実際のコードで確認します。
refitの基本
まず、refit()が本当に「構造はそのまま・葉だけ更新」なのかを確認します。
古い世界のデータで学習したモデルoldを用意し、別のデータでrefit()した結果refと、木の構造・葉の値を比べます。
import numpy as np
import lightgbm as lgb
def gen(n, regime, seed):
r = np.random.RandomState(seed)
X = r.uniform(-2, 2, (n, 5))
if regime == "old":
y = 3*X[:,0] + 2*np.sin(3*X[:,1]) - X[:,2]**2 # 古い世界
else:
y = 3*X[:,0] - 2*np.sin(3*X[:,1]) + X[:,2]**2 + 4.0 # 新しい世界(関係が変化)
return X, y + r.randn(n)*0.3
params = dict(objective="regression", num_leaves=31, learning_rate=0.05,
min_child_samples=20, verbose=-1)
Xold, yold = gen(6000, "old", 1)
Xnew, ynew = gen(3000, "new", 2)
old = lgb.train(params, lgb.Dataset(Xold, yold), num_boost_round=300)
ref = old.refit(Xnew, ynew, decay_rate=0.5)
a = old.trees_to_dataframe()
b = ref.trees_to_dataframe()
print("木の本数 old / refit:", old.num_trees(), "/", ref.num_trees())
print("分割の特徴が一致:", a["split_feature"].equals(b["split_feature"]))
print("分割の閾値が一致:", a["threshold"].equals(b["threshold"]))
print("葉の値が変化した数:", int((a["value"].round(9) != b["value"].round(9)).sum()), "/", len(a))
木の本数 old / refit: 300 / 300
分割の特徴が一致: True
分割の閾値が一致: True
葉の値が変化した数: 9300 / 18300
木の本数(300本)も、各ノードの分割特徴・閾値もすべて一致しています。一方で、葉の値は9300個すべてが更新されました。狙い通り、構造は固定・葉だけ差し替えになっています。
refit()は元のモデルを書き換えず、更新後の新しいBoosterを返す点にも注意してください(上のoldはそのまま残ります)。
概念ドリフトで適応させる
refit()が効くのは、データの傾向が時間とともに変わる概念ドリフトのような場面です。
ここでは「古い世界」で学習したモデルを、関係性が変わった「新しい世界」のテストに当てます。そのままのold、refit()したもの、新データで全再学習したものの3つを比べます。
import time
Xtest, ytest = gen(4000, "new", 3)
rmse = lambda p, t: float(np.sqrt(np.mean((p - t) ** 2)))
# ① 旧モデルをそのまま適用
print("① 旧モデルをそのまま:", round(rmse(old.predict(Xtest), ytest), 4))
# ② refit: 新データで葉を更新
t0 = time.time()
ref = old.refit(Xnew, ynew, decay_rate=0.5)
t_ref = time.time() - t0
print("② refit :", round(rmse(ref.predict(Xtest), ytest), 4), f" {t_ref*1000:.0f}ms")
# ③ 新データで全再学習
t0 = time.time()
full = lgb.train(params, lgb.Dataset(Xnew, ynew), num_boost_round=300)
t_full = time.time() - t0
print("③ 全再学習 :", round(rmse(full.predict(Xtest), ytest), 4), f" {t_full*1000:.0f}ms")
① 旧モデルをそのまま: 7.6242
② refit : 0.6455 52ms
③ 全再学習 : 0.3943 932ms
旧モデルは新しい世界でRMSE7.62と大きく外していますが、refit()で葉を更新するだけで0.65まで下がりました。全再学習(0.39)には及ばないものの、かかった時間はrefitが全再学習の約18分の1です(絶対時間は環境で変わりますが、木を作り直さない分だけ速くなります)。
木を作り直さず葉の値だけ差し替えることで、安く・速く新しいデータへ寄せられる、というのがrefit()の使いどころです。
decay_rate:旧と新のブレンド率
refit()で葉の値をどれだけ入れ替えるかは、decay_rate(デフォルト0.9)で決まります。おおよそ
新しい葉の値 = decay_rate × 旧の葉の値 + (1 - decay_rate) × 新データでの葉の値
というブレンドです。decay_rate=0.9は旧の値を9割残す保守的な設定で、0.0にすると新データの葉の値だけになります。
デフォルトの0.9は「少しだけ寄せる」挙動なので、今回のようにはっきりドリフトした状況では効きが弱くなります。decay_rateを振って確かめます。
import matplotlib.pyplot as plt
drs = [0.0, 0.3, 0.5, 0.7, 0.9]
vals = [rmse(old.refit(Xnew, ynew, decay_rate=dr).predict(Xtest), ytest) for dr in drs]
for dr, v in zip(drs, vals):
print(f"decay_rate={dr}: RMSE={v:.4f}")
r_full = rmse(full.predict(Xtest), ytest)
plt.plot(drs, vals, "o-", label="refit (leaf update)")
plt.axhline(r_full, color="green", ls="--", label=f"full retrain = {r_full:.2f}")
plt.xlabel("decay_rate (0 = new leaves only, 0.9 = keep 90% of old)")
plt.ylabel("test RMSE on new-world data")
plt.legend(); plt.show()
decay_rate=0.0: RMSE=0.8244
decay_rate=0.3: RMSE=0.6499
decay_rate=0.5: RMSE=0.6455
decay_rate=0.7: RMSE=1.0196
decay_rate=0.9: RMSE=2.6351

デフォルトの0.9ではRMSE2.64と、旧の値を残しすぎて適応しきれていません。0.3〜0.5まで下げると0.65前後まで下がり、全再学習(0.39、緑の破線)にかなり近づきます。ドリフトへ積極的に適応させたいときは、decay_rateを下げるのが有効です。
なお、下げすぎ(0.0)が最良とは限りません。新データが少ないと新しい葉の値自体がばらつくため、旧の値を少し混ぜた方が安定することがあります(今回は0.5が最良でした)。適切な値はデータ次第です。
注意点
refit()は木の分割構造を変えません。分割そのものが新しいデータに合っていない(別の特徴量で分けるべき)場合、葉の値をどう更新しても限界があります。今回、全再学習(0.39)に届かなかったのはこのためです。逆に、分割構造が依然として妥当で、葉の値だけを直せば済むケースではrefit()が特に効きます。- 更新には新データの正解ラベルが必要です(教師なしで葉を更新するわけではありません)。
refit()は新しいBoosterを返します。元のモデルは変更されないので、必要なら返り値を受け取って使います。
まとめ
refit()は木の構造を保ったまま、葉の値だけを新データで更新する- 木を作り直さないため高速で、概念ドリフトへの安価な部分適応に使える
- 更新の強さは
decay_rateで決まる。デフォルト0.9は旧の値を9割残す保守的な設定で、積極的に適応させたいときは下げる - 分割構造は固定されるため、分割自体を変えるべきドリフトでは全再学習に劣る