【LightGBM】refitで木構造を保ったまま葉の値だけ更新する

はじめに

学習済みのモデルを新しいデータに合わせたいとき、普通は最初から学習し直します。木を一から作り直すので、それなりに時間がかかります。

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()が効くのは、データの傾向が時間とともに変わる概念ドリフトのような場面です。

ここでは「古い世界」で学習したモデルを、関係性が変わった「新しい世界」のテストに当てます。そのままのoldrefit()したもの、新データで全再学習したものの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

lightgbm-refit

デフォルトの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割残す保守的な設定で、積極的に適応させたいときは下げる
  • 分割構造は固定されるため、分割自体を変えるべきドリフトでは全再学習に劣る

参考リンク