はじめに
最近の Elixir の発展は目覚ましいですね
Elixir で高速データ探索をするためのパッケージとして、
Explorer (冒険家) がリリースされたようです
早速 Livebook で使ってみました
実行したコードはこちら
2023/01/30 更新
最新版に更新しました
実行環境
- Elixir: 1.14.2 OTP 24
- Livebook: 0.8.1
以下のリポジトリーの Docker コンテナ上で起動しています
Docker が使える環境であれば簡単に実行できます
https://docs.docker.com/engine/install/
Docker Desktop を無償利用できない場合は Rancher Desktop を使ってください
セットアップ
Explorer と、テーブル表示をキレイにするための Kino をインストールします
Mix.install([
{:explorer, "~> 0.5"},
{:kino, "~> 0.8"}
])
エイリアス
まず、今後使うときに便利なように、 alias を付けておきます
また、Query が使えるように require もしておきます
alias Explorer.DataFrame
alias Explorer.Series
require Explorer.DataFrame
Query についてはこちらを参照してください
基本操作
データフレーム作成
マップからデータフレームを作成します
sample_df =
%{
"labels" => ["a", "b", "c", "c", "c"],
"values" => [1, 2, 3, 2, 1]
}
|> DataFrame.new()
実行結果は以下のようになります
#Explorer.DataFrame<
Polars[5 x 2]
labels string ["a", "b", "c", "c", "c"]
values integer [1, 2, 3, 2, 1]
>
Polars
は Explorer のバックエンドで、Rust 製の超高速データフレームライブラリです
labels 列と values 列に各値が格納され、 5 x 2 のデータフレームになっています
データフレームのテーブル表示
Kino.DataTable.new
を使うことでテーブル表示ができます
Kino.DataTable.new(sample_df)
データフレームの基本統計量表示
DataFrame.describe
で以下のような基本統計量が取得できます
sample_df
|> DataFrame.describe()
|> Kino.DataTable.new()
四則演算
Query によって記号による四則演算が可能です
sample_df
|> DataFrame.mutate(
add: values + 2,
subtract: values - 2,
multiply: values * 2,
divide: values / 2,
pow: values ** 2
)
|> Kino.DataTable.new()
商(整数での割り算の結果)と余りは個別の関数を使います
- quotient: 商
- remainder: 余り
sample_df
|> DataFrame.mutate(
quotient: quotient(values, 2),
remainder: remainder(values, 2)
)
|> Kino.DataTable.new()
比較
比較演算子も使用できます
sample_df
|> DataFrame.mutate(
equal: values == 2,
not_equal: values != 2,
greater: values > 2,
greater_equal: values >= 2,
less: values < 2,
less_equal: values <= 2
)
|> Kino.DataTable.new()
論理演算
not
or
and
で条件を組み合わせることもできます
sample_df
|> DataFrame.mutate(
"not equal": Series.not(values == 2),
"greater or equal": Series.or(values > 2, values == 2),
"greater and equal": Series.and(values > 2, values == 2)
)
|> Kino.DataTable.new()
Series.all_equal
で列が完全に一致しているかを判定できます
Series.all_equal(sample_df["labels"], sample_df["labels"])
Series.all_equal(sample_df["labels"], sample_df["values"])
フィルター
DataFrame.filter
で条件に合致する行だけを取得できます
threshold = 1
sample_df
|> DataFrame.filter(values > ^threshold)
|> Kino.DataTable.new()
欠損値
nil になっているデータを埋めることができます
まず、 nil を含むデータフレームを用意します
nil_df =
%{
"labels" => ["a", nil, "c", "c", "c"],
"values" => [1, 2, 3, nil, 1]
}
|> DataFrame.new()
Kino.DataTable.new(nil_df)
is_nil
や is_not_nil
で欠損値の判定ができます
nil_df
|> DataFrame.mutate(
is_nil: is_nil(values),
is_not_nil: is_not_nil(values)
)
|> Kino.DataTable.new()
欠損値を補完する値を準備します
fill_label =
""
|> List.duplicate(5)
|> Series.from_list()
fill_value =
0
|> List.duplicate(5)
|> Series.from_list()
coalesce
で nil の場合だけ補完します
nil_df
|> DataFrame.mutate(
labels_filled: coalesce(labels, ^fill_label),
values_filled: coalesce(values, ^fill_value)
)
|> Kino.DataTable.new()
集計
group_by
でグループ化し、 summarise
で集計します
sample_df
|> DataFrame.group_by("labels")
|> DataFrame.summarise(
count: count(values),
n_distinct: n_distinct(values),
min: min(values),
max: max(values),
sum: sum(values),
mean: mean(values),
median: median(values),
"quantile 1/4": quantile(values, 0.25),
"quantile 3/4": quantile(values, 0.75),
variance: variance(values),
standard_deviation: standard_deviation(values)
)
|> Kino.DataTable.new()
Series.frequencies
で値毎の件数を取得します
sample_df["values"]
|> Series.frequencies()
|> Kino.DataTable.new()
並べ替え
DataFrame.arrange
で並べ替えます
asc: <列名>
で昇順、 desc: <列名>
で降順に並べます
sample_df
|> DataFrame.arrange(asc: values)
|> Kino.DataTable.new()
実際のデータを使った分析
データの準備
適当な統計データということで、統計局のホームページから
都道府県別、性別、年齢層別の人口データを Excel 形式でダウンロードして、
CSV に出力しました
こんな感じのデータです
私のリポジトリーから取得できます
データの読込
CSV からデータを読みます
列名の 人口(千人)
は扱いづらいので 人口_千人
にリネームします
population_df =
"/home/livebook/explorer/population_20211001.csv"
|> DataFrame.from_csv!()
|> DataFrame.rename("人口(千人)": "人口_千人")
実行結果は以下のようになります
#Explorer.DataFrame<
Polars[282 x 4]
都道府県 string ["北海道", "青森県", "岩手県", "宮城県", "秋田県", ...]
年齢層 string ["15歳未満", "15歳未満", "15歳未満", "15歳未満", "15歳未満", ...]
性別 string ["男性", "男性", "男性", "男性", "男性", ...]
人口_千人 string ["278", "65", "66", "135", "46", ...]
>
人口_千人
は数字のデータですが、文字列として読み込まれています
これは CSV ファイルの 人口(千人) 列に、一部 "
ダブルクォーテーションで囲っている値が存在するためです
表形式で表示してみます
DataFrame.table(population_df)
ちなみに、 limit: :infinity
を指定することで全件表示できます
指定しない場合は先頭の5件だけ表示されます
DataFrame.table(population_df, limit: :infinity)
データテーブルで表示すると以下のようになります
Kino.DataTable.new(population_df)
重複排除
年齢層の種類を取得します
population_df
|> DataFrame.distinct(["年齢層"])
|> DataFrame.pull("年齢層")
|> Series.to_list()
実行結果は以下のようになります
["15歳未満", "15~64歳", "65歳以上"]
データ抽出
以下のようにしてデータ抽出できます
filter
で条件を指定して選択し、
select
で項目を指定して射影します
population_df
|> DataFrame.filter(都道府県 == "東京都")
|> DataFrame.select(["年齢層", "性別", "人口_千人"])
|> Kino.DataTable.new()
データ編集
mutate
で色々データを編集できます
CSV を読み込んだとき、 人口(千人)
の列が何故か文字列になっていたので、
float に変換してみます
cast
で指定した列の型を変換します
population_df
|> DataFrame.mutate(人口_千人: cast(人口_千人, :float))
|> DataFrame.filter(都道府県 == "東京都")
|> DataFrame.select(["年齢層", "性別", "人口_千人"])
|> Kino.DataTable.new()
うまく変換できなかったところが空になってしまいました
どういうわけかというと、 Excel から CSV 出力するとき、
人口の数字が 1,234
というような桁区切りのまま出力されており、
それをうまくパースできなかったようです
カンマを取り除いてから変換しましょう
Series.transform
で指定した列に関数を適用します
trimmed =
population_df["人口_千人"]
|> Series.transform(fn input ->
String.replace(input, ",", "")
end)
|> Series.cast(:float)
実行結果は以下のようになり、 1463.0
などの桁区切りされていた値も数値に変換できています
#Explorer.Series<
Polars[282]
float [278.0, 65.0, 66.0, 135.0, 46.0, 60.0, 103.0, 168.0, 115.0, 113.0, 440.0, 373.0, 795.0,
549.0, 124.0, 58.0, 69.0, 48.0, 46.0, 123.0, 121.0, 220.0, 495.0, 107.0, 97.0, 148.0, 521.0,
336.0, 77.0, 53.0, 35.0, 42.0, 118.0, 178.0, 77.0, 39.0, 58.0, 77.0, 38.0, 338.0, 55.0, 83.0,
116.0, 68.0, 71.0, 105.0, 124.0, 1463.0, 338.0, 337.0, ...]
>
DataFrame.put
で変換した値で列を上書きます
population_df = DataFrame.put(population_df, "人口_千人", trimmed)
population_df
|> DataFrame.filter(都道府県 == "東京都")
|> DataFrame.select(["年齢層", "性別", "人口_千人"])
|> Kino.DataTable.new()
うまく変換できました
人口_千人
と書くのが面倒になってきたので、 1,000 倍して 人口
にしておきましょう
population_df =
population_df
|> DataFrame.mutate(人口_千人: 人口_千人 * 1000)
|> DataFrame.mutate(人口_千人: cast(人口_千人, :integer))
|> DataFrame.rename(人口_千人: "人口")
population_df
|> DataFrame.filter(都道府県 == "東京都")
|> DataFrame.select(["年齢層", "性別", "人口"])
|> Kino.DataTable.new()
ソート
arrange
でソートできます
population_df
|> DataFrame.filter(年齢層 == "15歳未満")
|> DataFrame.filter(性別 == "男性")
|> DataFrame.filter(人口 > 300_000)
|> DataFrame.select(["都道府県", "人口"])
|> DataFrame.arrange(desc: 人口)
|> Kino.DataTable.new()
ピボット
pivot_wider
や pivot_longer
で集計の軸を変更できます
年齢層の種類を列にしてみます
population_df
|> DataFrame.pivot_wider("年齢層", "人口")
|> Kino.DataTable.new()
集約
groupby
で集約し、 summarise
で集計値を取得できます
合計すると値が大きすぎて指数表示になるため、改めて千人単位に変換しています
sum_df =
population_df
|> DataFrame.group_by(["都道府県"])
|> DataFrame.summarise(人口_千人: sum(人口 / 1_000))
|> DataFrame.mutate(人口_千人: cast(人口_千人, :float))
sum_df
|> DataFrame.arrange(desc: 人口_千人)
|> Kino.DataTable.new()
都道府県毎の男性率と女性率を出してみます
sex_ratio_df =
population_df
|> DataFrame.group_by(["都道府県", "性別"])
|> DataFrame.summarise(人口: sum(人口))
|> DataFrame.mutate(人口: cast(人口, :float))
|> DataFrame.pivot_wider("性別", "人口")
|> DataFrame.mutate(合計: 男性 + 女性)
|> DataFrame.mutate(男性率: 男性 / 合計)
|> DataFrame.mutate(女性率: 女性 / 合計)
|> DataFrame.select(["都道府県", "男性率", "女性率"])
sex_ratio_df
|> DataFrame.arrange(desc: 男性率)
|> Kino.DataTable.new()
トップの県でも女性の方が多いようです
続いて都道府県毎の高齢者率を出してみます
elderly_rate_df =
population_df
|> DataFrame.group_by(["都道府県", "年齢層"])
|> DataFrame.summarise(人口: sum(人口))
|> DataFrame.mutate(人口: cast(人口, :float))
|> DataFrame.pivot_wider("年齢層", "人口")
|> DataFrame.rename("15歳未満": "young")
|> DataFrame.rename("15~64歳": "middle")
|> DataFrame.rename("65歳以上": "elder")
|> DataFrame.mutate(合計: young + middle + elder)
|> DataFrame.mutate(高齢者率: elder / 合計)
|> DataFrame.select(["都道府県", "高齢者率"])
elderly_rate_df
|> DataFrame.arrange(desc: 高齢者率)
|> Kino.DataTable.new(sorting_enabled: true)
結合
別の統計データを読み込みます
assets_df = DataFrame.from_csv!("/home/livebook/explorer/assets_20151216.csv")
Kino.DataTable.new(assets_df)
こちらもカンマが邪魔だったので、排除してから float にします
parse_float = fn df, col ->
df
|> DataFrame.put(
col,
df[col]
|> Series.transform(fn input ->
String.replace(input, ",", "")
end)
|> Series.cast(:float)
)
end
assets_df =
assets_df
|> parse_float.("年間収入")
|> parse_float.("貯蓄現在高")
|> parse_float.("負債現在高")
Kino.DataTable.new(assets_df)
join
で他のデータフレームと結合します
how: :left
で左外部結合を指定しています
joined_df =
sum_df
|> DataFrame.join(sex_ratio_df, how: :left)
|> DataFrame.join(elderly_rate_df, how: :left)
|> DataFrame.join(assets_df, how: :left)
Kino.DataTable.new(joined_df)
すっごく簡単ですね
データの保存
to_csv
で CSV 形式で保存できます
DataFrame.to_csv(joined_df, "/tmp/joined.csv")
相関係数の取得
ここからはゴリ押しです
せっかく統計データを取ってきたので、各数値の共分散や相関係数を見てみましょう
pandas だと集計値は簡単に取得できます
joined_df = pd.read_csv("tmp/joined.csv")
# 共分散
joined_df.cov()
# 相関係数
joined_df.corr()
しかし、残念ながら Explorer には、まだこのような機能がないようです
※平均や標準偏差なら簡単に取得できます
せっかくなので、やってみます
まず、統計を取りたい列を定義します
"都道府県" 以外の列です
cols =
joined_df
|> DataFrame.names()
|> Enum.filter(& &1 != "都道府県")
結果は以下のようになります
["人口_千人", "男性率", "女性率", "高齢者率", "世帯人員", "持ち家率",
"年間収入", "貯蓄現在高", "負債現在高"]
標準偏差を map に持っておきます
std_map =
joined_df
|> DataFrame.select(cols)
|> DataFrame.to_series()
|> Enum.map(fn {key, value} -> {key, Series.standard_deviation(value)} end)
|> Enum.into(%{})
各標準偏差は以下の値になります
%{
"世帯人員" => 0.15789960393037436,
"人口_千人" => 2791.9761139238913,
"女性率" => 0.009130530023194104,
"年間収入" => 548.6312098671866,
"持ち家率" => 6.866234804354504,
"男性率" => 0.009130530023194095,
"負債現在高" => 816.1966623764004,
"貯蓄現在高" => 2867.74243208822,
"高齢者率" => 0.03187703554831672
}
x,y の共分散は以下の式で表すことができます
x*yの平均 - xの平均 * yの平均
というわけで、まずは各列を掛け合わせた値をデータフレームに追加します
get_mul = fn df, col_1, col_2 ->
DataFrame.put(
df,
"#{col_1}*#{col_2}",
Series.multiply(df[col_1], df[col_2])
)
end
covariance_df =
cols
|> Enum.reduce(joined_df, fn col_1, sub_df_1 ->
cols
|> Enum.reduce(sub_df_1, fn col_2, sub_df_2 ->
get_mul.(sub_df_2, col_1, col_2)
end)
end)
その上で各列の平均を map に取得しておきます
select_cols =
covariance_df
|> DataFrame.names()
|> Enum.filter(&(&1 != "都道府県"))
mean_map =
covariance_df
|> DataFrame.select(select_cols)
|> DataFrame.to_series()
|> Enum.map(fn {key, value} -> {key, Series.mean(value)} end)
|> Enum.into(%{})
実行結果は以下のようになります
%{
"高齢者率" => 0.31102745326423686,
"男性率*女性率" => 0.24963106680631075,
"貯蓄現在高*高齢者率" => 4184.178164295221,
"男性率*人口_千人" => 1298.276595744681,
"貯蓄現在高*世帯人員" => 33409.922340425524,
"高齢者率*高齢者率" => 0.09773260196459105,
"貯蓄現在高*負債現在高" => 48792312.5106383,
"高齢者率*人口_千人" => 770.468085106383,
...
}
そして、先程の共分散の計算式を統計対象列のすべての組み合わせに対して適用します
get_covariance = fn col_1, col_2 ->
Map.get(mean_map, col_1 <> "*" <> col_2) - Map.get(mean_map, col_1) * Map.get(mean_map, col_2)
end
covariance_map =
cols
|> Enum.map(fn col_1 ->
cols
|> Enum.map(fn col_2 ->
{col_1 <> "*" <> col_2, get_covariance.(col_1, col_2)}
end)
|> Enum.into(%{})
end)
|> Enum.reduce(fn map, merged_map ->
Map.merge(merged_map, map)
end)
共分散は以下のようになります
%{
"男性率*女性率" => -8.159282151495861e-5,
"貯蓄現在高*高齢者率" => -18.636547583582797,
"男性率*人口_千人" => 8.371636326161024,
"貯蓄現在高*世帯人員" => 82.47629244001291,
"高齢者率*高齢者率" => 9.945252805540067e-4,
"貯蓄現在高*負債現在高" => 978410.078768678,
"高齢者率*人口_千人" => -60.081096795347094,
"世帯人員*男性率" => 4.6065873934009183e-4,
"世帯人員*高齢者率" => 6.398412987442814e-4,
"世帯人員*年間収入" => 45.66631960162704,
"持ち家率*持ち家率" => 46.14209144409324,
...
}
相関係数は xとyの共分散 / (xの標準偏差*yの標準偏差) なので、
これをさらに計算して map に格納します
get_correlation = fn col_1, col_2 ->
cond do
col_1 == col_2 ->
1
true ->
Map.get(covariance_map, col_1 <> "*" <> col_2) /
(Map.get(std_map, col_1) * Map.get(std_map, col_2))
end
end
correlation_map =
cols
|> Enum.map(fn col_1 ->
cols
|> Enum.map(fn col_2 ->
{col_1 <> "*" <> col_2, get_correlation.(col_1, col_2)}
end)
|> Enum.into(%{})
end)
|> Enum.reduce(fn map, merged_map ->
Map.merge(merged_map, map)
end)
相関係数は以下のようになります
%{
"男性率*女性率" => -0.9787234042549128,
"貯蓄現在高*高齢者率" => -0.2038672165452424,
"男性率*人口_千人" => 0.32839963554761675,
"貯蓄現在高*世帯人員" => 0.1821410965621719,
"高齢者率*高齢者率" => 1,
"貯蓄現在高*負債現在高" => 0.4180093161154209,
"高齢者率*人口_千人" => -0.675069096214225,
"世帯人員*男性率" => 0.31952311779563053,
"世帯人員*高齢者率" => 0.12711982927356386,
"世帯人員*年間収入" => 0.527150304322526,
"持ち家率*持ち家率" => 1,
...
}
最後に相関係数をデータフレームに流し込んで表形式で表示します
cols
|> Enum.map(fn col_1 ->
%{
col_1 =>
cols
|> Enum.map(fn col_2 ->
Map.get(correlation_map, col_1 <> "*" <> col_2)
end)
}
end)
|> Enum.reduce(fn map, merged_map ->
Map.merge(merged_map, map)
end)
|> Map.merge(%{"x" => cols})
|> DataFrame.new()
|> DataFrame.select(["x" | cols])
|> Kino.DataTable.new(keys: ["x" | cols], sorting_enabled: true)
それっぽく出来上がりました
実は pandas で実行した結果と微妙に違うので、どこか失敗していそうですが
男性率と女性率には、当然強い負の相関が出ています
高齢者率と女性率の相関があるのは、おそらく女性の方が長生きだからでしょうね
世帯人員が高いほど、持ち家率も年間収入も高くなるようです
x | 人口_千人 | 男性率 | 女性率 | 高齢者率 | 世帯人員 | 持ち家率 | 年間収入 | 貯蓄現在高 | 負債現在高 |
---|---|---|---|---|---|---|---|---|---|
人口_千人 | 1.0 | 0.32839963554761675 | -0.3283996355476253 | -0.675069096214225 | -0.38592888760786614 | -0.3201635451800164 | 0.2903848632180311 | 0.34074103001227296 | 0.6825283462577149 |
男性率 | 0.32839963554761675 | 1.0 | -0.9787234042549128 | -0.5954624427808082 | 0.31952311779563053 | 0.09492910399813101 | 0.6260409214159116 | 0.34008522695212134 | 0.6050084839753493 |
女性率 | -0.3283996355476253 | -0.9787234042549128 | 1.0 | 0.5954624427808075 | -0.3195231177956302 | -0.09492910399790425 | -0.6260409214158202 | -0.340085226952121 | -0.6050084839752573 |
高齢者率 | -0.675069096214225 | -0.5954624427808082 | 0.5954624427808075 | 1.0 | 0.12711982927356386 | 0.39852608818879076 | -0.27590246386820655 | -0.2038672165452424 | -0.6840201298233702 |
世帯人員 | -0.38592888760786614 | 0.31952311779563053 | -0.3195231177956302 | 0.12711982927356386 | 1.0 | 0.5669163291406313 | 0.527150304322526 | 0.1821410965621719 | 0.1016151699191319 |
持ち家率 | -0.3201635451800164 | 0.09492910399813101 | -0.09492910399790425 | 0.39852608818879076 | 0.5669163291406313 | 1.0 | 0.5819064066105291 | 0.5816831569245144 | 0.04600816107969441 |
年間収入 | 0.2903848632180311 | 0.6260409214159116 | -0.6260409214158202 | -0.27590246386820655 | 0.527150304322526 | 0.5819064066105291 | 1.0 | 0.7148869412009056 | 0.6141653791913659 |
貯蓄現在高 | 0.34074103001227296 | 0.34008522695212134 | -0.340085226952121 | -0.2038672165452424 | 0.1821410965621719 | 0.5816831569245144 | 0.7148869412009056 | 1.0 | 0.4180093161154209 |
負債現在高 | 0.6825283462577149 | 0.6050084839753493 | -0.6050084839752573 | -0.6840201298233702 | 0.1016151699191319 | 0.04600816107969441 | 0.6141653791913659 | 0.4180093161154209 | 1.0 |
もっとスマートで正確なやり方があると思いますが、
とりあえず Explorer でデータ分析することができました!
おわりに
pandas と同じ感覚でデータを扱えるのは非常に便利です!
今後、もっと便利な機能が追加されることを期待しています