Ml-tips

【LightGBM】カテゴリ変数のネイティブ処理(one-hot不要)

はじめに

カテゴリ変数を勾配ブースティングに入れるとき、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_smoothcat_l2min_data_per_groupで調整の余地があります。
  • ネイティブ指定した列の値は0以上の整数で渡します。categorydtypeにしておけば内部でコード化されるので、この点は気にせずに済みます。
  • 学習データに存在しなかったカテゴリ(未知のカテゴリ)は、予測時に欠損と同様の扱いになります。

まとめ

  • LightGBMはカテゴリ変数をcategorydtype(またはcategorical_feature指定)でそのまま扱える
  • one-hotと違い列が増えず、高カーディナリティでは速度・精度ともにネイティブ処理が有利
  • 内部ではカテゴリを目的変数の統計量でソートし、2グループに分割する(Fisherの最適分割)ため、1分岐で多数のカテゴリを振り分けられる
  • 過学習が気になる場合はcat_smoothcat_l2min_data_per_groupで調整できる(通常はデフォルトで可)

参考リンク