lightgbm カテゴリカル変数と欠損値の扱いについて+α

一発目から自由研究をしていないのですが、ご容赦ください。笑
lightgbmのカテゴリカル変数の扱い等がチーム内で話題になったため、メモも兼ねてまとめました。

本題

話題となったのは、以下の3点です。

  • label encoding1して入力するのと、カテゴリカル変数として入力する違い
  • trainに存在しないが、testには存在するカテゴリーの扱い
  • 欠損値の扱い

これより、順に説明します。

1. label encoding[^1]して入力するのと、カテゴリカル変数として入力する違い

 label encodingして入力すると、普通の数値型変数と同様に閾値との大小関係で判定されます。カテゴリカル変数として入力すると、marugariさんがブログで紹介しているように変数A (is or is not) category_xで判定されるようです。ただ、カテゴリーに順序性がある場合は、カテゴリカル変数として扱うのはイマイチの場合があるとのこと。2 そのため、カテゴリカル変数でも順序性がある特徴はlabel encodingで、順序性がない特徴はカテゴリカル変数として入力するのが良さそうです。

 下の画像は、カテゴリカル変数を持つデータで学習後に、graphvizを使って木を可視化したものです。閾値の部分がis, is notで分岐していることがわかります。

f:id:tebasaki3:20190127220105p:plain

2. trainに存在しないが、testには存在するカテゴリーの扱い

 is, is notで判定する仕様のため、testにのみ存在するカテゴリーは全ての分岐でis not側に振り分けられます。そのためコンペなどでは問題ないのですが、実務ではちょっとした落とし穴があります。

カテゴリー型をlightgbmの入力とする方法は以下の2種類が主流だと思います。
 - scikit-learnのLebelEncoderを使い、学習時にカテゴリカル変数としてカラムを指定する
 - pandasのカテゴリー型に変換して、モデルに入れる

 ここで、実務で学習・予測データを別々に処理する必要がある場合は、LabelEncoderの出力番号が学習・予測データでずれてしまう場合があります。一方pandasのカテゴリー型では、元のカテゴリーの情報を保持して適切に扱ってくれるようです。そのため、問題がなければpandasのカテゴリー型に変換して学習させるのが良いと考えます。

 以下、しばらくLabelEncoderとpandasのカテゴリー型の検証が続くので、興味のない人は飛ばしてください。

LabelEncoder + 入力時にカテゴリカル指定
設定

カテゴリカル変数'cat'がcなら1, それ以外なら0のデータを予測
学習データのカテゴリカル変数は'a' or 'c'
テストデータのカテゴリカル変数は'a' or 'b'のため、予測結果は0になることが期待される

>>> c1_train = np.random.choice(['a', 'c'], 100)
>>> c2_train = np.random.rand(100)
>>> c1_test = np.repeat(['b', 'a'], 1)
>>> c2_test = np.random.rand(2)
>>> columns = ['cat', 'rand']

>>> X_train = pd.DataFrame(np.c_[c1_train, c2_train], columns=columns)
>>> y_train = np.where(X_train.iloc[:, 0] == 'c', 1, 0)
>>> X_test = pd.DataFrame(np.c_[c1_test, c2_test], columns=columns)

label encodingの処理

>>> le = LabelEncoder()
>>> X_train.cat = le.fit_transform(X_train.cat)
>>> X_test.cat = le.fit_transform(X_test.cat)
>>> X_train.rand = X_train.rand.astype('float') 

モデル学習と予測

>>> X_data = lgb.Dataset(X_train, y_train, categorical_feature=['cat'])
>>> params = {'n_estimators': 1, 'learning_rate':1}
>>> model = lgb.train(params=params, train_set=X_data)
>>> model.predict(X_test)
array([ 1.00000001e+00, -2.14576721e-08]) # カテゴリー変数がbなのに、予測クラスが1
pandasでカテゴリー型に変換
>>> c1_train = np.random.choice(['a', 'c'], 100)
>>> c2_train = np.random.rand(100)
>>> c1_test = np.repeat(['b', 'a'], 1)
>>> c2_test = np.random.rand(2)
>>> columns = ['cat', 'rand']

>>> X_train = pd.DataFrame(np.c_[c1_train, c2_train], columns=columns)
>>> y_train = np.where(X_train.iloc[:, 0] == 'c', 1, 0)
>>> X_test = pd.DataFrame(np.c_[c1_test, c2_test], columns=columns)

category型への変換

>>> X_train.cat = X_train.cat.astype('category')
>>> X_train.rand = X_train.rand.astype('float') 
>>> X_test.cat = X_test.cat.astype('category')
>>> X_test.rand = X_test.rand.astype('float')

モデル学習と予測

>>> X_data = lgb.Dataset(X_train, y_train)
>>> params = {'n_estimators': 1, 'learning_rate':1}
>>> model = lgb.train(params=params, train_set=X_data)
>>> model.predict(X_test)
array([7.15255732e-09, 7.15255732e-09]) # 予測クラスがどちらも0

3. 欠損値の扱い

lightgbmでは、欠損値を一度無視して分割を探索した後に、よりロスが下がるほうの分岐に欠損値を振り分けるようです。3 そのため、例えばその変数が欠損値であるという情報が重要な場合は、明示的にその変数の最大値、最小値の外側の値を代入するなどで区別がつくようにした方が良いと思います。 また、zero_as_missingパラメータをTrueにすると、0もnullと同様に無視をして分割を探索してから、あとで振り分けるようになるとのこと。

まとめ

  • label encodingは普通の数値型変数として扱われる。カテゴリカル変数として入力すると分岐がis, is notで判定される
  • trainに存在しないが、testには存在するカテゴリーの扱いはis, is notで判定されるため基本は問題ない。しかし、実務などで予測データのみをLabelEncoderで変換する場合は注意が必要。
  • 欠損値の扱いは、一度無視して分岐を探索後、よりロスが下がるほうに振り分ける。

おまけ

カテゴリカル変数はmax binの制約がかかるのか。
githubのdisucussionを要約すると、カテゴリー型の変数はmax_binの最大値を下回る場合は全カテゴリーが採用され、上回る場合は頻度の高い順から全データの99%を占めるまでに登場する全カテゴリーが採用されるようです。そのため、high cardinalityな変数ではかなり多数のカテゴリーとなるケースがありそうですね。



  1. label encoding : 各カテゴリーを0からの連番などの数値に変換する処理

  2. リンクの実験参照

  3. リンク3.2項参照 余談ですが、xgboostではデフォルトで0と欠損値を無視して分割を探索後に、よりロスが下がるほうに両方を振り分けるようです。この辺りにも、微妙に仕様の差があるようですね。