データサイエンス100本ノック(構造化データ加工編) を Julia で解いてみた。
100本ノックについて
データサイエンス100本ノック(構造化データ加工編) というのがあります。
模範回答は、SQL、R、Pythonの3つが用意されていますが、それをJuliaで解いてみました。
CSVの読み込み
↑URLのリポジトリの docker/work/data フォルダにCSVファイルがあるので、それをどこか適当なところに持ってきて読み込む。
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件を表示し、どのようなデータを保有しているか目視で確認せよ。
書き方は複数あります。
first(df_receipts, 10)
@linq df_receipts |> first(10)
@chain df_receipts begin
first(10)
end
主に @chain
マクロを使っていきます。
(@linq
は非推奨らしいので)
@chain
マクロを使うと、関数の第1引数に前行の結果を勝手に入れてくれて省略できるので、第1引数がDataFrame型であるJuliaのデータフレーム関連の関数をすっきり書ける。
P-004
レシート明細データ(df_receipt)から売上日(sales_ymd)、顧客ID(customer_id)、商品コード(product_cd)、売上金額(amount)の順に列を指定し、以下の条件を満たすデータを抽出せよ。
顧客ID(customer_id)が"CS018205000001"
@chain df_receipts begin
select(:sales_ymd, :customer_id, :product_cd, :amount)
@subset(:customer_id .== "CS018205000001")
end
or
@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件表示せよ。
@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件表示せよ。
@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)が等しい場合は同一順位を付与するものとする。
@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)を合計せよ。
@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件表示せよ。
@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件表示せよ。
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
が母標準偏差(
std
が不偏標準偏差(
Juliaのデフォルトだと不偏標準偏差が出てくる。(corrected=true
)
それだと模範解答と合わなかったので仕方なく母標準偏差を出す。
P-037
商品データ(df_product)とカテゴリデータ(df_category)を内部結合し、商品データの全項目とカテゴリデータのカテゴリ小区分名(category_small_name)を10件表示せよ。
@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
がつく)
公式のドキュメント曰く
innerjoin(a, b, on=:ID, renamecols = "_left" => "_right")
とすると、左右のテーブルの列名に指定の文字を追加できるらしいが、接頭辞ではなく接尾辞なのが微妙。
P-043
レシート明細データ(df_receipt)と顧客データ(df_customer)を結合し、性別コード(gender_cd)と年代(ageから計算)ごとに売上金額(amount)を合計した売上サマリデータを作成せよ。性別コードは0が男性、1が女性、9が不明を表すものとする。
ただし、項目構成は年代、女性の売上金額、男性の売上金額、性別不明の売上金額の4項目とすること(縦に年代、横に性別のクロス集計)。また、年代は10歳ごとの階級とすること。
@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件表示せよ。
@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。
もっとうまい書き方がある気がするが、調べてもこれより良いのが見つからず。
納得がいかないので、この記事を書きながらまた書き直した。
@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"から始まるのものは非会員を表すため、除外して計算すること。
@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件表示せよ。
@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 関数は、四分位数と一緒に最小値と最大値も返してくることに注意。
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件表示せよ。
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(λ₁ - λ₂))
という数式そのままを書いて、それが動く感動。
その他の問題について
回答は以下に置いています。
Juliaはいいぞ。
Discussion