📑

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

2023/03/06に公開

100本ノックについて

データサイエンス100本ノック(構造化データ加工編) というのがあります。

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

模範回答は、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_customerdf_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_ymdlast イズ 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 が母標準偏差( \frac{1}{N} で割った場合)
std が不偏標準偏差( \frac{1}{N-1} で割った場合)

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(λ₁ - λ₂))
という数式そのままを書いて、それが動く感動。

その他の問題について

回答は以下に置いています。

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

Juliaはいいぞ。

Discussion