目的
-
LightGBM
で交差検証を行う時に、cv()
関数を使いサクッと書く。 - K-Fold系以外のデータ分割方法を使用する。
はじめに
LightGBM
のTraining APIにはcv()
関数があり、for
ループを使うスクラッチよりも手短に交差検証(以下CV)を記述できます。
一方で、CVのデータ分割には、様々な方法がsklearn.model_selection
モジュールに用意されています(sklearn
のmodel_selection
解説記事)。これらはすべてcv()
に適用可能です。
cv()
そのものには、nfold
やstratified
、shuffle
といったデータ分割のパラメータがあり、K-FoldやStratified K-Foldなどはこれらを使うことで実装できます。
他のsplitterに関しては、cv()
にはfolds
というパラメータがあり、ここにsklearn.model_selection
のクラスを突っ込むと、データ分割に関する 他のパラメータに優先して この設定を使用し、分割を行ってくれます。自作splitterも使用可能です。
手順
iris
データセットを例に挙げ、分割方法は変わりどころでLeaveOneGroupOut
(LOGO)を使ってみます。
流れは次の通り。
- モジュール、データセットを用意し、図示してデータの並びを確認する
- LOGO用グループの作成
- 訓練データとテストデータに分割
- CVで学習する
- 出力されたものの確認
- テストデータで検証
1. モジュール、データセットの用意と図示
import lightgbm as lgb
import matplotlib.pyplot as plt
import numpy as np
import pprint
import seaborn as sns
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import train_test_split, LeaveOneGroupOut
from sklearn.preprocessing import LabelEncoder
# データ読み込み
df = sns.load_dataset("iris")
print(df)
sepal_length sepal_width petal_length petal_width species
0 5.1 3.5 1.4 0.2 setosa
1 4.9 3.0 1.4 0.2 setosa
.. ... ... ... ... ...
148 6.2 3.4 5.4 2.3 virginica
149 5.9 3.0 5.1 1.8 virginica
[150 rows x 5 columns]
LabelEncoder
を使い、species列を整数に変換します。
df["species_number"] = LabelEncoder().fit_transform(df["species"])
irisデータはspeciesごとに並んでいるので、訓練データとテストデータの分割には注意が必要です。
# speciesの並びを図示
plt.scatter(df.index, df["species"])
plt.xlabel("index")
plt.ylabel("species")
2. LOGO用にグループ作成
LOGOでは、データポイントごとにあらかじめ定められたグループ番号に基づき、ある1つのグループをtest、その他をtrainに使用します。グループが全部で4つある場合、
fold | group_train | group_test |
---|---|---|
0 | 1, 2, 3 | 0 |
1 | 2, 3, 0 | 1 |
2 | 3, 0, 1 | 2 |
3 | 0, 1, 2 | 3 |
となります。KFold
と似ていますが、グループ番号の設定が必要なのと、グループごとにサンプル数が大幅に違っていても構わないことが特徴です。
簡単のため、df.index
を4で割った余りをグループ番号とします。
df["group"] = (df.index % 4).values
print(df["group"])
0 0
1 1
2 2
3 3
..
148 0
149 1
Name: group, Length: 150, dtype: int64
この例ではspeciesはグループ番号ごとにまんべんなくバラけているので、不均衡データとなる可能性は低いです。
3. 訓練データとテストデータに分割
CVを行う訓練データと、最後に検証を行うテストデータに分割します。
上で確認した通り、irisデータはspeciesごとに並んでいるので、分割の際はシャッフルします。
train_test_split()
のパラメータshuffle
はデフォルトでTrue
ですが、明示的に指定します。
# 訓練データとテストデータを分割
df_train, df_test = train_test_split(df, test_size=0.2, shuffle=True, random_state=42)
speciesがきちんと分割されたか、一応確認します。
print("train data species")
print(df_train["species"].value_counts())
print("test data species")
print(df_test["species"].value_counts())
train data species
species
versicolor 41
setosa 40
virginica 39
Name: count, dtype: int64
test data species
species
virginica 11
setosa 10
versicolor 9
Name: count, dtype: int64
いい感じに分散しています。
4. CVで学習する
-
LightGBM
のパラメータを設定 - データセットの作成
- LOGO分割の設定
-
cv()
関数の実行
の順で行います。パラメータは大まかにはCVではないLightGBMとほぼ変わりません。
# LightGBMのパラメータ
params = {
"objective": "multiclass",
"num_class": 3,
"metric": "multi_logloss",
"verbosity": -1,
"seed": 42,
}
# early stopping設定
es = lgb.early_stopping(10, verbose=10)
# データセットを作成
lgb_train = lgb.Dataset(
df_train[["sepal_length", "sepal_width", "petal_length", "petal_width"]],
label=df_train["species_number"])
# LOGO分割のインスタンスを作成
logo = LeaveOneGroupOut()
# モデルの学習
cv_results = lgb.cv(
params,
lgb_train,
num_boost_round=100,
folds=logo.split(df_train, groups=df_train["group"]), # fold指定
callbacks=[es],
return_cvbooster=True, # 学習済みブースターを取り出すためのパラメータ
)
Training until validation scores don't improve for 10 rounds
Early stopping, best iteration is:
[29] cv_agg's multi_logloss: 0.301891 + 0.0493512
サクッと書いてサクッと終わりました。
foldsに代入するオブジェクトについて。
lightgbm.cv()ドキュメントには
generator or iterator of (train_idx, test_idx) tuples, scikit-learn splitter object
とありますが、sklearn
のsplitterを使う場合、書き方には2種類があります。
-
インスタンス作成時にすべてのパラメータを代入するsplitter、例えば
KFold
、StratifiedKFold
、ShuffleSplit
、LeaveOneOut
、LeavePOut
、TimeSeriesSplit
などの場合は、iterator of (train_idx, test_idx) tuplesおよびscikit-learn splitter objectの両方を使用できます。すなわち、kf = KFold(n_splits=3)
としてインスタンスを作成した上で、
cv_results = lgb.cv( ... nfold=kf.split(X), # (train_idx, test_idx) タプルのイテレータを代入 ... )
でも
cv_results = lgb.cv( ... nfold=kf, # splitterを直接代入 ... )
でもどちらでも動きます。(冗長ですが)2番目の場合、
X
は要素数しか重要でなく、CV用データと長さが同じものであれば、例えばnumpy.zeros(len(df_train))
でも動きます。 -
今回のように、インスタンス作成後splitする時にさらにパラメータの代入が必要な、
LeaveOneGroupOut
、LeavePGroupsOut
、GroupShuffleSplit
などの場合は、iterator of (train_idx, test_idx) tuplesのみ、すなわち、logo = LaveOneGroupOut() cv_results = lgb.cv( ... nfold=logo.split(X, groups), # (train_idx, test_idx) タプルのイテレータを代入 ... )
のケースでしか動きません(
groups
が必須なのだから当然と言えば当然)。X
が長さのみ重要なのも、1と同様です。
5. 出力されたものの確認
上のcv()
ではパラメータreturn_cvbooster
をTrue
にしたので、戻り値の辞書にkeyがcvbooster
、valueがCVBooster
の要素が追加され、ここからBooster
を取り出せるようになります。
pprint.pprint(cv_results)
{'cvbooster': <lightgbm.engine.CVBooster object at 0x00000**********0>,
'multi_logloss-mean': [0.9575569821056559,
0.8450309423260612,
...
0.30444359000617194,
0.3018914613226832],
'multi_logloss-stdv': [0.005672538477242035,
0.009719508968869075,
...
0.048503937668075946,
0.04935122241640637]}
返り値には、CVBooster
オブジェクトと、最適解に至るまでの試行のmetrics値が格納されています。CVBooster
オブジェクトからは、best iterationやCVの各試行でのBooster
を取得できます。
6. テストデータで検証
CVの結果を使ってテストデータの予測をしてみましょう。
CVのsplitごとのブースターでテストデータを予測し、可能性予測値の平均で最大のものを最終的なクラス予測値とします(この部分のコードをもっとスマートに書く方法がありましたらご教授ください)。
# splitごとのブースターを取得
boosters = cv_results['cvbooster'].boosters
# 各ブースターで可能性予測値を計算
pred_per_cv = [
b.predict(df_test[["sepal_length", "sepal_width", "petal_length", "petal_width"]])
for b in boosters]
pred_per_cv = np.array(pred_per_cv)
# ブースター間で可能性予測値の平均を取る
pred_average = pred_per_cv.mean(axis=0)
# 最も可能性の高いカテゴリ番号を取得
pred = np.argmax(pred_average, axis=1)
# 個人的にconfusion matrixが見やすくて好きなので表示
cm = confusion_matrix(df_test["species_number"], pred)
print(cm)
[[10 0 0]
[ 0 9 0]
[ 0 0 11]]
正しく予測できました! lightgbm.cv()
を使うことで、複雑なsplitを伴うCVでも、少ない行数で簡単に記述できます。
関連する参考記事
スクラッチと比較して、cv()
利用のメリット・デメリットについて述べられています。