@piacerex さんは Elixir Advent Calendar 2022 の「Eixirで機械学習に初挑戦④:データ処理に強いElixirでKaggle挑戦(前半)…「データ前処理」の基礎編 ※最新Livebook 0.8に対応」という記事にデータ処理をEnum
やMap
で処理しました。Explorerはこのために作れりてますので、その部分の処理を書き直しました。
Kaggle挑戦について、上記の記事を読むことをお勧めします。
準備
データが必要です。このチュートリアルは「タイタニック問題」の挑戦を利用してるので、(Titanic - Machine Learning From Disaster Dataset)[https://www.kaggle.com/competitions/titanic/data]ページの右下にいる「Download All」ボタンをクリックして、保存します。Zip
ファイルの中に3津CSV
ファイルがあります:
-
gender_submission.csv
- サンプルコンペの結果申し込むデータ -
test.csv
- 実験・確認するためのデータ -
train.csv
- 学習データ
ファイルを$HOME/data/titanic
に解凍してください。これで他の挑戦のデータを区別するし、わかり安にパスにアクセンできるためですね。
生データをLivebookに読み込む
Livebook最上部の「Notebook dependencies and setup」で、下記を実行し、ライブラリ群をロードしてください
Mix.install([
- {:csv, "~> 3.0"},
{:exla, "~> 0.4"},
{:axon, "~> 0.3"},
+ {:explorer, "~> 0.4"}, # 追加
+ {:kino, "~> 0.6"} # 追加
])
CSVパーサがExplorerにあるので、いらなくなります。ExplorerとKinoのライブラリーを以前の記事に追加します。
それから、ライブラリ「CSV」でtrain.csvをロードします
+ alias Explorer.DataFrame, as: DF
+ alias Explorer.Series, as: S
+ require Explorer.DataFrame # For DF.mutate
- train_csv_raw =
- File.stream!("train.csv")
+ train_df =
+ DF.from_csv!("data/titanic/train.csv")
- |> CSV.decode!
- |> Enum.to_list
+ Kino.DataTable.new(train_csv_raw)
データ操作しやすくするためにマップ群に変換
リスト内リストのままだと、ヘッダーの列名で値にアクセスするのが不便なので、ヘッダーの列名をキーとするマップ群に変換します
まず、ヘッダーをhdで取り出し、小文字化し、アトム化します
train_df =
DF.from_csv!("data/titanic/train.csv")
+ |> DF.rename_with(& String.downcase(&1))
- train_csv_header =
- train_csv_raw
- |> hd
- |> Enum.map(& 1 |> String.downcase |> String.to_atom)
+ train_df_header = DF.names(train_df)
下記のように、ヘッダーを小文字化/アトム化されたリストにします
ヘッダーがデータフレームの中にアクセスできるので、変数が本当に必要ないです。でも、すべてのヘッダーが小文字に変更することでデータフレームのマクロで使えるようになりますので、データフレームの初期化するところに追加しました。
["passengerid", "survived", "pclass", "name", "sex", "age", "sibsp", "parch", "ticket", "fare",
"cabin", "embarked"]
ヘッダー列名をキーとするマップ群は、ヘッダー以降のデータ群をtlで取り出し、ヘッダーと各データをList.zipでキーワードリスト化し、Enum.intoでマップ化することで作成します
- train_csv_maps =
- train_csv_raw
- |> tl
- |> Enum.map(& List.zip([train_csv_header, &1]) |> Enum.into(%{}))
これで各データをマップとして扱えるので、アクセスしやすくなります
マップに変更することも必要ないです。データフレームで全部処理を簡単にできます。
学習のための最低限の「データ前処理」
まず大前提として、機械学習では、文字列や空白のような数値データ以外を入力として使うことはできないので、空白無の数値データのみに変換する必要があり、これを「データ前処理」で行います(これは、学習データとラベルの両方に必要です)
また、以下2つも「データ前処理」で行います
- データの中には、ラベルと相関性が無い、いわゆる「特徴と言えないデータ」が存在し得るので、その列の削除
- ID/ラベル/学習データはモデル学習の際に別データとする必要があるため、分離
なお、本講義回では、学習のための最低限の「データ前処理」のみ行い、精度向上のための工夫は次回に預けます
①空白値の確認
まず、IDやラベルも含め、空白値をピックアップし、集計します
- train_csv_maps
- |> Enum.flat_map(fn map -> Map.filter(map, & elem(&1, 1) == "") |> Map.keys end)
- |> Enum.reject(& &1 == [])
- |> Enum.frequencies
+ train_headers
+ |> Enum.reduce(%{}, fn name, acc ->
+ count = DF.pull(train_df, name) |> S.is_nil() |> S.sum()
+ if (count > 0), do: Map.put(acc, name, count), else: acc
+ end)
結果がデータフレームではないと、ちょっと複雑になります。パイプラインでできませんでした。
これは何をすると、それぞれの列(フィールド)に:
- シリーズを取得して
- それぞれの値が
nil
ならtrue
、nil
以外ならfalse
の新しいシリーズを作成して -
true
の合計を数して - 数が
0
より以上ならマップにその結果をput
する
以下の列に空白値があるようです
> %{"Age" => 177, "Cabin" => 687, "Embarked" => 2}
これらのうち、欠損値の補完が必要か、それとも列自体が不要かをこの後、判断します
なお、「データ前処理」は、学習データだけで無く、検証データや未知データに対しても同じ処理を行う必要があるため、count_missingsで関数化しておきます
defmodule Pre do
- def count_missings(datas) do
+ def count_missings(df) do
- datas
+ DF.names(df)
- |> Enum.flat_map(fn map -> Map.filter(map, &(elem(&1, 1) == "")) |> Map.keys end)
- |> Enum.reject(&(&1 == []))
- |> Enum.frequencies
+ |> Enum.reduce(%{}, fn name, acc ->
+ count = DF.pull(df, name) |> S.is_nil() |> S.sum()
+ if (count > 0), do: Map.put(acc, name, count), else: acc
+ end)
end
end
end
下記のように呼び出します
- train_csv_maps
+ train_df
|> Pre.count_missings
②ID/ラベル/学習データを分離
ID/ラベル/学習データの分離は、それぞれの列をマップから抜き出すseparateという関数で行います
うち、ラベルについては、この段階で行列化しておきますが、以下2つの操作が必要です
Nx.tensorでの行列化できる値は小数だが、ラベルは整数文字列なので、整数の後ろに「.0」を付加し、String.to_floatすることで小数化
モデルに入力できるよう、「2次元行列のリスト」に変換する必要があるが、ラベル群は単なるリストのため、2次元行> 列で包むために、2重リスト[[~]]で囲んだ上で、Nx.tensorに渡す
defmodule Pre do
…
- def separate(datas, id, label) do
+ def separate(df, id, label) do
{
- datas |> Enum.map(& Map.get(&1, id)),
+ DF.pull(df, id) |> S.to_list(),
- datas |> Enum.map(& [[String.to_float("#{Map.get(&1, label)}.0")]] |> Nx.tensor),
+ DF.pull(df, label)
+ |> S.cast(:float)
+ |> S.to_list
+ |> Enum.map(& [[&1]] |> Nx.tensor),
- datas |> Enum.map(& Map.drop(&1, [id, label]))
+ df |> DF.discard([id, label]) |> DF.to_rows
}
end
end
ラベルの作成することについて、DF.pull(df, "label") |> S.cast(:float) |> S.to_tensor
でできるはずと思いましたが、この行列のリストになってしまいました:
#Nx.Tensor<
f64[891]
[0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, ...]
>
トレーニングの関数に渡すフォーマットは以下の2次元行列のリストの形が必要です:
[
#Nx.Tensor<
f32[1][1]
[
[0.0]
]
>,
#Nx.Tensor<
f32[1][1]
[
[1.0]
]
>,
...
]
{trains_csv_ids, train_csv_labels, train_csv_maps} =
- train_csv_all_maps
+ train_df
- |> Pre.separate(:passengerid, :survived)
+ |> Pre.separate("passengerid", "survived")
ID/ラベル/学習データに分離され、ラベルは「2次元行列のリスト」に変換されたことが確認できます
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, ...],
[
#Nx.Tensor<
f32[1][1]
[
[0.0]
]
>,
#Nx.Tensor<
f32[1][1]
[
...
]
>,
...
],
[
%{
"age" => 22.0,
"cabin" => nil,
"embarked" => "S",
"fare" => 7.25,
"name" => "Braund, Mr. Owen Harris",
"parch" => 0,
"pclass" => 3,
"sex" => "male",
"sibsp" => 1,
"ticket" => "A/5 21171"
},
...
]
③特徴とならない列の削除
タイタニック問題で「特徴と言えないデータ」に該当すると思われるのは以下です
- cabin:部屋番号
- 部屋番号は、生存率にとても高い相関性を持っているはずですが、Cabinは891件中、687件と大量のデータが欠> 損しているため、使い物にならないと判断し、削除
- name:乗客名
- 乗客名は、全員が異なり、生存率にも無関係と思われる
- 名字が同じで、チケット番号が近い or 部屋が近い等であれば、家族乗船の可能性があり、家族全員がボートに乗れるまで待ったとき生存率が低くなる、といった仮説は考えられるが、いったん削除
- ticket:チケット番号
- チケット番号そのものは、生存率に無関係と思われる
- 近い番号の方が、生存率の高い/低い部屋番号にまとまって配置されたという可能性は考えられるが、憶測の域を出ないので、いったん削除
これら列の削除をdropという関数で行います
defmodule Pre do
…
- def drop(datas, keys) do
+ def drop(df, keys) do
- datas
- |> Enum.map(& Map.drop(&1, keys))
+ keys
+ |> Enum.reduce(df, & DF.discard(&2, &1))
end
end
piacereさんはtrain_csv_map
を作成したあとに"Cabin", "Name", "Ticket"をマップから削除しています。私はその前にデータフレームから削除していますので、train_csv_dropped_maps
が要らなくなります。
- train_csv_dropped_maps = train_csv_maps
- |> Pre.drop([:cabin, :name, :ticket])
+ train_df = train_df
+ |> Pre.drop(~w(cabin name ticket))
{trains_csv_ids, train_csv_labels, train_csv_maps} =
train_df
|> Pre.separate("passengerid", "survived")
cabinなどの列が削除されているのが確認できます
[
%{
"Age" => 22.0,
"Embarked" => "S",
"Fare" => 7.25,
"Parch" => 0,
"Pclass" => 3,
"Sex" => "male",
"SibSp" => 1
},
...
]
④欠損値の補完
欠損値のうち、
cabin
は列ごと削除されたので、残るage
とemberked
が補完対象で、これをcount_missings
という関数で行います
defmodule Pre do
def count_missings(datas) do
…
def empty_replace(datas, replaces_map) do
replaces_map
- |> Enum.reduce(datas, fn {key, replace}, acc ->
+ |> Enum.reduce(df, fn {key, replace}, acc ->
- acc
- |> Enum.map(& Map.put(&1, key, String.replace(Map.get(&1, key), ~r/^$/, replace)))
+ DF.put(acc, key, DF.pull(acc, key) |> S.fill_missing(replace))
end)
end
end
DF.pull
とDF.put
でやりたくないですが、DF.mutate
でS.fill_missing
を使う方法はマクロの中にあるので、key
をうまく使えませんでした。
ここではいったん、ageは「0」、emberkedは「S」で補完します(本来、どのような値で補完するかは精度に影響を与えますが、これは次回に)
- train_csv_replaced_maps = train_csv_dropped_maps
- |> Pre.empty_replace(%{embarked: "S", age: "0"})
train_df = train_df
|> Pre.drop(~w(cabin name ticket))
+ |> Pre.empty_replace(%{embarked: "S", age: 0.0})
{trains_csv_ids, train_csv_labels, train_csv_maps} =
train_df
|> Pre.separate("passengerid", "survived")
Pre.empty_replace
の代わりに、以下の方法の方が快適と思います:
train_df = train_df
|> Pre.drop(~w(cabin name ticket))
- |> Pre.empty_replace(%{embarked: "S", age: 0.0})
+ |> DF.mutate(embarked: fill_missing(embarked, "S"))
+ |> DF.mutate(age: fill_missing(age, 0.0))
{trains_csv_ids, train_csv_labels, train_csv_maps} =
train_df
|> Pre.separate("passengerid", "survived")
欠損値が無くなったことを確認できました
...
],
[
%{"age" => 22.0, "embarked" => "S", "fare" => 7.25, "parch" => 0, "pclass" => 3, "sex" => "male", "sibsp" => 1},
%{"age" => 38.0, "embarked" => "C", "fare" => 71.2833, "parch" => 0, "pclass" => 1, "sex" => "female", "sibsp" => 1},
%{"age" => 26.0, "embarked" => "S", "fare" => 7.925, "parch" => 0, "pclass" => 3, "sex" => "female", "sibsp" => 0},
%{"age" => 35.0, "embarked" => "S", "fare" => 53.1, "parch" => 0, "pclass" => 1, "sex" => "female", "sibsp" => 1},
%{"age" => 35.0, "embarked" => "S", "fare" => 8.05, "parch" => 0, "pclass" => 3, "sex" => "male", "sibsp" => 0},
- %{"age" => nil, "embarked" => "Q", "fare" => 8.4583, "parch" => 0, "pclass" => 3, "sex" => "male", "sibsp" => 0},
+ %{"age" => 0.0, "embarked" => "Q", "fare" => 8.4583, "parch" => 0, "pclass" => 3, "sex" => "male", "sibsp" => 0},
%{"age" => 54.0, "embarked" => "S", "fare" => 51.8625, "parch" => 0, "pclass" => 1, "sex" => "male", "sibsp" => 0},
%{"age" => 2.0, "embarked" => "S", "fare" => 21.075, "parch" => 1, "pclass" => 3, "sex" => "male", "sibsp" => 3},
...
%{"age" => 40.0, "embarked" => "S", "fare" => 9.475, "parch" => 0, "pclass" => 3, "sex" => "female", ...},
%{"age" => 27.0, "embarked" => "S", "fare" => 21.0, "parch" => 0, "pclass" => 2, ...},
- %{"age" => nil, "embarked" => "C", "fare" => 7.8958, "parch" => 0, ...},
+ %{"age" => 0.0, "embarked" => "C", "fare" => 7.8958, "parch" => 0, ...},
%{"age" => 3.0, "embarked" => "C", "fare" => 41.5792, ...},
%{"age" => 19.0, "embarked" => "Q", ...},
- %{"age" => nil, ...},
+ %{"age" => 0.0, ...},
%{...},
...
]}
⑤カテゴリ値(種別文字列)を数値に変換
sex
は、「male」と「female」の2種類の文字列で男女を分類していますが、このような種別を表す文字列の数値化は、「male」を「0」、「female」を「1」のように、通番の数値に置換することで実現できますこのような種別を表す文字列を「カテゴリ値」と呼びます
また、通番数値はダミーの値であることから「ダミー変数」と呼ばれることもあります(Pythonのpandasでは
get_dummies
という関数でこの置換を行います)こうした置換自体を「ワンホットエンコーディング」と呼ぶこともあります
さて蘊蓄はこのくらいにして、置換用コードを書いてみましょう
まずは、指定列をカテゴリ値とみなし、ダミー値を振ったマップを生成する
make_dummies
という関数を追加します指定されたキーのみを抽出し、ユニーク化した後、
Enum.with_index
でインデックスを振り出し、Enum.into
でマップ化することで実装しますこのとき、
Enum.with_index
で振り出すインデックスは、整数がデフォルトなので、separete
でラベルに行ったのと同じ方法で小数化します
defmodule Pre do
…
- def make_dummies(datas, key) do
+ def make_dummies(df, key) do
+ series = DF.pull(df, key)
- datas
- |> Enum.map(& Map.get(&1, key))
+ map = S.to_list(series)
|> Enum.uniq
|> Enum.with_index(& {&1, String.to_float("#{&2}.0")})
|> Enum.into(%{})
+ new_series = S.transform(series, & Map.get(map, &1))
+ DF.put(df, key, new_series)
end
まだDF.pull
とDF.put
でデータフレームを更新しています。でも、その間にマップを作成する部分がほぼ同じです。
データフレームはdummies
という関数があります。でも、このget_dummies
と関係ないです。DataFrame.dummies(df, "column")
はcolumn
の列にランダムの0
か1
を入れます。
カテゴリ値であるsexとemberkedのダミー値入りマップを生成してみます
- Pre.make_dummies(train_csv_replaced_maps, :embarked)
+ Pre.make_dummies(train_df, "embarked")
- Pre.make_dummies(train_csv_replaced_maps, :sex)
+ Pre.make_dummies(train_df, "sex")
ダミー値入りマップが生成可能となりました
%{"C" => 1, "Q" => 2, "S" => 0}
%{"female" => 1, "male" => 0}
次に、make_dummiesを使って、指定列をダミー値に入れ替えるto_dummiesという関数を追加します
defmodule Pre do
…
- def to_dummies(datas, train_maps, keys) do
+ def to_dummies(df, keys) do
keys
|> Enum.reduce(datas, fn key, acc ->
- acc
- |> Enum.map(& Map.put(&1, key, make_dummies(train_maps, key)[Map.get(&1, key)]))
+ map = make_dummies(acc, key)
+ DF.pull(acc, key)
+ |> S.transform(& Map.get(map, &1))
+ |> then(& DF.put(acc, key, &1))
end)
end
私のやり方では引き続いてデータフレームを編集しています。だから、分けたtrain_maps
が必要ないですね。直接keys
の列をダミーマップを作成して、データを更新します。
カテゴリ値であるsexとemberkedを置換します
- train_csv_dummied_maps = train_csv_replaced_maps
- |> Pre.to_dummies(train_csv_replaced_maps, [:embarked, :sex])
train_df = train_df
|> Pre.drop(~w(cabin name ticket))
|> DF.mutate(embarked: fill_missing(embarked, "S"))
|> DF.mutate(age: fill_missing(age, 0.0))
+ |> Pre.to_dummies(["embarked", "sex"])
{trains_csv_ids, train_csv_labels, train_csv_maps} =
train_df
|> Pre.separate("passengerid", "survived")
カテゴリ値が数値に置換されました
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, ...],
[
#Nx.Tensor<
f32[1][1]
[
[0.0]
]
>,
...
#Nx.Tensor<
f32[1][1]
[
...
]
>,
...
],
[
%{"age" => 22.0, "embarked" => 0, "fare" => 7.25, "parch" => 0, "pclass" => 3, "sex" => 0, "sibsp" => 1},
%{"age" => 38.0, "embarked" => 1, "fare" => 71.2833, "parch" => 0, "pclass" => 1, "sex" => 1, "sibsp" => 1},
%{"age" => 26.0, "embarked" => 0, "fare" => 7.925, "parch" => 0, "pclass" => 3, "sex" => 1, "sibsp" => 0},
%{"age" => 40.0, "embarked" => 0, "fare" => 9.475, "parch" => 0, "pclass" => 3, "sex" => 1, ...},
%{"age" => 27.0, "embarked" => 0, "fare" => 21.0, "parch" => 0, "pclass" => 2, ...},
%{"age" => 0.0, "embarked" => 1, "fare" => 7.8958, "parch" => 0, ...},
%{"age" => 3.0, "embarked" => 1, "fare" => 41.5792, ...},
%{"age" => 19.0, "embarked" => 2, ...},
%{"age" => 0.0, ...},
%{...},
...
]}
⑥整数を小数に変換
数値文字列から数値への変換を行いますが、ここで、整数と小数が混在する列は、文字列から数値への変換をString.to_integer
とString.to_float
を使い分けしなければならなくて面倒なため、整数文字列を全て小数文字列に変換した上で、小数に変換します
まずは、整数値は小数表記の文字列に変換した上で、全て小数に置換するinteger_string_to_floatという関数を追加します
integer_string_to_float
では、指定された全キーに対し、Enum.reduce
を使って、小数化を行っていきます
整数文字列の小数文字列化は、小数点である「.」が存在しない値を整数とみなし、String.replaceの正規表現置換によって後ろに「.0」を付加します
これがデータフレームでは必要ないです。Axonも:integer
, :boolean
, :float
などの数字がそのままで使えます。結果だけが小数点の形が必要らしいです。
⑦数値を行列に変換
最後に、数値をモデルに入力できるよう、行列に変換するためにmap_to_tensorという関数を追加します
ここで、「2次元行列のリスト」になるよう、Map.valuesで出力されるリストを、更にリスト[~]で囲んだ上で、
Nx.tensorにて行列化します
defmodule Pre do
…
def map_to_tensor(datas) do
datas
|> Enum.map(& [Map.values(&1)] |> Nx.tensor)
end
end
そのままで使えます。
{trains_csv_ids, train_csv_labels, train_csv_maps} =
train_df
|> Pre.separate("passengerid", "survived")
- train_csv_datas = train_csv_nimeric_maps
+ train_csv_datas = train_csv_maps
|> Pre.map_to_tensor
データフレームを最後までに変更して、separate
しましたので、train_csv_numeric_maps
がないですね。でも、マップのままで作成したから、関数を変わらずに同じ結果になります。
学習データが「2次元行列のリスト」に変換されました
[
#Nx.Tensor<
f32[1][7]
[
[22.0, 0.0, 7.25, 0.0, 3.0, 0.0, 1.0]
]
>,
#Nx.Tensor<
f32[1][7]
[
[38.0, 1.0, 71.2833023071289, 0.0, 1.0, 1.0, 1.0]
]
>,
...
]
⑧「データ前処理」全体の関数化
ここまでの「データ前処理」をprocessという関数で1発で完了するようにします
ここで、datasとtrain_datasの2つの引数を取っている理由ですが、これはto_dummiesの実施が、検証データや未知データではカテゴリ値が網羅されていないケースへの対策として、学習データからカテゴリ値を拾うためです
この処理は以下のことをPre
モジュールでやることらしいです。でも、全部はseparate
したの後の処理ですので、順番が違います。このままで処理を続きます。
train_df = train_df
|> Pre.drop(~w(cabin name ticket))
|> DF.mutate(embarked: fill_missing(embarked, "S"))
|> DF.mutate(age: fill_missing(age, 0.0))
|> Pre.to_dummies(["embarked", "sex"])
{trains_csv_ids, train_csv_labels, train_csv_maps} =
train_df
|> Pre.separate("passengerid", "survived")
train_csv_datas = train_csv_maps
|> Pre.map_to_tensor
更に、CSVファイルロード/マップ群変換/分離(ID/ラベル/学習データ)を処理する処理を、csv_file_to_datasとして関数化しておきます
ここで哲学的な意見の相違が生じているようです。 問題固有の関数が Pre のような汎用モジュールに属しているとは思えません。
しかし、データフレームの読み取りとその処理を統合したいので、その部分をここに移動しましょう。
- train_df = train_df
+ train_df =
+ DF.from_csv!("data/titanic/train.csv")
+ |> DF.rename_with(& String.downcase(&1))
|> Pre.drop(~w(cabin name ticket))
|> DF.mutate(embarked: fill_missing(embarked, "S"))
|> DF.mutate(age: fill_missing(age, 0.0))
|> Pre.to_dummies(["embarked", "sex"])
{trains_csv_ids, train_csv_labels, train_csv_maps} =
train_df
|> Pre.separate("passengerid", "survived")
train_csv_datas = train_csv_maps
|> Pre.map_to_tensor
データフレームを読んで、リデュースして、処理する。これがメインプログラムですので、モジュールに隠れたくないです。