【2024年2月版】Elixir で XGBoost = 勾配ブースティング回帰木を実行する EXGBoost

## はじめに

以前の記事で、 Livebook 上で XGBoost を実行しました

https://qiita.com/RyoWakabayashi/items/e71972611807a55cf63e#comment-28b1a15675560efb233d

@ta_to_jp さんのコメントでバージョンアップについてお知らせいただいたので、最新版で改めて実行してみました

https://qiita.com/RyoWakabayashi/items/e71972611807a55cf63e#comment-28b1a15675560efb233d

最新版の場合、結構色々変わっていたため、記事の更新ではなく別記事を書くことにしました
また、パラメータ調整で少し改善しています

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/exgboost/titanic.livemd

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/exgboost/titanic_optimized.livemd

## 実行環境

今回はイテレーション数が多く、ローカルで実行すると時間がかかりすぎるため、以前紹介した手法を使い、 Google Colab 上で Livebook を実行します

https://qiita.com/RyoWakabayashi/items/51ff0f44065e958fc490

当時からバージョン等が変わっているため、最新版はこちらのノートブックを参照してください

https://github.com/RyoWakabayashi/elixir-learning/blob/main/colab/Livebook.ipynb

せっかくなので Colab Pro で T4 GPU 、ハイメモリを使いました

![スクリーンショット 2024-02-11 17.40.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/4f92314b-3554-c30c-bc47-e053a4d1fe3d.png)

そこまでしなくても大丈夫だとは思います

## セットアップ

以前と同じパッケージの最新版をインストールします

```elixir
Mix.install([
  {:exgboost, "~> 0.5"},
  {:explorer, "~> 0.8"},
  {:nx, "~> 0.6"},
  {:kino, "~> 0.12"},
  {:kino_vega_lite, "~> 0.1"}
])
```

エイリアスとマクロの設定をします

```elixir
alias Explorer.DataFrame
alias Explorer.Series
require Explorer.DataFrame
```

## データ入力

トレーニング用データ、テスト用データのファイルをアップロードします

```elixir
train_data_input = Kino.Input.file("train data")
```

```elixir
test_data_input = Kino.Input.file("test data")
```

![スクリーンショット 2024-02-11 17.46.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/3e823b38-078c-95de-a2e7-daead7ca38f3.png)

データを表形式で表示します

```elixir
train_data =
  train_data_input
  |> Kino.Input.read()
  |> Map.get(:file_ref)
  |> Kino.Input.file_path()
  |> DataFrame.from_csv!()

Kino.DataTable.new(train_data)
```

![スクリーンショット 2024-02-11 17.47.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/72ded0de-5199-25e2-03cd-8532f42e565f.png)

```elixir
test_data =
  test_data_input
  |> Kino.Input.read()
  |> Map.get(:file_ref)
  |> Kino.Input.file_path()
  |> DataFrame.from_csv!()

Kino.DataTable.new(test_data)
```

![スクリーンショット 2024-02-11 17.50.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1e3b1f1a-0251-eb51-5c91-102bfe00c308.png)

## 前処理

前処理用のモジュールを定義します

```elixir
defmodule Preprocess do
  defp load_csv(kino_input) do
    kino_input
    |> Kino.Input.read()
    |> Map.get(:file_ref)
    |> Kino.Input.file_path()
    |> DataFrame.from_csv!()
  end

  defp fill_empty(data, fill_map) do
    fill_map
    |> Enum.reduce(data, fn {column_name, fill_value}, acc ->
      DataFrame.put(
        acc,
        column_name,
        Series.fill_missing(data[column_name], fill_value)
      )
    end)
  end

  defp replace_dummy(data, columns_names) do
    data
    |> DataFrame.dummies(columns_names)
    |> DataFrame.concat_columns(DataFrame.discard(data, columns_names))
  end

  defp to_tensor(data) do
    data
    |> DataFrame.to_columns()
    |> Map.values()
    |> Nx.tensor()
    |> Nx.transpose()
  end

  def process(kino_input, id_key, label_key) do
    data_org = load_csv(kino_input)

    id_list = Series.to_list(data_org[id_key])

    has_label_key =
      data_org
      |> DataFrame.names()
      |> Enum.member?(label_key)

    labels =
      if has_label_key do
        Series.to_tensor(data_org[label_key])
      else
        nil
      end

    inputs =
      if has_label_key do
        DataFrame.discard(data_org, [label_key])
      else
        data_org
      end
      |> DataFrame.discard([id_key, "Cabin", "Name", "Ticket"])
      |> fill_empty(%{"Age" => 0.0, "Embarked" => "S", "Fare" => 0.0})
      |> replace_dummy(["Sex", "Embarked"])
      |> to_tensor()

    {id_list, labels, inputs}
  end
end
```

各データに前処理を施します

```elixir
{
  train_id_list,
  train_labels,
  train_inputs
} = Preprocess.process(train_data_input, "PassengerId", "Survived")
```

```elixir
{
  test_id_list,
  nil,
  test_inputs
} = Preprocess.process(test_data_input, "PassengerId", "Survived")
```

## トレーニング

トレーニング状況を見るためのグラフを用意します
（この時点では空）

```elixir
loss_plot =
  VegaLite.new(width: 700)
  |> VegaLite.mark(:line)
  |> VegaLite.encode_field(:x, "step", type: :quantitative)
  |> VegaLite.encode_field(:y, "mlogloss", type: :quantitative)
  |> Kino.VegaLite.new()
```

各イテレーションで実行するコールバックを用意します

```elixir
step_callback =
  EXGBoost.Training.Callback.new(
    :after_iteration,
    fn state ->
      if rem(state.iteration, 1000) == 0 do
        Kino.VegaLite.push(
          loss_plot,
          %{"step" => state.iteration, "mlogloss" => state.metrics["training"]["mlogloss"]}
        )
      end

      state
    end,
    :loss_plot
  )
```

EXGBoost v0.3 のときと比べて、以下の点を変更しています

- [EXGBoost.Training.Callback.new](https://hexdocs.pm/exgboost/0.5.0/EXGBoost.Training.Callback.html#new/4) の第3引数 `name` (コールバックを識別するための名前) に `:loss_plot` を指定
- `if rem(state.iteration, 1000) == 0 do` のグラフにプロットする感覚を 20 から 1000 に増加（イテレーション数が増えるため）

トレーニングを実行しますトレーニングが 10,000 回で終わらないため、最大 200,000 回まで実行するようにしています
また、 `learning_rates`（学習率）を 0.1 で固定します（デフォルトは 0.3）

:::note warn
末尾に `Kino.nothing()` を追加して、トレーニングの結果の回帰木を表示しないようにしています
バグかスペック不足のため、表示しようとするとエラーになるためです
:::

```elixir
booster =
  EXGBoost.train(train_inputs, train_labels,
    num_class: 2,
    objective: :multi_softprob,
    num_boost_rounds: 200000,
    learning_rates: fn _ -> 0.1 end,
    max_depth: 6,
    early_stopping_rounds: 10,
    evals: [{train_inputs, train_labels, "training"}],
    callbacks: [step_callback]
  )

Kino.nothing()
```

## テストデータに対する推論

学習した回帰木を使って、テストデータを推論します

```elixir
preds = EXGBoost.predict(booster, test_inputs) |> Nx.argmax(axis: -1)
```

推論結果を提出用に整形し、表形式で表示します

```elixir
results =
  preds
  |> Nx.to_flat_list()
  |> Enum.map(&round(&1))
  |> then(
    &%{
      "PassengerId" => test_id_list,
      "Survived" => &1
    }
  )
  |> DataFrame.new()

Kino.DataTable.new(results)
```

推論結果をダウンロードします

```elixir
results
|> DataFrame.dump_csv!()
|> then(&Kino.Download.new(fn -> &1 end, filename: "result.csv"))
```

結果は以下のようになりました

イテレーション数: 56,216

トレーニング中のロス推移:
![visualization (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/92b29815-3111-8497-be3e-5f79dd44cb1d.png)

提出結果のスコア: 0.74162

前回は 0.72248 だったので、 2 % ほど改善しています

学習率を下げたことで、微改善したのだと思われます

## 前処理の改善

前処理を以下のように変更します

:::note info
変更内容は[前回の記事を参照](https://qiita.com/RyoWakabayashi/items/e71972611807a55cf63e#comment-28b1a15675560efb233d)してください
:::

```elixir
defmodule PreProcess do
  def load_csv(kino_input) do
    kino_input
    |> Kino.Input.read()
    |> Map.get(:file_ref)
    |> Kino.Input.file_path()
    |> DataFrame.from_csv!()
  end

  def fill_empty(data, fill_map) do
    fill_map
    |> Enum.reduce(data, fn {column_name, fill_value}, acc ->
      fill_value =
        if fill_value == :median do
          Series.median(data[column_name])
        else
          fill_value
        end

      DataFrame.put(
        acc,
        column_name,
        Series.fill_missing(data[column_name], fill_value)
      )
    end)
  end

  def replace_dummy(data, columns_names) do
    data
    |> DataFrame.dummies(columns_names)
    |> DataFrame.concat_columns(DataFrame.discard(data, columns_names))
  end

  def to_tensor(data) do
    data
    |> DataFrame.to_columns()
    |> Map.values()
    |> Nx.tensor()
    |> Nx.transpose()
  end

  def process(kino_input, id_key, label_key, followers_df) do
    data_org = load_csv(kino_input)

    id_list = Series.to_list(data_org[id_key])

    has_label_key =
      data_org
      |> DataFrame.names()
      |> Enum.member?(label_key)

    labels =
      if has_label_key do
        Series.to_tensor(data_org[label_key])
      else
        nil
      end

    inputs =
      if has_label_key do
        DataFrame.discard(data_org, [id_key, label_key])
      else
        DataFrame.discard(data_org, [id_key])
      end
      |> DataFrame.mutate(
        prob_child:
          col("Name") |> contains("Master") or
            (col("Name") |> contains("Miss") and
               col("Parch") > 0)
      )

    filled_age =
      [
        Series.to_list(inputs["Age"]),
        Series.to_list(inputs["prob_child"])
      ]
      |> Enum.zip()
      |> Enum.map(fn
        {nil, true} ->
          9

        {nil, false} ->
          30

        {age, _prob_child} ->
          age
      end)
      |> Series.from_list()

    inputs =
      inputs
      |> DataFrame.put("Age", filled_age)
      |> DataFrame.join(followers_df, how: :left)
      |> fill_empty(%{"followers" => 0, "Embarked" => "S", "Fare" => :median})
      |> replace_dummy(["Embarked", "Pclass"])
      |> DataFrame.mutate(is_man: col("Sex") == "male")
      |> DataFrame.mutate(fare_group: (col("Fare") / 50) |> floor())
      |> DataFrame.mutate(age_group: (col("Age") / 10) |> floor())
      |> DataFrame.discard(["Cabin", "Name", "Ticket", "Sex", "Fare", "Age", "SibSp", "Parch"])
      |> to_tensor()

    {id_list, labels, inputs}
  end
end
```

トレーニング時のハイパーパラメータを以下のように変更します

```elixir
booster =
  EXGBoost.train(train_inputs, train_labels,
    num_class: 2,
    objective: :multi_softprob,
    num_boost_rounds: 100000,
    learning_rates: fn _ -> 0.1 end,
    max_depth: 6,
    early_stopping_rounds: 10,
    evals: [{train_inputs, train_labels, "training"}],
    callbacks: [step_callback]
  )

Kino.nothing()
```

結果は以下のようになりました

イテレーション数: 41,602

トレーニング中のロス推移:
![visualization (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/92b29815-3111-8497-be3e-5f79dd44cb1d.png)

提出結果のスコア: 0.75358

前回は 0.75119 だったので、こちらも 2 % ほど改善しています

## まとめ

最新版を使い、学習率を下げることでスコアが微増しました

また、最初はローカルで実行してみましたが、あまりに遅いため Google Colab 上で実行することになりました

やはり Google Colab は強力なプラットフォームですね