Ml-tips

【LightGBM】単調性制約(monotone constraints)を使ってみる

はじめに

LightGBMには**単調性制約(monotone constraints)**という機能があります。 これは「この特徴量が増えたら、予測値は必ず増える(もしくは必ず減る)」という制約を、モデルに強制的に守らせる仕組みです。

例えば「部屋の広さが増えれば家賃は上がるはず」「年齢が上がれば保険料は上がるはず」といった、ドメイン知識として明らかに単調な関係がある場合に効果を発揮します。 勾配ブースティングは表現力が高いぶん、ノイズに引っ張られて本来の単調な関係をガタガタに学習してしまうことがあります。単調性制約はそれを防いでくれます。

今回はこの単調性制約を実際に動かして、制約なしのときとどう変わるのかを見ていきます。

単調性制約とは

勾配ブースティングは決定木を大量に組み合わせるモデルなので、放っておくと局所的なノイズにフィットして、以下のような予測をしてしまうことがあります。

  • 本来は「xが増えれば y も増える」はずなのに、ところどころで予測値が下がる

こうした「本来の関係と矛盾する予測」は、

  1. モデルの解釈性を損なう(なぜここだけ下がるの?と説明できない)
  2. 過学習の温床になる(ノイズを拾っている)
  3. ビジネス側の納得感が下がる(広い部屋のほうが家賃が安い、という予測は受け入れられない)

といった問題を引き起こします。 単調性制約を入れると、指定した特徴量については必ず単調(増加 or 減少)になるよう学習してくれるので、これらの問題を回避できます。

環境

今回使ったバージョンは以下です。

LightGBM 4.6.0
numpy 2.2.6

インストールがまだの場合は以下でOKです。

$ pip install lightgbm

データ準備

まずは検証用のデータを作ります。 y = log(1 + x) という単調増加の関係をベースにして、そこにノイズを乗せています。 本来は右肩上がりの関係ですが、ノイズがあるので素直に学習すると局所的にガタつくはずです。

import numpy as np
import matplotlib.pyplot as plt
import lightgbm as lgb

np.random.seed(42)

N = 300
x = np.random.uniform(0, 10, N)
y_true = np.log1p(x)                    # 単調増加の真の関係
y = y_true + np.random.normal(0, 0.4, N)  # ノイズを乗せる

X = x.reshape(-1, 1)

plt.scatter(x, y, s=12, alpha=0.5, label="observed data (noisy)")
xs = np.sort(x)
plt.plot(xs, np.log1p(xs), color="black", lw=2, label="true relation (monotonic)")
plt.legend()
plt.show()

黒い線が真の関係(単調増加)で、点がノイズ入りの観測データです。 全体として右肩上がりですが、点はそれなりにばらついていますね。

制約なしで学習してみる

まずは普通に、制約なしでLightGBMを学習させます。 今回は関係を見やすくするため、0〜10の細かいグリッドに対して予測させて、その予測曲線を見てみます。

grid = np.linspace(0, 10, 500).reshape(-1, 1)

params = dict(
    objective="regression",
    num_leaves=15,
    learning_rate=0.1,
    min_child_samples=5,
    verbose=-1,
)

model_free = lgb.train(params, lgb.Dataset(X, y), num_boost_round=200)
pred_free = model_free.predict(grid)

単調性制約を入れる

次に単調性制約を入れます。指定方法はとてもシンプルで、monotone_constraintsパラメータに特徴量ごとの制約をリストで渡すだけです。

  • 1 … 単調増加(その特徴量が増えたら予測も必ず増える)
  • -1 … 単調減少(その特徴量が増えたら予測は必ず減る)
  • 0 … 制約なし

今回は特徴量がxの1つだけで、単調増加させたいので [1] を渡します。 特徴量が複数ある場合は、例えば [1, 0, -1] のように特徴量の数だけ並べます。

params_mono = dict(params, monotone_constraints=[1])  # x を単調増加に

model_mono = lgb.train(params_mono, lgb.Dataset(X, y), num_boost_round=200)
pred_mono = model_mono.predict(grid)

結果を比べてみる

制約なしと制約ありの予測曲線を重ねてみます。

plt.scatter(x, y, s=10, alpha=0.25, color="gray")
plt.plot(grid, pred_free, label="no constraint", lw=2)
plt.plot(grid, pred_mono, label="monotone increasing", lw=2)
plt.legend()
plt.show()

青が制約なし、オレンジが単調増加制約ありです。 青い線はところどころ上がったり下がったりしているのに対し、オレンジは一度も下がらずきれいな右肩上がりの階段状になっているのがわかります。

実際に「予測値が前の点より下がっている箇所(=単調性が破れている箇所)」を数えてみると、はっきり差が出ます。

def n_violation(p):
    return int(np.sum(np.diff(p) < -1e-9))

print("制約なし :", n_violation(pred_free))   # => 47
print("制約あり :", n_violation(pred_mono))   # => 0
制約なし : 47
制約あり : 0

制約なしでは500点中47箇所で予測値が下がってしまっていましたが、制約ありでは0箇所。しっかり単調性が守られていることがわかります。

制約の強さを調整する:monotone_constraints_method

単調性制約にはmonotone_constraints_methodというパラメータもあって、制約のかけ方を3段階で選べます。

特徴
basic(デフォルト) 最もシンプル。学習速度は落ちないが、制約が強すぎて予測を抑え込みすぎることがある
intermediate 学習がほんの少し遅くなるが、basicより制約が緩く精度が上がりやすい
advanced さらに制約が緩く精度が出やすいが、学習がやや遅くなる

デフォルトのbasicは「安全側だが少しキツすぎる」ことがあるので、精度を追いたい場合はintermediateadvancedを試すのがおすすめです。

params_mono = dict(
    params,
    monotone_constraints=[1],
    monotone_constraints_method="advanced",
)

もう一つ、monotone_penaltyというパラメータもあります。 これは木の浅い部分(=影響が大きい分岐)に対して単調性制約をどれだけ強くかけるかを調整するもので、値を大きくすると根に近い分岐ほど制約が強くなります。まずはデフォルトのままで問題ありません。

まとめ

  • LightGBMの単調性制約はmonotone_constraintsにリストを渡すだけで使える
    • 1=単調増加、-1=単調減少、0=制約なし を特徴量の数だけ並べる
  • ドメイン知識として単調な関係がわかっている特徴量に使うと、過学習の抑制解釈性の向上に効く
  • 精度を追う場合はmonotone_constraints_methodintermediateadvancedにするのも手

「この特徴量は増えたら絶対に上がるはず」という知識をモデルに教え込めるのは地味に強力です。Kaggleでも実務でも、ぜひ引き出しの一つに入れておきましょう。

参考リンク