juliaで前処理大全 2.抽出

(src=https://pixabay.com/photos/food-salad-raw-carrots-1209503/)

juliaで前処理大全をやります。今回は、データの読みこみと抽出のやり方を見ていきます。 内容は前処理大全の第2章に相当します。

@chainマクロを使ったパイプライン処理についても簡単に解説します。

ファイルを読み込む

CSV ファイルの読み込みでは、以下の方法をよく使います。

using DataFrames,CSV
df = CSV.read("file.csv", DataFrame)
😕 Warning!
最近、CSV.read関数は、出力するデータ型(この場合は DataFrame 型)を指定しないとエラーを吐くようになりました。

CSVファイル以外の読み込みでは、Excelから読み込むことが多いので、 ExcelFiles.jlをよく利用します。こちらはシート名を直接指定する必要があります。

using DataFrames, ExcelFiles
df = DataFrame(load("file.xlsx","Sheet1"))

datファイルなら、標準パッケージのDelimitedFiles.jlを利用すると読み込みが高速です。

using DataFrames,DelimitedFiles

df = DataFrame(readdlm("file.dat"))

そのほかに、Rとpythonで共通なFeather形式を利用していたこともありましたが、 ほかの言語で処理することもなかったし、特段ファイルサイズが小さくなるわけでもありませんでした。

さて、早速サンプルのホテルの予約データ(reserve.csv)を読み込んでいきます。 一行目のデータを表示してみましょう。

using DataFrames,CSV,Chain,Downloads

reserve_url = "https://raw.githubusercontent.com/hanafsky/awesomebook/master/data/reserve.csv"

reserve_df = @chain reserve_url Downloads.download CSV.File DataFrame
println(first(reserve_df))
DataFrameRow
 Row │ reserve_id  hotel_id  customer_id  reserve_datetime     checkin_date  checkin_time  checkout_date  people_num  total_price
     │ String7     String7   String7      String31             Dates.Date    Dates.Time    Dates.Date     Int64       Int64
─────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
   1 │ r1          h_75      c_1          2016-03-06 13:09:42  2016-03-26    10:00:00      2016-03-29              4        97200

いきなり@chainマクロを使ったパイプライン処理がでてきますが、これは次のセクションで解説します。

パイプライン処理

julia標準のパイプライン処理

「そもそもパイプライン処理とは何ぞや?」から解説したいと思います。 簡単な例として、次のような関数が入れ子になった計算をしたい場合を考えます。

sin(cos(tan(1)))

関数名が短い場合は良いのですが、このような入れ子になった式は、一般的に可読性が高いとは言えません。 そこで、作用させる関数を後から追加できるように、juliaでは次のパイプ演算子|>が用意されています。 フォントの関係で右三角に見えますが、実際の入力は|>です。

1 |> tan |> cos |> sin

このような記法を採用するメリットは、可読性が高まるだけでなく、実行結果を確認しながら後で関数を追加できることです。

引数が複数ある場合の対応

ところが、複数の引数をとる関数をパイプライン処理で実装したいときは、 そのままでは実行できない問題が発生します。そこで、無名関数を使う必要がでてきます。

add(a,b) = a+b
1 |> (x -> add(x,2))

ただし、このような書き方をすると可読性が犠牲になります。

この問題を解決できるのがパイプライン処理のパッケージです。

Chain.jl パッケージ

パイプライン処理のパッケージはいくつかありますが、ここではChain.jlを使うことにします。 使い方を簡単に解説します。

@chainマクロを使うと、そもそもパイプ演算子|>を記述する必要がなくなります。 最初の例は、次のように一行で書けます。

using Chain
@chain 1 tan cos sin

2番目の例のように、複数の引数をとる関数がきても大丈夫です。

add(a,b) = a+b
@chain 1 add(2) add(3)
6

左側の計算結果を、次の関数の最初に引数として扱ってくれるからです。 この@chainマクロが何をしているか展開して確認してみると

using Chain
@macroexpand @chain 1 add(2) add(3) add(4)
quote
    local var"##444" = 1
    local var"##445" = add(var"##444", 2)
    local var"##446" = add(var"##445", 3)
    local var"##447" = add(var"##446", 4)
    var"##447"
end

複数行で実行

複数行で実行したい場合は、begin-end環境で囲います。 最初の例でやってみましょう。

@chain 1 begin
  tan
  cos
  sin
end
0.013387802193205699

引数の位置を明示

引数の位置を明示したい場合は、_(アンダースコア)を用います。 これは、ほかのパイプライン処理のパッケージでも同じ実装であることが多いです。

マクロを展開すると_の位置がlocal変数で書き換えられていることがわかると思います。

@macroexpand(@chain reserve_url begin
  Downloads.download(IOBuffer())
  String(take!(_))
  CSV.read(IOBuffer(_),DataFrame)
end) |>Base.remove_linenums!
quote
    local var"##452" = reserve_url
    local var"##453" = Downloads.download(var"##452", IOBuffer())
    local var"##454" = String(take!(var"##453"))
    local var"##455" = CSV.read(IOBuffer(var"##454"), DataFrame)
    var"##455"
end

@asideマクロ

パイプライン処理の途中の結果で何かをしたいときには、@asideマクロが使えます。 そのうち使うかもしれません。

@chain 1 begin
  tan
  @aside println(_)
  cos
  sin
end
1.5574077246549023
0.013387802193205699

@chainマクロとDataFrames.jlは非常に相性が良いので、この先ガンガン使っていきます。

抽出

データ列の抽出

前処理大全では、可読性を高めるために、データの列番号ではなく、ラベルによって列を抽出することが推奨されています。 まず、ラベル名を確認しておきます。

@show names(reserve_df);
names(reserve_df) = ["reserve_id", "hotel_id", "customer_id", "reserve_datetime", "checkin_date", "checkin_time", "checkout_date", "people_num", "total_price"]

試しにreserve_idhotel_idの列を抽出してみます。

String で指定

オーソドックスなのは、ラベル名をString型で指定する場合です。

reserve_df[!,["reserve_id","hotel_id"]] |> first |> println
DataFrameRow
 Row │ reserve_id  hotel_id
     │ String7     String7
─────┼──────────────────────
   1 │ r1          h_75

Symbol で指定

同じことを Symbol型で指定してやってみます。

reserve_df[!,[:reserve_id,Symbol("hotel_id")]] |> first |> println
println()
@show typeof(:reserve_id)

DataFrameRow
 Row │ reserve_id  hotel_id
     │ String7     String7
─────┼──────────────────────
   1 │ r1          h_75

typeof(:reserve_id) = Symbol
ここで、:reserve_idSymbol("reserve_id")は同じ結果を示します。 ともに型はSymbol型です。

:reserve_idの方が、タイプ数が少ないので省エネです。

また、一列だけ抽出したいときは、df.reserve_idのようにすることもできます。 ただし、返り値の型はDataFrame型ではなく、Vecter型になります。

@show reserve_df.reserve_id[1:5]
println()
@show typeof(reserve_df.reserve_id)
reserve_df.reserve_id[1:5] = InlineStrings.String7["r1", "r2", "r3", "r4", "r5"]

typeof(reserve_df.reserve_id) = Vector{InlineStrings.String7}
😕 Warning!

いつでも:reserve_idのようにできるわけではありません。よくあるトラップが、 ラベル名に%/などの2項演算子が文字列に使われている時です。

testdf = DataFrame("a%"=>"a", "b(m/s)"=>1:1e5)
println(first(testdf))
DataFrameRow
 Row │ a%      b(m/s)
     │ String  Float64
─────┼─────────────────
   1 │ a           1.0

この場合には、df[!,Symbol("a%")]のようにして指定する必要があります。 演算子がラベル名に使われている場合は、私は最初にラベル名を当たり障りのないように変更するようにしています。

😲 Note

reserve_df[:,["reserve_id"]]でもreserve_df[!,["reserve_id"]] でも、返り値は同じです。 ただし、前者の場合は新しくメモリ割り当てが発生します。その結果として実行速度も遅くなります。

@time testdf[:,["a%","b(m/s)"]]
println()
@time testdf[!,["a%","b(m/s)"]]
  0.006153 seconds (4.72 k allocations: 1.858 MiB, 93.06% compilation time)

  0.000017 seconds (18 allocations: 1.391 KiB)

select 関数

列を抽出するには、select 関数も利用できます。

select(reserve_df,:reserve_id, :hotel_id) |> first |> println
DataFrameRow
 Row │ reserve_id  hotel_id
     │ String7     String7
─────┼──────────────────────
   1 │ r1          h_75

select関数には、新しい列を作る機能もあるので、 後で活躍しそうです。

Not や正規表現による抽出

ラベルがidで終わる列を正規表現で抽出します。

reserve_df[!,r"id$"] |> first |> println

DataFrameRow
 Row │ reserve_id  hotel_id  customer_id
     │ String7     String7   String7
─────┼───────────────────────────────────
   1 │ r1          h_75      c_1
😕 Warning!
前処理大全では、ずっと使うコードにこのような抽出方法を採用することは避けるべきだとされています。

ラベル名がidで終わらない列を抽出するには、Not に入れてしまうと楽です。 Not の中身は、直接ラベル名を指定することもできるので有用です。

reserve_df[!,Not(r"id$")] |> first |> println
DataFrameRow
 Row │ reserve_datetime     checkin_date  checkin_time  checkout_date  people_num  total_price
     │ String31             Dates.Date    Dates.Time    Dates.Date     Int64       Int64
─────┼─────────────────────────────────────────────────────────────────────────────────────────
   1 │ 2016-03-06 13:09:42  2016-03-26    10:00:00      2016-03-29              4        97200

条件指定による抽出

checkin_dateが2016-10-12から2016-10-13までのデータを抽出します。 ここではfilter関数を使います。 filter関数には、見ての通り2通りの使い方があります。 基本的には無名関数を使って、引数x-> a<x<bのような書き方をしています。

using Dates: Date
# filter(:checkin_date => row-> Date(2016,10,12) <= row <= Date(2016,10,13), reserve_df) |> println
filter(row-> Date(2016,10,12) <= row.checkin_date <= Date(2016,10,13), reserve_df) |> println
8×9 DataFrame
 Row │ reserve_id  hotel_id  customer_id  reserve_datetime     checkin_date  checkin_time  checkout_date  people_num  total_price
     │ String7     String7   String7      String31             Dates.Date    Dates.Time    Dates.Date     Int64       Int64
─────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
   1 │ r285        h_121     c_67         2016-09-27 06:13:19  2016-10-12    12:00:00      2016-10-14              4       184000
   2 │ r1933       h_113     c_477        2016-09-24 09:04:26  2016-10-12    11:30:00      2016-10-13              4        77200
   3 │ r2291       h_230     c_574        2016-10-09 04:34:14  2016-10-12    12:00:00      2016-10-13              1        17400
   4 │ r2524       h_203     c_631        2016-09-14 10:45:15  2016-10-12    10:30:00      2016-10-14              3       167400
   5 │ r3147       h_163     c_794        2016-10-02 07:35:16  2016-10-13    09:00:00      2016-10-16              1        64200
   6 │ r3328       h_23      c_833        2016-09-28 08:22:57  2016-10-13    09:00:00      2016-10-14              4       260400
   7 │ r3381       h_110     c_844        2016-09-17 17:44:02  2016-10-13    12:30:00      2016-10-15              1        52800
   8 │ r3444       h_14      c_859        2016-10-03 17:26:00  2016-10-13    12:30:00      2016-10-15              3        46200

上の例では1行で書きましたが、いくつかの前処理を一気に行うなら、以下のように複数行に分けたほうが、可読性は高いでしょう。

using Chain
@chain reserve_df begin
  filter(:checkin_date => >=(Date(2016,10,12)),_)
  filter(:checkin_date => <=(Date(2016,10,13)),_)
  # println
end

データ値に基づかないサンプリング

データ数を大体半分にしたいときの処方箋です。 pythonやRのようにDataFrame用にsample関数のようなものがあってもよさそうですが、 ないみたいなので、StatsBase.jlのsample関数を利用します。 DataFrameの行数から重複を許さずに、半分の値をサンプリングします。 そして、それを行番号として指定します。

using StatsBase: sample 
sample_row = sample(1:nrow(reserve_df), round(Int,nrow(reserve_df)/2), replace=false)
reserve_df[sample_row,:]

SQLで出てきた、乱数を使って、データ数を約半分にする方法も使えそうです。

using Chain
reserve_df.rand=rand(nrow(reserve_df))
sdf = @chain reserve_df begin
  filter(:rand =><(0.5),_)
  select!(Not(:rand))
end
println(first(sdf))
DataFrameRow
 Row │ reserve_id  hotel_id  customer_id  reserve_datetime     checkin_date  checkin_time  checkout_date  people_num  total_price
     │ String7     String7   String7      String31             Dates.Date    Dates.Time    Dates.Date     Int64       Int64
─────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
   1 │ r4          h_214     c_1          2017-03-08 03:20:10  2017-03-29    11:00:00      2017-03-30              4       194400

2-4 集約IDに基づくサンプリング

顧客IDを集約して、半分にサンプリングし、顧客IDによるデータの抽出を行います。 IDの重複をなくすにはunique関数が便利です。

@chain reserve_df.customer_id begin
  unique
  sample(_, round(Int,length(_)/2),replace=false)
  filter(:customer_id => in(_),reserve_df) 
end

unique関数はデータフレーム全体に適用することも可能です。

DataFrame(a=[1,1,2],b=["a","a","b"]) |> unique
2×2 DataFrame
 Row │ a      b
     │ Int64  String
─────┼───────────────
   1 │     1  a
   2 │     2  b

つづく