45
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ElixirAdvent Calendar 2022

Day 1

Elixir でデータ分析 Explorer を使ってみた

Last updated at Posted at 2022-06-23

はじめに

最近の 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 を使ってください

https://rancherdesktop.io/

セットアップ

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)

data_table.png

データフレームの基本統計量表示

DataFrame.describe で以下のような基本統計量が取得できます

  • count: 件数
  • mean: 平均値
  • std: 標準偏差
  • min: 最小値
  • 25%: 25パーセンタイル
  • 50%: 中央値(50パーセンタイル)
  • 75%: 75パーセンタイル
  • max: 最大値
sample_df
|> DataFrame.describe()
|> Kino.DataTable.new()

describe.png

四則演算

Query によって記号による四則演算が可能です

sample_df
|> DataFrame.mutate(
  add: values + 2,
  subtract: values - 2,
  multiply: values * 2,
  divide: values / 2,
  pow: values ** 2
)
|> Kino.DataTable.new()

math.png

商(整数での割り算の結果)と余りは個別の関数を使います

  • quotient: 商
  • remainder: 余り
sample_df
|> DataFrame.mutate(
  quotient: quotient(values, 2),
  remainder: remainder(values, 2)
)
|> Kino.DataTable.new()

mod.png

比較

比較演算子も使用できます

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

greater.png

論理演算

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

logic.png

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

filter.png

欠損値

nil になっているデータを埋めることができます

まず、 nil を含むデータフレームを用意します

nil_df =
  %{
    "labels" => ["a", nil, "c", "c", "c"],
    "values" => [1, 2, 3, nil, 1]
  }
  |> DataFrame.new()

Kino.DataTable.new(nil_df)

nilable.png

is_nilis_not_nil で欠損値の判定ができます

nil_df
|> DataFrame.mutate(
  is_nil: is_nil(values),
  is_not_nil: is_not_nil(values)
)
|> Kino.DataTable.new()

is_nil.png

欠損値を補完する値を準備します

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

coalesce.png

集計

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

group_by.png

Series.frequencies で値毎の件数を取得します

sample_df["values"]
|> Series.frequencies()
|> Kino.DataTable.new()

frequencies.png

並べ替え

DataFrame.arrange で並べ替えます

asc: <列名> で昇順、 desc: <列名> で降順に並べます

sample_df
|> DataFrame.arrange(asc: values)
|> Kino.DataTable.new()

arrange.png

実際のデータを使った分析

データの準備

適当な統計データということで、統計局のホームページから
都道府県別、性別、年齢層別の人口データを Excel 形式でダウンロードして、
CSV に出力しました

こんな感じのデータです

スクリーンショット 2022-06-23 17.09.51.png

私のリポジトリーから取得できます

データの読込

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)

スクリーンショット 2022-06-23 17.24.34.png

ちなみに、 limit: :infinity を指定することで全件表示できます

指定しない場合は先頭の5件だけ表示されます

DataFrame.table(population_df, limit: :infinity)

データテーブルで表示すると以下のようになります

Kino.DataTable.new(population_df)

population_table.png

重複排除

年齢層の種類を取得します

population_df
|> DataFrame.distinct(["年齢層"])
|> DataFrame.pull("年齢層")
|> Series.to_list()

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

["15歳未満", "15~64歳", "65歳以上"]

データ抽出

以下のようにしてデータ抽出できます

filter で条件を指定して選択し、
select で項目を指定して射影します

population_df
|> DataFrame.filter(都道府県 == "東京都")
|> DataFrame.select(["年齢層", "性別", "人口_千人"])
|> Kino.DataTable.new()

population_filter.png

データ編集

mutate で色々データを編集できます

CSV を読み込んだとき、 人口(千人) の列が何故か文字列になっていたので、
float に変換してみます

cast で指定した列の型を変換します

population_df
|> DataFrame.mutate(人口_千人: cast(人口_千人, :float))
|> DataFrame.filter(都道府県 == "東京都")
|> DataFrame.select(["年齢層", "性別", "人口_千人"])
|> Kino.DataTable.new()

population_cast.png

うまく変換できなかったところが空になってしまいました

どういうわけかというと、 Excel から CSV 出力するとき、
人口の数字が 1,234 というような桁区切りのまま出力されており、
それをうまくパースできなかったようです

スクリーンショット 2022-06-23 18.25.34.png

カンマを取り除いてから変換しましょう

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

うまく変換できました

population_mutated.png

人口_千人 と書くのが面倒になってきたので、 1,000 倍して 人口 にしておきましょう

population_df =
  population_df
  |> DataFrame.mutate(人口_千人: 人口_千人 * 1000)
  |> DataFrame.mutate(人口_千人: cast(人口_千人, :integer))
  |> DataFrame.rename(人口_千人: "人口")

population_df
|> DataFrame.filter(都道府県 == "東京都")
|> DataFrame.select(["年齢層", "性別", "人口"])
|> Kino.DataTable.new()

population_thousand.png

ソート

arrange でソートできます

population_df
|> DataFrame.filter(年齢層 == "15歳未満")
|> DataFrame.filter(性別 == "男性")
|> DataFrame.filter(人口 > 300_000)
|> DataFrame.select(["都道府県", "人口"])
|> DataFrame.arrange(desc: 人口)
|> Kino.DataTable.new()

population_arrange.png

ピボット

pivot_widerpivot_longer で集計の軸を変更できます

年齢層の種類を列にしてみます

population_df
|> DataFrame.pivot_wider("年齢層", "人口")
|> Kino.DataTable.new()

population_pivot_wider.png

集約

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

population_sum.png

都道府県毎の男性率と女性率を出してみます

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

population_sex_ratio.png

トップの県でも女性の方が多いようです

続いて都道府県毎の高齢者率を出してみます

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)

population_elderly_ratio.png

結合

別の統計データを読み込みます

assets_df = DataFrame.from_csv!("/home/livebook/explorer/assets_20151216.csv")

Kino.DataTable.new(assets_df)

assets.png

こちらもカンマが邪魔だったので、排除してから 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)

assets_mutated.png

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)

joined.png

すっごく簡単ですね

データの保存

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)

covariance.png

その上で各列の平均を 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)

correlation.png

それっぽく出来上がりました

実は pandas で実行した結果と微妙に違うので、どこか失敗していそうですが:sweat_smile:

男性率と女性率には、当然強い負の相関が出ています

高齢者率と女性率の相関があるのは、おそらく女性の方が長生きだからでしょうね

世帯人員が高いほど、持ち家率も年間収入も高くなるようです

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 と同じ感覚でデータを扱えるのは非常に便利です!

今後、もっと便利な機能が追加されることを期待しています

45
15
10

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
45
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?