データサイエンス100本ノック(構造化データ加工編) を Julia で解いてみた。

## 100本ノックについて
データサイエンス100本ノック（構造化データ加工編） というのがあります。

https://github.com/The-Japan-DataScientist-Society/100knocks-preprocess

模範回答は、SQL、R、Pythonの3つが用意されていますが、それをJuliaで解いてみました。


## CSVの読み込み

↑URLのリポジトリの docker/work/data フォルダにCSVファイルがあるので、それをどこか適当なところに持ってきて読み込む。
```julia
using DataFramesMeta
using StatsBase
using CSV

dir = "/path/to/100knocks-preprocess/data/"
df_customers  = CSV.read(dir * "customer.csv", DataFrame)
df_categories = CSV.read(dir * "category.csv", DataFrame)
df_products   = CSV.read(dir * "product.csv", DataFrame)
df_geocodes   = CSV.read(dir * "geocode.csv", DataFrame)
df_receipts   = CSV.read(dir * "receipt.csv", DataFrame)
df_stores     = CSV.read(dir * "store.csv", DataFrame)
;
ENV["COLUMNS"] = 1000
;
```
`ENV["COLUMNS"]` を設定しているのは、それをデフォルトより大きく設定しないと列が欠落するから。数字は適当

単数形に違和感があったので、`df_customer` を `df_customers` のように複数形にしています。
以下、問題文は単数形ですが頭の中で読み替えてください。


# P-001
> レシート明細データ（df_receipt）から全項目の先頭10件を表示し、どのようなデータを保有しているか目視で確認せよ。

書き方は複数あります。
```julia
first(df_receipts, 10)
```
```julia
@linq df_receipts |> first(10)
```
```julia
@chain df_receipts begin
    first(10)
end
```
主に `@chain` マクロを使っていきます。
([`@linq` は非推奨らしいので](https://juliadata.github.io/DataFramesMeta.jl/stable/api/api/#DataFramesMeta.@linq-Tuple{Any}))

`@chain` マクロを使うと、関数の第1引数に前行の結果を勝手に入れてくれて省略できるので、第1引数がDataFrame型であるJuliaのデータフレーム関連の関数をすっきり書ける。


## P-004
> レシート明細データ（df_receipt）から売上日（sales_ymd）、顧客ID（customer_id）、商品コード（product_cd）、売上金額（amount）の順に列を指定し、以下の条件を満たすデータを抽出せよ。
> 顧客ID（customer_id）が"CS018205000001"
```julia
@chain df_receipts begin
    select(:sales_ymd, :customer_id, :product_cd, :amount)
    @subset(:customer_id .== "CS018205000001")
end
```
or
```julia
@chain df_receipts begin
    select(:sales_ymd, :customer_id, :product_cd, :amount)
    @rsubset(:customer_id == "CS018205000001")
end
```
SQLでいうWHERE句は、`@subset` `@rsubset` を使います。
`@rsubset` が各行ごとの比較になるので、基本 `@rsubset`　でよさげ。
`@subset`　を使う場合は、Vectorをブロードキャストするために　`==` ではなく、`.==` とドットを付けて評価させる必要あり。

## P-010
> 店舗データ（df_store）から、店舗コード（store_cd）が"S14"で始まるものだけ全項目抽出し、10件表示せよ。
```julia
@chain df_stores begin
    @rsubset(occursin(r"^S14", :store_cd))
    first(10)
end
```
`@rsubset` で評価する式は true or false を返す必要があり。
Juliaの関数名はアンダースコアをつけない文化があり、occurs in X (Xの中に存在するか)という英語で、`occursin` としているのだろうが他の言語にないネーミングセンスで最初戸惑う。

私はRuby脳なので、
`arr.select{|a| /^S14/ =~ a.store_cd }`
のように `=~` と書けて、 nil と false 以外を真として扱えるRubyがここで少し恋しくなりました。

## P-018
> 顧客データ（df_customer）を生年月日（birth_day）で若い順にソートし、先頭から全項目を10件表示せよ。
```julia
@chain df_customers begin
    sort(:birth_day, rev=true)
    first(10)
end
```
`sort` のデフォルトは昇順。降順にする場合は、`rev=true`

## P-019
> レシート明細データ（df_receipt）に対し、1件あたりの売上金額（amount）が高い順にランクを付与し、先頭から10件表示せよ。項目は顧客ID（customer_id）、売上金額（amount）、付与したランクを表示させること。なお、売上金額（amount）が等しい場合は同一順位を付与するものとする。
```julia
@chain df_receipts begin
    @transform(:rank = competerank(:amount, rev=true))
    select(:customer_id, :amount, :rank)
    sort(:amount, rev=true)
    first(10)
end
```
|Row|customer_id|amount|rank|
|--|--|--|--|
|1	|CS011415000006	|10925|	1|
|2	|ZZ000000000000	|6800|	2|
|3	|CS028605000002	|5780|	3|
|4	|CS015515000034	|5480|	4|
|5	|ZZ000000000000	|5480|	4|
|6	|ZZ000000000000	|5480|	4|
|7	|ZZ000000000000	|5440|	7|
|8	|CS021515000089	|5440|	7|
|9	|CS015515000083	|5280|	9|
|10	|CS017414000114	|5280|	9|

`@transform` で列の追加を行なう。(各行ごとの計算時は`@rtransform`を使う※後述)
ランク付けは StatsBase.competerank を使う。

## P-023
> レシート明細データ（df_receipt）に対し、店舗コード（store_cd）ごとに売上金額（amount）と売上数量（quantity）を合計せよ。
```julia
@chain df_receipts begin
    groupby(:store_cd)
    combine(:amount => sum, :quantity => sum)
end
```
|Row|	store_cd|	amount_sum|	quantity_sum|
|--|--|--|--|
|1|	S14006|	712839|	2284|
|2|	S13008|	809288|	2491|
|3|	S14028|	786145|	2458|
|⋮|	⋮|	⋮|	⋮|

`groupby`したデータフレームに対して、
`combine(:amount => sum)` すると、`amount` 列を `sum` した結果が `amount_sum` 列に保持される。
関数に渡すだけで勝手に列名をよしなにつけてくれるのは便利

## P-024
> レシート明細データ（df_receipt）に対し、顧客ID（customer_id）ごとに最も新しい売上年月日（sales_ymd）を求め、10件表示せよ。
```julia
@chain df_receipts begin
    groupby(:customer_id)
    combine(:sales_ymd => last) 
    sort(:customer_id)
    first(10)
end
```
|Row|	customer_id|	sales_ymd_last|
|--|	--|--|
|1|	CS001113000004|	20190308|
|2|	CS001114000005|	20190731|
|3|	CS001115000010|	20171228|
|⋮|	⋮|	⋮|	⋮|

`sum` 以外も同様。
`sales_ymd` の `last` イズ `sales_ymd_last` 
となるのは気持ちいい。

## P-031
> レシート明細データ（df_receipt）に対し、店舗コード（store_cd）ごとに売上金額（amount）の標準偏差を計算し、降順で5件表示せよ。
```julia
stdpop(x) = std(x, corrected=false)
@chain df_receipts begin
    groupby(:store_cd)
    combine(:amount => stdpop, :amount => std)
    sort(:amount_stdpop, rev=true)
    first(5)
end
```
|Row|	store_cd|	amount_stdpop	|amount_std |
|--|--|--|--|
|1|	S13052|	663.392 |	664.728|
|2|	S14011|	553.457 |	553.572|
|3|	S14034|	544.904 |	545.04|
|4|	S13001|	543.537 |	543.653|
|5|	S13015|	543.41 |	543.532|

`stdpop` が母標準偏差( $\frac{1}{N}$ で割った場合)
`std` が不偏標準偏差( $\frac{1}{N-1}$ で割った場合)

Juliaのデフォルトだと不偏標準偏差が出てくる。(`corrected=true`)

それだと模範解答と合わなかったので仕方なく母標準偏差を出す。

## P-037
> 商品データ（df_product）とカテゴリデータ（df_category）を内部結合し、商品データの全項目とカテゴリデータのカテゴリ小区分名（category_small_name）を10件表示せよ。
```julia
@chain df_products begin
    innerjoin(df_categories,
        on= :category_small_cd => :category_small_cd,
        makeunique=true
    )
    select(names(df_products), :category_small_name)
    first(10)
end
```
|Row|	product_cd|	category_major_cd|	category_medium_cd|	category_small_cd|	unit_price|	unit_cost|	category_small_name|
|--|--|--|--|--|--|--|--|
|1|	P040101001|	4|	401|	40101|	198|	149|	弁当類|
|2|	P040101002|	4|	401|	40101|	218|	164|	弁当類|
|3|	P040101003|	4|	401|	40101|	230|	173|	弁当類|
|4|	P040101004|	4|	401|	40101|	248|	186|	弁当類|
|⋮|	⋮|	⋮|	⋮||⋮|	⋮|	⋮|	⋮|

内部結合は `innerjoin` を使う。SQLと同じ名前の関数が用意されているので馴染みやすい。
ただ、列名が重複している場合がSQLと比較してつらい。
全部の列がくっつくので、joinする前にselectしたデータフレームを用意した方がいいのかも。
とりあえず`makeunique=true`で回避する(末尾に`_1`がつく)

[公式のドキュメント](https://dataframes.juliadata.org/stable/man/joins/#Renaming-joined-columns)曰く
`innerjoin(a, b, on=:ID, renamecols = "_left" => "_right")`
とすると、左右のテーブルの列名に指定の文字を追加できるらしいが、接頭辞ではなく接尾辞なのが微妙。

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

```julia
@chain df_receipts begin
    innerjoin(df_customers, 
        on= :customer_id => :customer_id,
        makeunique=true)
    @rtransform(:generation = floor(Int, :age / 10) * 10)
    groupby([:gender_cd, :generation])
    combine(:amount => sum)
    unstack(:generation, :gender_cd, :amount_sum)
    rename([:generation, :male, :female, :unknown])
end
```
|Row|	generation|	male|	female|	unknown|
|--|--|--|--|--|
|1|	10|	1591|	149836|	4317|
|2|	20|	72940|	1363724|	44328|
|3|	30|	177322|	693047|	50441|
|4|	40|	19355|	9320791|	483512|
|5|	50|	54320|	6685192|	342923|
|6|	60|	272469|	987741|	71418|
|7|	70|	13435|	29764|	2427|
|8|	80|	46360|	262923|	5111|
|9|	90|	missing|	6260|	missing|

`@rtransform` で、列の追加を行なう。
横持ちは `unstack` を使う。

## P-058
> 顧客データ（df_customer）の性別コード（gender_cd）をダミー変数化し、顧客ID（customer_id）とともに10件表示せよ。
```julia
@chain df_customers begin
    select(:customer_id, :gender_cd)
    @aside gender_cds = unique(df_customers.gender_cd) |> sort
    transform(:gender_cd => ByRow(v -> gender_cds .== v)
        => Symbol.(:gender_cd_, gender_cds))
    select(Not(:gender_cd))
    first(10)
end
```
|Row|	customer_id|	gender_cd_0|	gender_cd_1|	gender_cd_9|
|--|--|--|--|--|
|1|	CS021313000114|	false|	true|	false|
|2|	CS037613000071|	false|	false|	true|
|3|	CS031415000172|	false|	true|	false|
|⋮|	⋮|	⋮|	⋮|⋮|	

参考: https://yjunechoe.github.io/posts/2022-11-13-dataframes-jl-and-accessories/

ダミー変数化。one-hot encoding。
もっとうまい書き方がある気がするが、調べてもこれより良いのが見つからず。

納得がいかないので、この記事を書きながらまた書き直した。
```julia
@chain df_customers begin
    select(:customer_id, :gender_cd)
    @rtransform(:gender_cd_col = string("gender_cd_", :gender_cd))
    @rtransform(:gender_cd_val = true)
    unstack(:gender_cd_col, :gender_cd_val, fill=false)
    select(Not(:gender_cd))
    first(10)
end
```
|Row|	customer_id|	gender_cd_1|	gender_cd_9|	gender_cd_0|
|--|--|--|--|--|
|1|	CS021313000114|	true|	false|	false|
|2|	CS037613000071|	false|	true|	false|
|3|	CS031415000172|	true|	false|	false|
|⋮|	⋮|	⋮|	⋮|⋮|	


## P-059
> レシート明細データ（df_receipt）の売上金額（amount）を顧客ID（customer_id）ごとに合計し、売上金額合計を平均0、標準偏差1に標準化して顧客ID、売上金額合計とともに10件表示せよ。標準化に使用する標準偏差は、分散の平方根、もしくは不偏分散の平方根のどちらでも良いものとする。ただし、顧客IDが"Z"から始まるのものは非会員を表すため、除外して計算すること。
```julia
@chain df_receipts begin
    @rsubset(occursin(r"^[^Z]", :customer_id))
    groupby(:customer_id)
    combine(:amount => sum)
    @transform(:amount_sum_std = zscore(:amount_sum))
    sort(:customer_id)
    first(10)
end
```
平均0、標準偏差1に標準化は `zscore` を使う。
|Row|	customer_id|	amount_sum|	amount_sum_std|
|--|--|--|--|
|1|	CS001113000004|	1298|	-0.45935|
|2|	CS001114000005|	626|	-0.706348|
|3|	CS001115000010|	3044|	0.182403|
|⋮|	⋮|	⋮|	⋮|⋮|	



## P-078
> レシート明細データ（df_receipt）の売上金額（amount）を顧客単位に合計し、合計した売上金額の外れ値を抽出せよ。ただし、顧客IDが"Z"から始まるのものは非会員を表すため、除外して計算すること。なお、ここでは外れ値を第1四分位と第3四分位の差であるIQRを用いて、「第1四分位数-1.5×IQR」を下回るもの、または「第3四分位数+1.5×IQR」を超えるものとする。結果は10件表示せよ。
```julia
@chain df_receipts begin
    @rsubset(occursin(r"^[^Z]", :customer_id))
    groupby(:customer_id)
    combine(:amount => sum)
    @aside quartiles = quantile(_.amount_sum)
    @aside IQR = quartiles[4] - quartiles[2]
    @rsubset(:amount_sum < (quartiles[2] - 1.5*IQR)
        || (quartiles[4] + 1.5*IQR) < :amount_sum)
    sort(:customer_id)
    first(10)
end
```
|Row|	customer_id|	amount_sum|
|--|--|--|
|1|	CS001414000048|	8584|
|2|	CS001605000009|	18925|
|3|	CS002415000594|	9568|
|⋮|	⋮|	⋮|	⋮|


`@aside` は、chainマクロの中で次行に引き継ぎたくない計算のときに使う。

Statistics.quantile 関数は、四分位数と一緒に最小値と最大値も返してくることに注意。
```julia
quantile(df_receipts.amount)
# => 5-element Vector{Float64}:
    10.0 # ←最小値
   102.0 # ←第1四分位数
   170.0
   288.0
 10925.0 # ←最大値
```
Juliaの配列は0始まりのインデックスではなく1始まりであることもあり、
第1四分位数を取得するのに `quartiles[2]` になってしまうのがモヤる。


## P-086
> 085で作成した緯度経度つき顧客データに対し、会員申込店舗コード（application_store_cd）をキーに店舗データ（df_store）と結合せよ。そして申込み店舗の緯度（latitude）・経度情報（longitude)と顧客住所（address）の緯度・経度を用いて申込み店舗と顧客住所の距離（単位：km）を求め、顧客ID（customer_id）、顧客住所（address）、店舗住所（address）とともに表示せよ。計算式は以下の簡易式で良いものとするが、その他精度の高い方式を利用したライブラリを利用してもかまわない。結果は10件表示せよ。
```julia
latlngs = @chain df_geocodes begin
    groupby(:postal_cd)
    combine(:latitude => mean => :latitude, :longitude => mean => :longitude)
end

stores_latlngs = @chain df_stores begin
    select(:store_cd, :address => :store_address, 
        :latitude => :store_latitude, :longitude => :store_longitude)
end

@chain df_customers begin
    innerjoin(latlngs, on= :postal_cd => :postal_cd)
    innerjoin(stores_latlngs, on= :application_store_cd => :store_cd)
    @rtransform(:distance_to_store = begin
        ϕ₁, ϕ₂ = deg2rad.([:latitude, :store_latitude])
        λ₁, λ₂ = deg2rad.([:longitude, :store_longitude])
        6371 * acos(sin(ϕ₁)*sin(ϕ₂) + cos(ϕ₁)*cos(ϕ₂)*cos(λ₁ - λ₂))
    end)
    select(:customer_id, :address, :store_address, :distance_to_store)
    first(10)
end
```
| Row|	customer_id|address	|store_address|	distance_to_store|
|--|--|--|--|--|
| 1|	CS020301000012|	埼玉県川口市青木**********|	東京都北区十条仲原三丁目|	4.7222|
| 2|	CS051412000011|	埼玉県川口市飯塚**********|	東京都板橋区大原町|	3.40719|
| 3|	CS051412000012|	埼玉県川口市飯塚**********|	東京都板橋区大原町|	3.40719|
|⋮|	⋮|	⋮|	⋮|⋮|

Juliaで書いてて一番気持ちよかった問題。
`6371 * acos(sin(ϕ₁)*sin(ϕ₂) + cos(ϕ₁)*cos(ϕ₂)*cos(λ₁ - λ₂))`
という数式そのままを書いて、それが動く感動。


## その他の問題について
回答は以下に置いています。

https://github.com/tkmfujise/julia-100knocks-preprocess

Juliaはいいぞ。