LoginSignup
8
4

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

Last updated at Posted at 2023-09-05

はじめに

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

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

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

先駆者 @piacerex さん

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

データの準備

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

アカウントの作成

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

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

データのダウンロード

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

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

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

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

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

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

セットアップ

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

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

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

alias Explorer.DataFrame
alias Explorer.Series
require Explorer.DataFrame

データのロード

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

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

スクリーンショット 2023-09-04 15.22.43.png

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

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

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

スクリーンショット 2023-09-04 15.25.13.png

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

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

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

前処理

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

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

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
{
  train_id_list,
  train_labels,
  train_inputs
} = Preprocess.process(train_data_input, "PassengerId", "Survived")

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

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

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

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

トレーニング

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

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

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

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

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

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

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 を実行します

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

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

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

スクリーンショット 2023-09-04 15.43.39.png

推論

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

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

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

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

#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 と合わせてデータフレーム化します

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

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

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

スクリーンショット 2023-09-04 15.59.54.png

Kaggle への提出

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

結果は 0.72248 でした

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

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

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

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

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

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

改善した前処理

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

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

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

Kino.DataTable.new(full_data)
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)
{
  train_id_list,
  train_labels,
  train_inputs
} = PreProcess.process(train_data_input, "PassengerId", "Survived", followers_df)
{
  test_id_list,
  nil,
  test_inputs
} = PreProcess.process(test_data_input, "PassengerId", "Survived", followers_df)

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

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

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

%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 よりも優れていましたが、場合によって変わると思うので、他にも様々なデータで使ってみたいと思います

8
4
6

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4