Juliaでデータサイエンス100本ノック（構造化データ加工編）

データサイエンティスト協会による[データサイエンス100本ノック（構造化データ加工編）](https://github.com/The-Japan-DataScientist-Society/100knocks-preprocess)をJuliaで解きました。

## はじめに
わざわざ紹介するまでもありませんが、Juliaは書きやすくて実行が速いモダンな科学計算向け言語として昨今注目を集めています。ただ、まだデータをこねくりまわすコード例が少なく、前処理などの用途で使うには少しとっつきづらさも残っていると思います。つい先月公開されたデータサイエンス100本ノック（構造化データ加工編）はそんな日頃遭遇するデータ処理のタスクを効果的にカバーしているもので、Python、R、SQLでの回答例もついています。ということで、いっちょこれをJuliaでやって実地で使えるように練習しようと思い立ち、やってみました。いくぶんでも参考になるかもしれないので、あくまで一回答例として公開してみます。

## コード
https://github.com/Ken-Kuroki/julia-100knocks-preprocess
上記リポジトリにてほぼ全問題への回答例を公開しています。テーブルのデータなどは含まず、Juliaでのnotebookのみ置いているので、あらかじめオリジナルのリポジトリを手元に用意してください。

## ポイント
JuliaでRのtidyverseのようにテーブルを扱うため、[DataFrames.jl](https://juliadata.github.io/DataFrames.jl/stable/)を基本として、クエリ機能を[DataFramesMeta](https://github.com/JuliaData/DataFramesMeta.jl)で補っています。Rの `%>%`　に相当する `|>` でどんどん処理をつなげていけるのが便利です。ここでは使っていませんがDataFramesMeta.jlと同等の機能のライブラリとして[Query.jl](https://github.com/queryverse/Query.jl)もあります。

そのほか、SQLに接続するために[LibPQ.jl](https://github.com/invenia/LibPQ.jl)、Julia版の[ScikitLearn.jl](https://github.com/cstjean/ScikitLearn.jl)（Juliaに移植されている機能とPython呼び出しになる機能があります）、統計関係の関数で[StatsBase.jl](https://github.com/JuliaStats/StatsBase.jl)、Pythonでのimbalanced-learnに相当する機能を使うために[MLDataPattern.jl](https://github.com/JuliaML/MLDataPattern.jl)、そしてCSVファイルを取り扱うために[CSV.jl](https://github.com/JuliaData/CSV.jl)を用いています。

ここでは最適化はさして意識していないコードになっています。速度を意識する場面で使うにはなによりJuliaの高速化の出発点である「関数内でコードを実行すること」「グローバル変数へのアクセス回数は抑えること」が重要です。それだけで十分に速くなると思いますが、そのほかのテクニックについては公式ドキュメントの[Performance Tips](https://docs.julialang.org/en/v1/manual/performance-tips/)が非常に充実しているので気になる方は読んでみてください。

実行結果はPython版とおおむね一致することを基本的に確認しています。ただ、ソートアルゴリズムがPythonではstableなTim Sort （Merge sortとInsertion Sortの組み合わせ）であるのに対し、JuliaではQuick Sortが基本であるようで、きちんと検証したわけではありませんがおそらくその点に起因するソート結果の不一致が見られました。他にもミスや「こうした方がもっとスマートに書ける」などありましたらぜひコメントいただけたら幸いです。

#### ライブラリの用意
パッケージのインストールも行なってしまいます。最後の行はscikit-learnのPythonコードを呼び出すために必要です。

```julia
ENV["COLUMNS"]=240  # 描画する表の列数を増やす
ENV["LINES"]=10  # 行の数は制限（問題の指示とは異なるので好みに合わせて修正）

using Pkg

Pkg.add("DataFrames")
Pkg.add("DataFramesMeta")
Pkg.add("LibPQ")
Pkg.add("StatsBase")
Pkg.add("ScikitLearn")
Pkg.add("MLDataPattern")
Pkg.add("CSV")

using DataFrames
using DataFramesMeta
using LibPQ
using StatsBase
using Statistics
using Dates
using Random
using ScikitLearn
using MLDataPattern
using CSV

@sk_import preprocessing: (LabelBinarizer, StandardScaler, MinMaxScaler)
```

#### SQLとの接続
今回のサンプルデータはPostgreSQLから取るので、以下のようにして接続して取り出し、DataFrameを作ってやります。シェルスクリプトのように文字列中で `$var` で変数展開できます。

```julia
host = "db"
port = ENV["PG_PORT"]
database = ENV["PG_DATABASE"]
user = ENV["PG_USER"]
password = ENV["PG_PASSWORD"]
dsl = "postgresql://$user:$password@$host:$port/$database"
conn = LibPQ.Connection(dsl)

df_customer = DataFrame(execute(conn, "select * from customer"))
df_category = DataFrame(execute(conn, "select * from category"))
df_product = DataFrame(execute(conn, "select * from product"))
df_receipt = DataFrame(execute(conn, "select * from receipt"))
df_store = DataFrame(execute(conn, "select * from store"))
df_geocode = DataFrame(execute(conn, "select * from geocode"));
```

## 一部問題の回答と解説
トピック別に一部問題に絞って、問題文を引用しながらJuliaでの回答例とその解説を示します。ほぼ全問題の回答例は [上記のリポジトリ](https://github.com/Ken-Kuroki/julia-100knocks-preprocess) に用意してあります。

#### 条件で絞り込み
> 006: レシート明細データフレーム「df_receipt」から売上日（sales_ymd）、顧客ID（customer_id）、商品コード（product_cd）、売上数量（quantity）、売上金額（amount）の順に列を指定し、以下の条件を満たすデータを抽出せよ。
> - 顧客ID（customer_id）が"CS018205000001"
> - 売上金額（amount）が1,000以上または売上数量（quantity）が5以上

```julia
@linq df_receipt |>
    select(:sales_ymd, :customer_id, :product_cd, :quantity, :amount) |>
    where(:customer_id.=="CS018205000001", (:amount .>= 1000) .| (:quantity .>= 5))
```

<table class="data-frame"><thead><tr><th></th><th>sales_ymd</th><th>customer_id</th><th>product_cd</th><th>quantity</th><th>amount</th></tr><tr><th></th><th>Int32?</th><th>String?</th><th>String?</th><th>Int32?</th><th>Int32?</th></tr></thead><tbody><p>5 rows × 5 columns</p><tr><th>1</th><td>20180911</td><td>CS018205000001</td><td>P071401012</td><td>1</td><td>2200</td></tr><tr><th>2</th><td>20180414</td><td>CS018205000001</td><td>P060104007</td><td>6</td><td>600</td></tr><tr><th>3</th><td>20170614</td><td>CS018205000001</td><td>P050206001</td><td>5</td><td>990</td></tr><tr><th>4</th><td>20190226</td><td>CS018205000001</td><td>P071401020</td><td>1</td><td>2200</td></tr><tr><th>5</th><td>20180911</td><td>CS018205000001</td><td>P071401005</td><td>1</td><td>1100</td></tr></tbody></table>

pandasなら `.loc` で列を絞ってから `query()` あたりを使うところでしょうか。DataFramesMeta.jlを活用してC#的なlinqで書くと非常にすっきりします。演算子の頭に `.` がつくのはvectorizeして適用する表記です。多用するので覚えておいてください。 `where` に複数の引数を与えるとand条件になり、or条件は `.|` で表します。また `:foobar` のようにコロンで始まるのは文字列に似ていますが `Symbol` 型で、同じ中身なら同じインスタンスである（equalityとidentityが等しい）という性質を持ちます。ここではだいたい文字列のようなものだと思っていただいて構いません。

---
#### 正規表現で絞り込み
> 010: 店舗データフレーム（df_store）から、店舗コード（store_cd）が"S14"で始まるものだけ全項目抽出し、10件だけ表示せよ。

```julia
# occursinをelement-wiseに使う。列名を指定するとその中身が展開されるのはここまでと同じ。
@linq df_store |>
    where(occursin.(r"^S14", :store_cd)) |>
    first(10)
```

<table class="data-frame"><thead><tr><th></th><th>store_cd</th><th>store_name</th><th>prefecture_cd</th><th>prefecture</th><th>address</th><th>address_kana</th><th>tel_no</th><th>longitude</th><th>latitude</th><th>floor_area</th></tr><tr><th></th><th>String?</th><th>String?</th><th>String?</th><th>String?</th><th>String?</th><th>String?</th><th>String?</th><th>Decimal…?</th><th>Decimal…?</th><th>Decimal…?</th></tr></thead><tbody><p>10 rows × 10 columns</p>
<tr><th>1</th><td>S14010</td><td>菊名店</td><td>14</td><td>神奈川県</td><td>神奈川県横浜市港北区菊名一丁目</td><td>カナガワケンヨコハマシコウホククキクナイッチョウメ</td><td>045-123-4032</td><td>Decimal(0, 1396326, -4)</td><td>Decimal(0, 3550049, -5)</td><td>Decimal(0, 1732, 0)</td></tr>
<tr><th>2</th><td>S14033</td><td>阿久和店</td><td>14</td><td>神奈川県</td><td>神奈川県横浜市瀬谷区阿久和西一丁目</td><td>カナガワケンヨコハマシセヤクアクワニシイッチョウメ</td><td>045-123-4043</td><td>Decimal(0, 1394961, -4)</td><td>Decimal(0, 3545918, -5)</td><td>Decimal(0, 1495, 0)</td>
</tr><tr><th>3</th><td>S14036</td><td>相模原中央店</td><td>14</td><td>神奈川県</td><td>神奈川県相模原市中央二丁目</td><td>カナガワケンサガミハラシチュウオウニチョウメ</td><td>042-123-4045</td><td>Decimal(0, 1393716, -4)</td><td>Decimal(0, 3557327, -5)</td><td>Decimal(0, 1679, 0)</td></tr>
<tr><th>...</th><td>...</td><td>...</td><td>...</td><td>...</td><td>...</td><td>...</td><td>...</td><td>...</td><td>...</td><td>...</td></tr><tr><th>10</th><td>S14011</td><td>日吉本町店</td><td>14</td><td>神奈川県</td><td>神奈川県横浜市港北区日吉本町四丁目</td><td>カナガワケンヨコハマシコウホククヒヨシホンチョウヨンチョウメ</td><td>045-123-4033</td><td>Decimal(0, 1396316, -4)</td><td>Decimal(0, 3554655, -5)</td><td>Decimal(0, 89, 1)</td></tr></tbody></table>

Pythonだと `column_name.str.` あたりをごにょごにょやるやつですね。こちらもJuliaのほうが直感的に書けると思います。 `occursin.()` と変な場所にドットが入っていますが、これもvectorizeの表記で、関数の場合は引数のかっこの前にドットを置きます。

---
#### ソート
> 019: レシート明細データフレーム（df_receipt）に対し、1件あたりの売上金額（amount）が高い順にランクを付与し、先頭10件を抽出せよ。項目は顧客ID（customer_id）、売上金額（amount）、付与したランクを表示させること。なお、売上金額（amount）が等しい場合は同一順位を付与するものとする。


```julia
@linq df_receipt |>
    sort(order(:amount, rev=true)) |>
    transform(rank=competerank(:amount, rev=true)) |>
    select(:customer_id, :amount, :rank) |>
    first(10)
```

<table class="data-frame"><thead><tr><th></th><th>customer_id</th><th>amount</th><th>rank</th></tr><tr><th></th><th>String?</th><th>Int32?</th><th>Int64?</th></tr></thead><tbody><p>10 rows × 3 columns</p><tr><th>1</th><td>CS011415000006</td><td>10925</td><td>1</td></tr><tr><th>2</th><td>ZZ000000000000</td><td>6800</td><td>2</td></tr><tr><th>3</th><td>CS028605000002</td><td>5780</td><td>3</td></tr><tr><th>4</th><td>CS015515000034</td><td>5480</td><td>4</td></tr><tr><th>5</th><td>ZZ000000000000</td><td>5480</td><td>4</td></tr><tr><th>6</th><td>ZZ000000000000</td><td>5480</td><td>4</td></tr><tr><th>7</th><td>ZZ000000000000</td><td>5440</td><td>7</td></tr><tr><th>8</th><td>CS021515000089</td><td>5440</td><td>7</td></tr><tr><th>9</th><td>CS015515000083</td><td>5280</td><td>9</td></tr><tr><th>10</th><td>CS017414000114</td><td>5280</td><td>9</td></tr></tbody></table>

こちらは微妙にすっきりしないケースです。昇順ソートなら

```julia
@linq df_receipt |>
    orderby(:amount) |>
    ...
```

と書くだけでいいのですが、DataFramesMeta.jlのorderbyは降順ソートをサポートしない（numericalな場合は符号を反転すればできますが、ここでは一般性を保ちたいので使いません。 [こちらのissue参照](https://github.com/JuliaData/DataFramesMeta.jl/issues/81) ）ので、DataFrames本体の `sort` を使います。本来は `sort(df, order(:col, rev=true))` のように使う関数ですが、linqの中では第一引数に当該データフレームが自動的に与えられるので、そこは省略します。他の関数も同様にlinqの中で使えるので、DataFrames.jlのドキュメントを見ながら読み替えてみてください。もちろん見やすさを気にしないなら`first(select(transfort(sort(...` のようにlinqをやめてもかまいません。

ランキングをつけるのは非常に簡単で、 [StatsBase.jlにさまざまなランキングの取り方をするための関数が用意されている](https://juliastats.org/StatsBase.jl/stable/ranking/#Rankings-and-Rank-Correlations-1) ので使うだけです。

---
#### 集約
> 026: レシート明細データフレーム（df_receipt）に対し、顧客ID（customer_id）ごとに最も新しい売上日（sales_ymd）と古い売上日を求め、両者が異なるデータを10件表示せよ。

```julia
@linq df_receipt |>
    select(:customer_id, :sales_ymd) |>
    orderby(:customer_id, :sales_ymd) |>
    groupby(:customer_id) |>
    combine(:sales_ymd => last, :sales_ymd => first) |>
    where(:sales_ymd_last .!= :sales_ymd_first) |>
    first(10)
```

<table class="data-frame"><thead><tr><th></th><th>customer_id</th><th>sales_ymd_last</th><th>sales_ymd_first</th></tr><tr><th></th><th>String?</th><th>Int32</th><th>Int32</th></tr></thead><tbody><p>10 rows × 3 columns</p><tr><th>1</th><td>CS001114000005</td><td>20190731</td><td>20180503</td></tr><tr><th>2</th><td>CS001115000010</td><td>20190405</td><td>20171228</td></tr><tr><th>3</th><td>CS001205000004</td><td>20190625</td><td>20170914</td></tr><tr><th>...</th><td>...</td><td>...</td><td>...</td></tr><tr><th>10</th><td>CS001215000040</td><td>20171022</td><td>20170214</td></tr></tbody></table>

pandasだったらgroupbyしてから `agg({"col": ["min", "max"]})` するか、もしも非数値データが対象だったらソートしてから `nth(0)` と `nth(-1)` を使うべきところでしょうか。Juliaだとこの通り、Rの `summarise()` に相当する `combine()` でさまざまな集約関数を指定でき、その関数名が末尾に付加された列ができるのでそれらについて `where` をかけるという自然なコードが書けます。数値なので `min` と `max` を使った方が手っ取り早いですがあえて一般性のある実装を示します。もちろんほかにも普通の `sum` や `mean` などの集約関数も使えます。なお、件数を数えるときにはpandasでは `count` を使いますが、 Juliaでは `length` になります。

---
#### 結合、集約、絞り込み
> 038: 顧客データフレーム（df_customer）とレシート明細データフレーム（df_receipt）から、各顧客ごとの売上金額合計を求めよ。ただし、買い物の実績がない顧客については売上金額を0として表示させること。また、顧客は性別コード（gender_cd）が女性（1）であるものを対象とし、非会員（顧客IDが'Z'から始まるもの）は除外すること。なお、結果は10件だけ表示させれば良い。

```julia
@linq outerjoin(df_customer[:, [:customer_id, :gender_cd]],
                df_receipt[:, [:customer_id, :amount]],
                on=:customer_id) |>
    dropmissing(:gender_cd) |>
    where(:gender_cd .== "1") |>
    where(occursin.(r"^[^Z]", :customer_id)) |>
    groupby(:customer_id) |>
    combine(:amount => sum) |>
    transform(amount_sum = coalesce.(:amount_sum, 0)) |>
    first(10)
```

<table class="data-frame"><thead><tr><th></th><th>customer_id</th><th>amount_sum</th></tr><tr><th></th><th>String?</th><th>Int64</th></tr></thead><tbody><p>10 rows × 2 columns</p><tr><th>1</th><td>CS021313000114</td><td>0</td></tr><tr><th>2</th><td>CS031415000172</td><td>5088</td></tr><tr><th>3</th><td>CS028811000001</td><td>0</td></tr><tr><th>...</th><td>...</td><td>...</td></tr><tr><th>10</th><td>CS040412000191</td><td>210</td></tr></tbody></table>

Joinもこのように簡単です。ここではjoinするDataFrameそれぞれ先に列を絞っています。顧客情報テーブルで `gender_cd` が `missing` であるエントリがあるので `dropmissing()` します。その上で与えられた条件で絞り込み、売り上げの合計を取り、売り上げがないために `missing` であるエントリは指示通り売り上げゼロとみなすためpandasの `fillna()`　に相当する `coalesce.()` してやります。行数はかさみますが素直に積み上げていくだけです。

---
#### 関数を作って適用＆クロス集計
> 043： レシート明細データフレーム（df_receipt）と顧客データフレーム（df_customer）を結合し、性別（gender）と年代（ageから計算）ごとに売上金額（amount）を合計した売上サマリデータフレーム（df_sales_summary）を作成せよ。性別は0が男性、1が女性、9が不明を表すものとする。
>
> ただし、項目構成は年代、女性の売上金額、男性の売上金額、性別不明の売上金額の4項目とすること（縦に年代、横に性別のクロス集計）。また、年代は10歳ごとの階級とすること。

```julia
# 一よりも上の位で切り捨てる関数を用意
# ÷はPythonの//に相当する商を求める演算
function digitfloor(x::Real, digit::Int64)
    d = digit
    return Int(floor(x÷10^d)*10^d)
end

# あとは適当に処理してunstackするだけ
@linq innerjoin(df_receipt, df_customer, on=:customer_id) |>
    select(:gender_cd, :age, :amount) |>
    transform(age_cd = digitfloor.(:age, 1)) |>
    groupby([:gender_cd, :age_cd]) |>
    combine(:amount => sum) |>
    unstack(:age_cd, :gender_cd, :amount_sum) |>
    rename([:age_cd, :male, :female, :unknown])
```

<table class="data-frame"><thead><tr><th></th><th>age_cd</th><th>male</th><th>female</th><th>unknown</th></tr><tr><th></th><th>Int64</th><th>Int64?</th><th>Int64?</th><th>Int64?</th></tr></thead><tbody><p>9 rows × 4 columns</p><tr><th>1</th><td>10</td><td>1591</td><td>149836</td><td>4317</td></tr><tr><th>2</th><td>20</td><td>72940</td><td>1363724</td><td>44328</td></tr><tr><th>3</th><td>30</td><td>177322</td><td>693047</td><td>50441</td></tr><tr><th>4</th><td>40</td><td>19355</td><td>9320791</td><td>483512</td></tr><tr><th>5</th><td>50</td><td>54320</td><td>6685192</td><td>342923</td></tr><tr><th>6</th><td>60</td><td>272469</td><td>987741</td><td>71418</td></tr><tr><th>7</th><td>70</td><td>13435</td><td>29764</td><td>2427</td></tr><tr><th>8</th><td>80</td><td>46360</td><td>262923</td><td>5111</td></tr><tr><th>9</th><td>90</td><td><em>missing</em></td><td>6260</td><td><em>missing</em></td></tr></tbody></table>

いままではPythonと比較しながらSQLっぽく書ける強みを押してきましたが、今回はSQLっぽくない利点、つまり自由に関数を定義して手続き型で書けることを活用してみます。Pythonでの回答はその場で `floor` を使って上位の桁で丸めていますが、ここでは別途関数に独立させています。

その上でいままでと同様に列選択、変形、集約をして、 `unstack` で横持ちにしてクロス集計にしています。このあたりもpandasに慣れていれば戸惑うことはないでしょう。

---
#### One-hotエンコーディング

> 058: 顧客データフレーム（df_customer）の性別コード（gender_cd）をダミー変数化し、顧客ID（customer_id）とともに抽出せよ。結果は10件表示させれば良い。

```julia
# StatModels.jlを使う手もあるが、ここではscikit-learnを呼んでみることにする。どちらにしてもArrayになってしまうのでwrapperを用意。
function get_onehot(df::DataFrame, col::Symbol)
    binalizer = LabelBinarizer()
    mapper = DataFrameMapper([(col, binalizer)])
    onehotdf = DataFrame(Int.(fit_transform!(mapper, copy(df))))
    rename!(onehotdf, [string(col, "_", each) for each in binalizer.classes_])
    return onehotdf
end

@linq hcat(df_customer[:, :customer_id], get_onehot(df_customer, :gender_cd)) |>
    first(10)
```

<table class="data-frame"><thead><tr><th></th><th>x1</th><th>gender_cd_0</th><th>gender_cd_1</th><th>gender_cd_9</th></tr><tr><th></th><th>String?</th><th>Int64</th><th>Int64</th><th>Int64</th></tr></thead><tbody><p>10 rows × 4 columns</p><tr><th>1</th><td>CS021313000114</td><td>0</td><td>1</td><td>0</td></tr><tr><th>2</th><td>CS037613000071</td><td>0</td><td>0</td><td>1</td></tr><tr><th>3</th><td>CS031415000172</td><td>0</td><td>1</td><td>0</td></tr><tr><th>...</th><td>...</td><td>...</td><td>...</td><td>...</td></tr><tr><th>10</th><td>CS033513000180</td><td>0</td><td>1</td><td>0</td></tr></tbody></table>

機械学習の前処理でおなじみのone-hotエンコーディングをする問題です。scikit-learnのPythonコードを呼んでPython同様に処理できます。そのあと元データに `hcat` で横方向に結合しています。そのままではArrayになって列名も消えてしまうので、適宜そのあたりをお世話するwrapper関数を定義して使っています。

---
#### サンプリング
> 075: 顧客データフレーム（df_customer）からランダムに1%のデータを抽出し、先頭から10件データを抽出せよ。

```julia
n = nrow(df_receipt)
index = StatsBase.sample(1:n, n÷100, replace=false)
first(df_receipt[index, :], 10)
```

（出力は省略）

pandasは `sample()` があるので非常に便利ですが、JuliaのDataFrameにはありません。scikit-learnや [MLDataPattern.jl](https://github.com/JuliaML/MLDataPattern.jl) を使えば直接サンプリングできるものの、ArrayとDataFrameの間の変換が必要でかえって煩雑なため、取り出すインデックスをサンプリングするという経路で解きます。

---
#### 欠損値カウント

> 079: 商品データフレーム（df_product）の各項目に対し、欠損数を確認せよ。

```julia
Dict(zip(names(df_product), sum.(eachcol(ismissing.(df_product)))))
```

    Dict{String,Int64} with 6 entries:
      "category_major_cd"  => 0
      "category_medium_cd" => 0
      "category_small_cd"  => 0
      "unit_cost"          => 7
      "unit_price"         => 7
      "product_cd"         => 0

これまでにも `missing` は出てきていましたが、その数をカウントする方法も改めて確認しておきます。JuliaでもPythonとほぼ同様にハッシュテーブルとしてのdictionaryが使えます。ただ、専用の記法はなく、 `(key, value)` のタプルを入れていって使います。というわけで列ごとの `missing` を数えて列名といっしょにzipすることでdictにしてやります。

---
#### 平均値を結合
> 085: 顧客データフレーム（df_customer）の全顧客に対し、郵便番号（postal_cd）を用いて経度緯度変換用データフレーム（df_geocode）を紐付け、新たなdf_customer_1を作成せよ。ただし、複数紐づく場合は経度（longitude）、緯度（latitude）それぞれ平均を算出すること。

```julia
# いったん平均を取るために使う列だけで処理してから再度joinする
df_tmp = @linq innerjoin(df_customer,
                         @select(df_geocode, :postal_cd, :longitude, :latitude),
                         on=:postal_cd) |>
    select(:customer_id, :longitude, :latitude) |>
    groupby(:customer_id) |>
    combine(:longitude => mean, :latitude => mean);

df_customer_1 = innerjoin(df_customer, df_tmp, on=:customer_id);
```

`df_customer` には同じ `customer_id` で複数エントリ登録されている場合があるから注意しなさい、という問題だと思います。出力結果としてはオリジナルの列をすべて保持することが求められていますが、ずっと持ち続けようとすると緯度経度に集約関数を適用するときに他の列は集約してはいけないので困ってしまいます。回避方法はありそうですが、ここでは安直にいったん緯度経度だけに列を絞り、平均を取った上でオリジナルのテーブルに結合し直すことにします。

---
#### ふたつの緯度経度から距離計算
> 086: 前設問で作成した緯度経度つき顧客データフレーム（df_customer_1）に対し、申込み店舗コード（application_store_cd）をキーに店舗データフレーム（df_store）と結合せよ。そして申込み店舗の緯度（latitude）・経度情報（longitude)と顧客の緯度・経度を用いて距離（km）を求め、顧客ID（customer_id）、顧客住所（address）、店舗住所（address）とともに表示せよ。計算式は簡易式で良いものとするが、その他精度の高い方式を利用したライブラリを利用してもかまわない。結果は10件表示すれば良い。

```julia
# 緯度経度がDecimal型のままではdeg2radに入らないのでFloatにキャスト。ほかはPython版の式を移植しただけ。
function calc_distance(x1, y1, x2, y2)
    x1, y1, x2, y2 = deg2rad(Float64(x1)), deg2rad(Float64(y1)), deg2rad(Float64(x2)), deg2rad(Float64(y2))
    L = 6371 * acos(sin(y1)*sin(y2)
                    + cos(y1)*cos(y2)*cos(x1 - x2))
    return L
end

# 各カスタマーに対して店舗の緯度経度を取得
# joinの左右で列名が違う場合はこのようにon = :left => :rightとする
df_tmp = @linq innerjoin(@select(df_customer_1, :customer_id, :application_store_cd),
                         df_store,
                         on=:application_store_cd=>:store_cd) |>
    select(:customer_id,
           store_lat = :latitude,
           store_lon = :longitude);

# 顧客と店舗の緯度経度をjoin
df_tmp2 = innerjoin(@select(df_customer_1,
                            :customer_id,
                            customer_lat = :latitude_mean,
                            customer_lon = :longitude_mean),
                    df_tmp,
                    on=:customer_id);

# 距離を計算
df_tmp3 = @byrow! df_tmp2 begin
    @newcol distance::Array{Float64}
    :distance = calc_distance(:customer_lat, :customer_lon, :store_lat, :store_lon)
end;

@linq df_tmp3 |>
    orderby(:customer_id) |>
    first(3)
```

<table class="data-frame"><thead><tr><th></th><th>customer_id</th><th>customer_lat</th><th>customer_lon</th><th>store_lat</th><th>store_lon</th><th>distance</th></tr><tr><th></th><th>String?</th><th>Decimal…</th><th>Decimal…</th><th>Decimal…?</th><th>Decimal…?</th><th>Float64</th></tr></thead><tbody><p>3 rows × 6 columns</p><tr><th>1</th><td>CS001105000001</td><td>Decimal(0, 3554137, -5)</td><td>Decimal(0, 13970238, -5)</td><td>Decimal(0, 3555135, -5)</td><td>Decimal(0, 1397132, -4)</td><td>1.47105</td></tr><tr><th>2</th><td>CS001112000009</td><td>Decimal(0, 355867, -4)</td><td>Decimal(0, 13970386, -5)</td><td>Decimal(0, 3555135, -5)</td><td>Decimal(0, 1397132, -4)</td><td>3.17301</td></tr><tr><th>3</th><td>CS001112000019</td><td>Decimal(0, 3557153, -5)</td><td>Decimal(0, 13974687, -5)</td><td>Decimal(0, 3555135, -5)</td><td>Decimal(0, 1397132, -4)</td><td>4.11684</td></tr></tbody></table>

列がごちゃごちゃになるのを避けるために分割して処理しています。まず顧客ごとに店舗を結合してその緯度経度を取り、そのあとで改めて顧客の緯度経度と合わせ、最後に距離を計算しています。Juliaは当然数値計算が得意なので特に困る要素はありませんが、Pythonで書くときよりも型を意識する場面は多い印象です。ここでは度からラジアンに単位変換する前にfloat型への変換が必要です。行数は多いですが書いているとそんなにしんどい感じではありません。

---
#### データセット分割
> 090: レシート明細データフレーム（df_receipt）は2017年1月1日〜2019年10月31日までのデータを有している。売上金額（amount）を月次で集計し、学習用に12ヶ月、テスト用に6ヶ月のモデル構築用データを3セット作成せよ。


```julia
df_tmp = @linq df_receipt |>
    transform(month = :sales_ymd .÷ 100) |>
    groupby(:month) |>
    combine(:amount => sum) |>
    orderby(:month);

# スライスを指定するときに1-originでboth-sides inclusiveなことに注意する
function split_data(df::DataFrame; start_offset::Int64, train_size=12, test_size=6, slide_window=6)
    train_start = 1 + start_offset*6
    train_end = train_start + train_size - 1
    test_start = train_end + 1
    test_end = test_start +test_size - 1
    return df[train_start:train_end, :], df[test_start:test_end, :]
end

df_train_1, df_test_1 = split_data(df_tmp, start_offset=0)
df_train_2, df_test_2 = split_data(df_tmp, start_offset=1)
df_train_3, df_test_3 = split_data(df_tmp, start_offset=2);
```

月次集計した仮テーブルを作り、適当に切り出す関数を定義して使ってやるだけです。ただし、Juliaだと1-originかつスライスが閉区間、つまり両端を含みます。そのため、Pythonでのクセで `arr[1:5]` と `arr[5:10]` とか分割すると真ん中が被ってしまいます。注意しましょう。

---
#### 層化抽出
> 091: 顧客データフレーム（df_customer）の各顧客に対し、売上実績のある顧客数と売上実績のない顧客数が1:1となるようにアンダーサンプリングで抽出せよ。

```julia
# 売上実績ありの顧客に1のフラグを立てる
df_ifpurchased = @linq df_receipt |>
    select(:customer_id) |>
    unique() |>
    transform(if_purchased = (x -> 1).(:customer_id));

# 購買履歴を結合してmissingを0埋めすることで売上なしを示す
df_tmp = @linq leftjoin(df_customer, df_ifpurchased, on=:customer_id) |>
    transform(if_purchased = coalesce.(:if_purchased, 0));
```

まずは売上の有無のフラグとなる列を作ります。顧客テーブルに売上履歴テーブルを結合することを軸にして処理します。

```julia
# obddim=1を指定して縦長のテーブルであることを示す。
arr_sampled, _ = undersample((Array(df_tmp), df_tmp.if_purchased), obsdim=1)
df_sampled = DataFrame(arr_sampled)
rename!(df_sampled, names(df_tmp));

# 件数が揃っていることを確認
@linq df_sampled |>
    groupby(:if_purchased) |>
    combine(:customer_id => length)
```

<table class="data-frame"><thead><tr><th></th><th>if_purchased</th><th>customer_id_length</th></tr><tr><th></th><th>Any</th><th>Int64</th></tr></thead><tbody><p>2 rows × 2 columns</p><tr><th>1</th><td>1</td><td>8306</td></tr><tr><th>2</th><td>0</td><td>8306</td></tr></tbody></table>

そのうえで売上のフラグを使ってMLDataPattern.jlにおまかせでundersamplingします。ただ、デフォルトでは列方向にエントリが入っている横長のテーブルを想定している（正確に言うと最後の次元が観測の次元。 [ドキュメント参照](https://mldatapatternjl.readthedocs.io/en/latest/documentation/container.html#observation-dimension) ）ので、 `obsdim=1` を与えて縦長のテーブルであることを示す必要があります。

なお、この記事では割愛していますが上記リポジトリに載せたP-076の解き方と同様に、scikit-learnの `CrossValidation.train_test_split()` でstratified samplingしてやるのも手です。

---
#### ファイル読み書き
> 094 （CSV保存、ヘッダーあり、UTF-8）

```julia
# CSVモジュールにお任せ。デフォルトの設定でOK
CSV.write("data/category_julia.csv", df_product_full)
```

> P-097 （CSV読み込み、ヘッダーあり、UTF-8）

```julia
# 保存する時と書き方が対照的でないので戸惑うが、CSV.read()はdeprecatedなのでこちらで
df_read = DataFrame!(CSV.File("data/category_julia.csv"))
```

CSVファイルの読み書きもモジュールがあるので使うだけです。ほかのフォーマットでのデータ入出力に関しては、 [HDF5を扱ったこちらの記事](https://qiita.com/cometscome_phys/items/7ced3362ac64b98129d1) が参考になります。 [私が以前書いたFeatherフォーマットに関する記事](https://qiita.com/Ken-Kuroki/items/5fd74487c1e0247fdbb3) もよろしければご覧ください。

## まとめ
というわけで、ここでは抜粋のみ紹介しましたが、100問通してみたところ、Juliaで十分にやっていける印象を覚えました。pandasに機能では負けますが、むしろやりかたが多すぎず混乱が少ないですし、RやSQLのように見通しのよい書き方ができます。大きなデータフレームになったときの処理速度も気になるところですが、その点については [ちょっとした速度比較を以前書いた](https://qiita.com/Ken-Kuroki/items/4102f599f49540f80e97) のでよかったら参考にしてください。

本記事の内容は、データサイエンティスト協会の作成されたコンテンツに基づいています。特に、問題文および出力結果は [オリジナルのリポジトリ](https://github.com/The-Japan-DataScientist-Society/100knocks-preprocess) の内容によるものを引用の範囲内で利用させていただきました。実践的で教育的価値の高いコンテンツを公開してくださったことに感謝いたします。

## （追記）We’re hiring!
私が関わっているスタートアップ企業であるQuantomicsでは農業×ITの分野で一緒にデータ解析や技術開発をしてくださるサイエンティスト・エンジニアを募集しています。詳しくは[弊社ウェブサイト](https://quantomics.co.jp/)をご覧ください。