はじめに
カテゴリ変数を勾配ブースティングに入れるとき、one-hotエンコーディングで0/1の列に展開するのが定番です。 一方でLightGBMは、カテゴリ変数をそのまま扱えます。列を展開せず、1本の特徴量のまま分岐に使えます。
この記事では、指定方法・one-hotとの速度/精度の比較・内部でどう分岐しているかを、実際のコードで確認します。
カテゴリ変数の指定方法
指定方法は2通りあります。
① pandasのcategorydtypeにする(自動で認識されます)
import numpy as np
import pandas as pd
import lightgbm as lgb
df = pd.DataFrame({"cat": [0, 1, 2, 0, 1], "x1": [0.1, 0.4, 0.2, 0.9, 0.3]})
df["cat"] = df["cat"].astype("category") # これだけでカテゴリ扱いになる
y = np.array([1.0, 2.0, 3.0, 1.1, 2.1])
model = lgb.train(dict(objective="regression", verbose=-1),
lgb.Dataset(df, y), num_boost_round=10)
② categorical_featureで列を明示する
model = lgb.train(dict(objective="regression", verbose=-1),
lgb.Dataset(df, y, categorical_feature=["cat"]),
num_boost_round=10)
categorydtypeにしておけば自動認識されるので、通常は①で十分です。列名や列インデックスで明示したいときは②を使います。
なお、numpy配列で渡す場合はdtypeが数値のため自動認識されず、categorical_featureでの指定が必要です。値は0以上の整数である必要があり、負値や小数は事前にラベルエンコードして整数に直します。
one-hotとの比較
高カーディナリティ(カテゴリ数が多い)のデータで、ネイティブ処理とone-hotを比べます。 カテゴリ自体が目的変数に効く(カテゴリごとに固有の効果を持つ)状況を作ります。
import time
import numpy as np
import pandas as pd
import lightgbm as lgb
def make(n=60000, n_cat=200, seed=0):
rng = np.random.RandomState(seed)
cat = rng.randint(0, n_cat, n) # 0..n_cat-1 のカテゴリ
cat_effect = rng.randn(n_cat) * 2.0 # カテゴリごとの効果
x1, x2 = rng.randn(n), rng.randn(n)
y = cat_effect[cat] + x1 + 0.5 * x2 + rng.randn(n) * 0.5
return pd.DataFrame({"cat": cat, "x1": x1, "x2": x2}), y
rmse = lambda p, t: float(np.sqrt(np.mean((p - t) ** 2)))
params = dict(objective="regression", num_leaves=31, learning_rate=0.1,
min_child_samples=20, verbose=-1)
df, y = make()
tr = np.arange(len(df)) < 45000
df_tr, df_te, y_tr, y_te = df[tr], df[~tr], y[tr], y[~tr]
# ① ネイティブ: cat を category dtype に
dtr = df_tr.copy(); dtr["cat"] = dtr["cat"].astype("category")
dte = df_te.copy(); dte["cat"] = dte["cat"].astype("category")
t = time.perf_counter()
m1 = lgb.train(params, lgb.Dataset(dtr, y_tr), num_boost_round=200)
t1 = time.perf_counter() - t
# ② one-hot: get_dummies で列に展開
tr_oh = pd.get_dummies(df_tr, columns=["cat"])
te_oh = pd.get_dummies(df_te, columns=["cat"]).reindex(columns=tr_oh.columns, fill_value=0)
t = time.perf_counter()
m2 = lgb.train(params, lgb.Dataset(tr_oh, y_tr), num_boost_round=200)
t2 = time.perf_counter() - t
print(f"① native RMSE={rmse(m1.predict(dte), y_te):.4f} time={t1:.2f}s feat={dtr.shape[1]}")
print(f"② one-hot RMSE={rmse(m2.predict(te_oh), y_te):.4f} time={t2:.2f}s feat={tr_oh.shape[1]}")
5シードの平均は次の通りです(n_cat=200)。
① native RMSE=0.5177 time=0.83s feat=3
② one-hot RMSE=0.5611 time=0.97s feat=202
ネイティブ処理は特徴量が3本のまま、精度もone-hotより良く、学習も速いという結果です。 one-hotは200個のカテゴリを200列に展開するため、特徴量が202本に膨らみます。
差はカテゴリ数を増やすほど広がります。カーディナリティを変えて(各3シード平均)測ると次のようになります。
n_cat= 50 : native RMSE=0.513 t=0.76s | one-hot RMSE=0.527 t=0.80s (feat=52)
n_cat= 200 : native RMSE=0.520 t=0.76s | one-hot RMSE=0.564 t=0.93s (feat=202)
n_cat=1000 : native RMSE=0.602 t=0.83s | one-hot RMSE=0.938 t=1.53s (feat=1002)
n_cat=2000 : native RMSE=0.655 t=1.12s | one-hot RMSE=1.409 t=1.95s (feat=2002)
カテゴリ数が少ないうちは差は小さいですが、数が増えると、ネイティブ処理は速度・精度をほぼ保つ一方、one-hotは学習が遅くなり、精度も落ちていきます。one-hotでは1つの分岐が「あるカテゴリか否か」しか判定できず、木の深さが限られるとカテゴリの効果を表現しきれないためです。
内部の仕組み
LightGBMはカテゴリ変数の分岐で、カテゴリを2つのグループに分けます。one-hotのような「1カテゴリ対その他」ではなく、複数のカテゴリをまとめて片側の枝に送れます。
実際に木の分岐を見てみます。カテゴリごとに効果を持つデータを学習し、最初の木のcat分岐を確認します。
rng = np.random.RandomState(0)
n, n_cat = 20000, 12
cat = rng.randint(0, n_cat, n)
cat_effect = rng.randn(n_cat) * 2.0
y = cat_effect[cat] + rng.randn(n) * 0.3
df = pd.DataFrame({"cat": cat}); df["cat"] = df["cat"].astype("category")
m = lgb.train(dict(objective="regression", num_leaves=8, learning_rate=0.1, verbose=-1),
lgb.Dataset(df, y), num_boost_round=10)
tdf = m.trees_to_dataframe()
splits = tdf[(tdf.tree_index == 0) & (tdf.split_feature == "cat")]
for _, r in splits.iterrows():
print(f"node {r.node_index}: cat in {{{r.threshold}}}")
node 0-S0: cat in {1||2||3||5||6||9}
node 0-S1: cat in {2||6}
node 0-S5: cat in {6}
node 0-S4: cat in {1||5}
node 0-S2: cat in {10}
node 0-S3: cat in {0||7}
node 0-S6: cat in {8||11}
分岐の閾値が単一の値ではなく、{1||2||3||5||6||9}のようなカテゴリの集合になっています。
最初の分岐は{1,2,3,5,6,9}とそれ以外に分けていますが、この2群は真の効果できれいに分かれています(前者は効果が高め〜中程度、後者{0,4,7,8,10,11}はいずれも強い負)。
LightGBMは分岐ごとに、カテゴリを目的変数の統計量でソートし、最も損失が下がる位置で2グループに分割します(Fisherの最適分割)。このため、200個のカテゴリでも1つの分岐で一括に振り分けられます。one-hotだと同じことをするのに何段もの分岐が必要になり、これが精度・速度の差につながります。
注意点
- カテゴリ変数の分岐に関わるパラメータがいくつかあります。
max_cat_threshold(デフォルト32)… 1分岐でカテゴリを分けるときの閾値候補数の上限。min_data_per_group(デフォルト100)… 1グループあたりの最小データ数。小さくしすぎると過学習しやすくなります。cat_smooth(デフォルト10)・cat_l2(デフォルト10)… カテゴリごとの統計量を平滑化・正則化する項。データの少ないカテゴリの過学習を抑えます。 通常はデフォルトのままで問題ありません。
- カーディナリティが非常に高い(数千以上)と、カテゴリ分岐は過学習しやすくなります。それでも今回の範囲ではone-hotより汎化していましたが、
cat_smooth・cat_l2・min_data_per_groupで調整の余地があります。 - ネイティブ指定した列の値は0以上の整数で渡します。
categorydtypeにしておけば内部でコード化されるので、この点は気にせずに済みます。 - 学習データに存在しなかったカテゴリ(未知のカテゴリ)は、予測時に欠損と同様の扱いになります。
まとめ
- LightGBMはカテゴリ変数を
categorydtype(またはcategorical_feature指定)でそのまま扱える - one-hotと違い列が増えず、高カーディナリティでは速度・精度ともにネイティブ処理が有利
- 内部ではカテゴリを目的変数の統計量でソートし、2グループに分割する(Fisherの最適分割)ため、1分岐で多数のカテゴリを振り分けられる
- 過学習が気になる場合は
cat_smooth・cat_l2・min_data_per_groupで調整できる(通常はデフォルトで可)