LightGBMのcv()関数を使い、任意のデータ分割方法で交差検証を行う

# 目的
1. `LightGBM`で交差検証を行う時に、`cv()`関数を使いサクッと書く。
2. K-Fold系以外のデータ分割方法を使用する。

# はじめに
`LightGBM`のTraining APIには[`cv()`関数](https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.cv.html)があり、`for`ループを使うスクラッチよりも手短に交差検証（以下CV）を記述できます。
一方で、CVのデータ分割には、様々な方法が`sklearn.model_selection`モジュールに用意されています（[`sklearn`の`model_selection`解説記事](https://scikit-learn.org/stable/auto_examples/model_selection/plot_cv_indices.html#sphx-glr-auto-examples-model-selection-plot-cv-indices-py)）。これらはすべて`cv()`に適用可能です。
`cv()`そのものには、`nfold`や`stratified`、`shuffle`といったデータ分割のパラメータがあり、K-FoldやStratified K-Foldなどはこれらを使うことで実装できます。
他のsplitterに関しては、`cv()`には`folds`というパラメータがあり、ここに`sklearn.model_selection`のクラスを突っ込むと、データ分割に関する **他のパラメータに優先して** この設定を使用し、分割を行ってくれます。自作splitterも使用可能です。

# 手順
`iris`データセットを例に挙げ、分割方法は変わりどころで[`LeaveOneGroupOut`](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.LeaveOneGroupOut.html) (LOGO)を使ってみます。
流れは次の通り。

1. モジュール、データセットを用意し、図示してデータの並びを確認する
2. LOGO用グループの作成
3. 訓練データとテストデータに分割
4. CVで学習する
5. 出力されたものの確認
6. テストデータで検証

## 1. モジュール、データセットの用意と図示
```py
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列を整数に変換します。

```py
df["species_number"] = LabelEncoder().fit_transform(df["species"])
```

irisデータはspeciesごとに並んでいるので、訓練データとテストデータの分割には注意が必要です。

```py
# speciesの並びを図示
plt.scatter(df.index, df["species"])
plt.xlabel("index")
plt.ylabel("species")
```

![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191060/bab59eaa-1d89-bcaf-1076-e53aa86f07ca.png)

## 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で割った余りをグループ番号とします。
```py
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`ですが、明示的に指定します。

```py
# 訓練データとテストデータを分割
df_train, df_test = train_test_split(df, test_size=0.2, shuffle=True, random_state=42)
```

speciesがきちんと分割されたか、一応確認します。

```py
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で学習する
1. `LightGBM`のパラメータを設定
2. データセットの作成
3. LOGO分割の設定
4. `cv()`関数の実行

の順で行います。パラメータは大まかにはCVではないLightGBMとほぼ変わりません。

```py
# 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()ドキュメント](https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.cv.html)には

> generator or iterator of (train_idx, test_idx) tuples, scikit-learn splitter object

とありますが、`sklearn`のsplitterを使う場合、書き方には2種類があります。

1. インスタンス作成時にすべてのパラメータを代入するsplitter、例えば`KFold`、`StratifiedKFold`、`ShuffleSplit`、`LeaveOneOut`、`LeavePOut`、`TimeSeriesSplit`などの場合は、iterator of (train_idx, test_idx) tuplesおよびscikit-learn splitter objectの両方を使用できます。すなわち、
    ```py
    kf = KFold(n_splits=3)
    ```
    としてインスタンスを作成した上で、
    ```py
    cv_results = lgb.cv(
        ...
        nfold=kf.split(X),  # (train_idx, test_idx) タプルのイテレータを代入
        ...
    )
    ```
    でも
    ```py
    cv_results = lgb.cv(
        ...
        nfold=kf,  # splitterを直接代入
        ...
    )
    ```
    でもどちらでも動きます。（冗長ですが）2番目の場合、`X`は要素数しか重要でなく、CV用データと長さが同じものであれば、例えば`numpy.zeros(len(df_train))`でも動きます。

2. 今回のように、インスタンス作成後splitする時にさらにパラメータの代入が必要な、`LeaveOneGroupOut`、`LeavePGroupsOut`、`GroupShuffleSplit`などの場合は、iterator of (train_idx, test_idx) tuplesのみ、すなわち、
   ```py
   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`を取り出せるようになります。
```py
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`オブジェクト](https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.CVBooster.html)と、最適解に至るまでの試行のmetrics値が格納されています。`CVBooster`オブジェクトからは、best iterationやCVの各試行での`Booster`を取得できます。

## 6. テストデータで検証

CVの結果を使ってテストデータの予測をしてみましょう。
CVのsplitごとのブースターでテストデータを予測し、可能性予測値の平均で最大のものを最終的なクラス予測値とします（この部分のコードをもっとスマートに書く方法がありましたらご教授ください）。

```py
# 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()`利用のメリット・デメリットについて述べられています。

https://blog.amedama.jp/entry/lightgbm-cv-implementation