tidymodelsでlightgbmをやるときの覚書

## はじめに

R のtidymodelsでlightgbmをするときの覚書を残します。いまいち分かっていない点も所々あるのですが、まあわかってないなりにまとめておくことが重要かな、と思ってとりあえず書きました。tidymodelsの勉強という側面もあるので、ステップバイステップで進めています。

## パッケージの紹介
`tidymodels`, `lightgbm`, `bonsai`を使います。

### bonsai?

`tidymodels`を通してツリー系のモデルを建てたい場合、`bonsai`を使います。枝葉を適宜に間引くことで美しい木を育てる、という面から名付けたのでしょうか。オシャレな名前だなあと思います。

そして、Change logに`num_leaves`のチューニングが出来るようになったよ、と書いてあったので試してみようかな～となった次第です。`set_engine()`で指定するとのことです。
> With the newest version of each of dials, parsnip, and bonsai installed, tune this argument by marking the `num_leaves` engine argument for tuning when defining your model specification:

```r
boost_tree() %>% set_engine("lightgbm", num_threads = x)
```
## コード

いつものようにirisの判別をしていきます。使うライブラリは以下のものです。
```r
library(tidyverse)    #データ処理、パイプ演算子のため
library(tidymodels)   #機械学習用、dialsも含まれている
library(lightgbm)     #lightGBMをするため
library(bonsai)       #lightGBMをtidymodelsを通して使うため
library(doParallel)   #並列処理に使う
```
まずデータを分割します。strataは対象となる変数が均等になるように分割する指示となります。irisであれば、学習データや検証データに特定の種が多いor少ないといった偏りを防ぐため設定します。
今回は**iris_train**を学習用、**iris_test**を検証用データをします。
```r
set.seed(42)
data("iris")

iris_split <- initial_split(iris, prop = 0.8, strata = Species)
iris_train <- training(iris_split)
iris_test <- testing(iris_split)
```
次に説明変数の前処理を行います。今回のようにlightGBMではぶっちゃけ不要とのことですが、すべての数字列をノーマライズします。この`recipe()`では、ほかにも欠損値をどう扱うか、因子型に対しダミー変数化するか、などの前処理を行うことが出来ます。
```r
iris_recipe <- recipe(Species ~ ., data = iris_split) %>%
  step_normalize(all_numeric_predictors())
```

続いて、どのようなモデルの仕様を決めていきます。今回はlightGBMを使っていくので`boost_tree()`を使い、その中でどのパラメーターをチューニングするかを設定します。チューニングしたいパラメーターは`tune()`を引数とします。
今回は、公式で**best first**だ、としている以下の３つのパラメーターに絞ります。

- **num_leaves**：ツリーモデルの複雑性をコントロールするメインパラメーターで、分岐の終着点の数です。**max_depth**との兼ね合いで決めるとのことです。あまり大きすぎると過学習する危険性があり、2^(max_depth)より小さい値にすることが推奨されています。
- **min_data_in_leaf**：葉のデータの最小数を設定します。tidymodelsでは**min_n**で指定します。これはlightGBMのようなleaf-wise treeでの過学習を抑えるために重要なパラメーターです。大きな値にすると過学習を防ぐ（ツリーが深くなりすぎる）ことを防ぐことが出来ますが、一方で過小適合となる可能性があります。
- **max_depth**：ツリーの深さを制限するパラメーターです。tidymodelsでは**tree_depth**で指定します。ここの値を明示的に設定する場合は、**num_leaves**も明示して2^(max_depth)以下になるように設定することが良いとのことです。

ここで、**num_leaves**は`set_engine()`で設定します。なぜでしょうね。

https://lightgbm.readthedocs.io/en/latest/Parameters-Tuning.html

https://knknkn.hatenablog.com/entry/2021/06/29/125226#max_depth

```r
lightgbm_spec <-
  boost_tree(min_n = tune(),
             tree_depth = tune(),
  ) %>%
  set_engine("lightgbm", num_leaves = tune()) %>%
  set_mode("classification")
  ```

つぎにworkflowを立て、モデルとデータを組み合わせます。

今回は、lightGBM決め打ちなのでworkflowを使うメリットがあまり無いように見えますが、複数のモデルで検証する場合はmap関数でまとめて処理することも可能です。bob3さんのサイトのが非常に参考になります。また、今回は後々チューニングした後に再利用するので、ワークフローはやっぱり便利です。

```r
lightgbm_wf <- workflow() %>% 
  add_recipe(iris_recipe) %>% 
  add_model(lightgbm_spec)
```

続いてハイパーパラメータチューニング用のCross validation用のデータを作ります。今回はv = 4を指定したので、4分割のCross validationになります。

```r
cv_folds <- vfold_cv(iris_train, v = 4, strata = Species)
```

ここで、並列処理を設定します。別にこのタイミングでなくても良いのですが、なんとなくこれから時間がかかる処理をするぞ！というタイミングで設定します。

```r
cl <- makePSOCKcluster(parallel::detectCores() - 1)
registerDoParallel(cl)
```

グリッドサーチでハイパーパラメータをチューニングします。ベイズ最適でチューニングする場合は、`tune_bayes()`を使うことが出来ます。grid = 10は、`tune()`で指定したパラメーターを、それぞれ10個ずつチューニングするようにグリッドサーチを行います。大きい数値にすればそれだけ細かくグリッドサーチをしますが、時間がとてもかかるようになります。

```r
res_grid <- tune_grid(lightgbm_wf, cv_folds, grid = 10)
```

ここから結果を確認していきます。とりあえず結果を`auto_plot()`すると、グリッドサーチした結果を確認できます。roc_aucを見ると、なんとなくMinimal node sizeとTree depthを大きくすると精度が良くなるような傾向が見えます。

```r
autoplot(res_grid)
```
![iris_lightgbm.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3526485/cea5762b-6c42-eb35-269c-e1d8f8e07f5a.png)

ハイパーパラメータを確認します。今回はroc_aucが良いものを選びます。
確認すると、numl_leaves < 2^(tree_depth)になっているので、これをそのまま用います。ちなみに、このnuml_leaves < 2^(tree_depth) が満たせなかった場合、どうすればよいのかは分かりません。（明示的に指定するしかないのかな）

```r
best_params <- select_best(res_grid, metric = "roc_auc")

best_params
```
```
# A tibble: 1 × 4
  min_n tree_depth num_leaves .config              
  <int>      <int>      <int> <chr>                
1    39         11         59 Preprocessor1_Model05
```

ここで得られたハイパーパラメータを使ってファイナライズしていきます。`finalize_workflow()`をって、元のワークフローに対し、先ほど求められたパラメーターを設定します。

```r
final_wf <- finalize_workflow(lightgbm_wf, best_params)

final_wf
```
```
══ Workflow ══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
Preprocessor: Recipe
Model: boost_tree()

── Preprocessor ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
1 Recipe Step

• step_normalize()

── Model ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Boosted Tree Model Specification (classification)

Main Arguments:
  min_n = 39
  tree_depth = 11

Engine-Specific Arguments:
  num_leaves = 59

Computational engine: lightgbm 
```
いよいよ大詰め、先ほど作ったワークフローに`last_fit()`を繋げ、モデルを作成します。`last_fit()`はsplitしたデータのみ渡すことが出来ます。学習データを入れ子的にさらに分割し、fitting用のデータとfittingしたデータを検証するためのデータに分けているのかな、と思います。ちなみに、splitしていないデータを渡すとエラーが出ます。
```r
iris_train_split <- initial_split(iris_train, prop = 0.8, strata = Species)

turned_fit <- final_wf %>% 
  last_fit(iris_train_split)
```
最後に、最初に作った検証用データを使って、作成したモデルを評価します。
```r
test_predictions <- predict(turned_fit %>% extract_workflow(), new_data = iris_test)

iris_test %>% select(Species) %>% 
  mutate(pred_Species = test_predictions$.pred_class) %>% 
  conf_mat(truth = Species, estimate = pred_Species)
```
外したのは2個だけなので、まあこんなものなのでしょうか。
```
            Truth
Prediction   setosa versicolor virginica
  setosa         10          0         0
  versicolor      0          9         1
  virginica       0          1         9
```

以上です。

## 参考にした記事

以下の記事を主に参考にしました。本当にありがとうございます。

https://datasciencemore.com/tidymodels-parsnip/

https://qiita.com/kenkenvw/items/d605050a88636cd677e7

https://bob3.hatenablog.com/entry/2023/04/15/214755

https://lightgbm.readthedocs.io/en/latest/Parameters-Tuning.html

https://knknkn.hatenablog.com/entry/2021/06/29/125226#max_depth

https://direct.gihyo.jp/view/item/000000002925