Elixir で XGBoost = 勾配ブースティング回帰木を実行する EXGBoost

## はじめに

:::note info
2024年2月版を書きました

https://qiita.com/RyoWakabayashi/items/1fcbccb5611c70c9ce16
:::

XGBoost は機械学習アルゴリズムの一つで、 Kaggle でも上位を取っている手法です

Elixir でも XGBoost 用のモジュール EXGBoost が作られています

https://github.com/acalejos/exgboost

そこで、今回は以前 CNN で学習したタイタニックの生存者予測を XGBoost で学習してみます

https://qiita.com/RyoWakabayashi/items/87047bbccd923c3dc328

先駆者 @piacerex さん

https://twitter.com/piacere_ex/status/1696898709605241139?s=20

また、今回も Livebook で実装していきます

https://livebook.dev/

## データの準備

Kaggle からタイタニックのデータをダウンロードします

### アカウントの作成

Kaggle のアカウントを作成します

トップページの右上「Register」をクリックし、指示に従っていけば簡単に作成できます

https://www.kaggle.com/

### データのダウンロード

以下のページ右下「Download All」をクリックし、 taitanic.zip をダウンロードします

https://www.kaggle.com/competitions/titanic/data

tainanic.zip を展開して出てくる以下の CSV ファイルを使用します

- train.csv: 学習データ
- test.csv: テストデータ

## XGBoost によるトレーニングの実装

まず、『「Elixirで機械学習に初挑戦」をやってみた（中編）』でやったのと同じ前処理で実装してみます

https://qiita.com/RyoWakabayashi/items/87047bbccd923c3dc328

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

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

### セットアップ

依存モジュールをインストールします

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

- [EXGBoost](https://github.com/acalejos/exgboost): XGBoost
- [Explorer](https://github.com/elixir-explorer/explorer): データ解析
- [Nx](https://github.com/elixir-nx/nx/tree/main/nx): 行列演算
- [Kino](https://github.com/livebook-dev/kino): Livebook の UI/UX
- [KinoVegaLite](https://github.com/livebook-dev/kino_vega_lite): グラフ描画

エイリアス等の設定をします

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

### データのロード

以下のコードを実行すると、ファイルアップロード用のフォームが表示されます

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

![スクリーンショット 2023-09-04 15.22.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/93060017-80d5-5c48-fc6c-a97ca750d0ee.png)

ここに Kaggle からダウンロードした  train.csv をドラッグ&ドロップしてアップロードします

同様に test.csv についてもアップロードします

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

![スクリーンショット 2023-09-04 15.25.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f9b8a179-20b5-0311-5f8b-fc2be617b831.png)

train.csv をロードしてテーブル表示します

```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)
```

![スクリーンショット 2023-09-04 15.26.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7ef60f6f-4b1f-3ba0-08fa-bc494ccfe722.png)

test.csv もロードしてテーブル表示します

### 前処理

これによって欠損値補完や、目的変数、説明変数の抽出を行っています

```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)
```

![スクリーンショット 2023-09-04 15.27.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/520e5071-dfb7-5883-bf44-e6ced5398676.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")
```

`train_id_list` が各行（乗客）の ID 、 train_labels が目的変数 = 生存フラグ、 train_inputs が説明変数 = 年齢等の値です

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

テストデータに対しても同様の前処理を施します

タイタニックのテストデータは正解のラベルを含んでいないので、目的変数は `nil` にしています

### トレーニング

XGBoost によるトレーニングを実行します

CNN のときと同様、トレーニングの状況をグラフで確認できるようにしてみます

トレーニングが進行すれば回帰残差はどんどん小さくなるはずなので、これをプロットするための枠を用意します

```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()
```

![スクリーンショット 2023-09-04 15.32.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/6120e5ae-2568-3cfa-c495-9246f1b37670.png)

トレーニングの反復毎に呼び出されるコールバック関数として、グラフを描画する関数を用意します

毎回描画すると処理が重くなりすぎるため、 20 回に 1 回だけ実行します

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

    {:cont, state}
  end)
```

`EXGBoost.train` で XGBoost を実行します

```elixir
booster =
  EXGBoost.train(train_inputs, train_labels,
    num_class: 2,
    objective: :multi_softprob,
    num_boost_rounds: 10000,
    early_stopping_rounds: 10,
    evals: [{train_inputs, train_labels, "training"}],
    callbacks: [step_callback]
  )
```

`early_stopping_rounds: 10` によって、10回連続で残差が改善されなかった場合に早期終了するよう指定します

また、 `callbacks: [step_callback]` によってグラフ描画用関数を指定します

実行すると、用意しておいたグラフ用の枠の中に、残差が瞬く間に小さくなる様子が表示されます

![exgboost.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/4fd1b354-6b45-bf33-ca46-5e3ad115c190.gif)

また、実行結果には最終的な反復回数と、最小の残差が表示されます

今回は早期終了せず、最後までトレーニングされました

![スクリーンショット 2023-09-04 15.43.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/8a8842bf-f70d-72ad-f4bc-b3c607bdfabb.png)

### 推論

`EXGBoost.predict` にトレーニング済みのパラメータとテストデータの説明変数を渡すことで、テストデータの目的変数の値を推論します

`EXGBoost.predict` の実行結果は乗客毎に `[生存しなかった確率, 生存した確率]` の形で返ってくるため、これを `Nx.argmax` により、生存したかどうかのフラグに変換します

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

実行結果は以下のようになります

```elixir
#Nx.Tensor<
  s64[418]
  [0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, ...]
>
```

結果を乗客の ID と合わせてデータフレーム化します

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

Kino.DataTable.new(results)
```

![スクリーンショット 2023-09-04 15.54.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b403a5c1-606e-6216-4e20-23035967c8d5.png)

データフレームを CSV としてダウンロードします

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

![スクリーンショット 2023-09-04 15.59.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5ca22830-f8c6-63e3-25e7-8eac8b7c5e28.png)

### Kaggle への提出

ダウンロードした CSV を Kaggle に提出します

結果は 0.72248 でした

同じ前処理をしたときの CNN が 0.77511 なので、今回の例では CNN より精度が低い結果となりました

## 前処理を改善した XGBoost の実行

次に『「Elixirで機械学習に初挑戦」をやってみた（後編）』でやったのと同じように、前処理を改善した上で XGBoost を実行してみます

https://qiita.com/RyoWakabayashi/items/5f08ba02d25524b8a256

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

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

### セットアップ、データロード

セットアップ、データロードは同じです

### 改善した前処理

データ分析の結果、改善した前処理の実装です

チケット番号から同乗者数などの項目を付加しています

```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
full_data =
  train_data
  |> DataFrame.discard("Survived")
  |> DataFrame.concat_rows(test_data)

Kino.DataTable.new(full_data)
```

```elixir
followers_df =
  full_data["Ticket"]
  |> Series.frequencies()
  |> DataFrame.rename(["Ticket", "followers"])
  |> DataFrame.mutate(followers: followers - 1)
  |> DataFrame.filter(col("Ticket") != "LINE")

Kino.DataTable.new(followers_df)
```

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

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

### 前処理改善時のトレーニング

トレーニングは同じように実行します

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

```elixir
%EXGBoost.Booster{
  ref: #Reference<0.355326591.2837577729.195022>,
  best_iteration: 4205,
  best_score: 0.26485201133872904
}
```

今回は途中で早期終了が発生しています

### 前処理改善時の推論結果

Kaggle に提出した結果、精度は 0.75119 になりました

同じ前処理で CNN を使ったときは 0.78947 だったので、こちらも CNN に軍配が上がりました

## まとめ

コールバック関数を使うことで、 CNN のときと同様にグラフ出力することができました

また、早期終了も設定できるので、過学習対策も可能です

今回のデータ、前処理、設定では CNN の方が XGBoost よりも優れていましたが、場合によって変わると思うので、他にも様々なデータで使ってみたいと思います