Techブログ - MNTSQ, Ltd.

リーガルテック・カンパニー「MNTSQ(モンテスキュー)」のTechブログです。

JuliaとPythonを併用したデータ処理のススメ

MNTSQ Tech Blog TOP > 記事一覧 > JuliaとPythonを併用したデータ処理のススメ

f:id:mntsq:20201207122126p:plain

Pythonでデータ処理をしている際、numpyにはまらないごちゃごちゃした前処理があり、ちょっと遅いんだよなぁ。。。となること、ないでしょうか。

ルーチンになっている解析であれば高速化を頑張る意味がありそうですが、新しい解析を試行錯誤している最中など、わざわざ高速化のためのコードをガリガリ書いていくのは辛いぐらいのフェーズ、ないでしょうか。

こんなとき、私はJuliaを使っています。Juliaは特別な書き方をしなくても高速になる場合が多く、並列処理も簡単にできます。

julialang.org

Julia、いいらしいが名前は聞いたことがあるけど使うまでには至ってない、という方がと思います。今まで使っているコードの資産を書き直すのは嫌ですよね。

しかし、JuliaにはPythonの資産を活かしつつ高速にデータ処理がするための道具がそろっています。

今回の記事はPythonとJuliaをいったりきたりしながらデータ解析を行うのに役立つライブラリなどを紹介していきます。

そもそもなんでJuliaを使うのか

いろんなところに書いてあることではありますが、Juliaは

  • Pythonと同様に動的型付けなので、型を明示しなくても良い (明示してもよい)
  • Pythonと同様にREPL/Jupyter Notebook対応があるためEDAしやすい
  • Pythonと違ってJITがあるために、がんばってnumpyやnumbaで処理を書き換えなくても速い
  • Pythonよりも並列処理がやりやすい

という点がハッピーです。特に並列処理に関しては、Pythonと異なりGIL(Global Interpreter Lock)がないため、プロセスより通信のオーバーヘッドが小さいスレッドでちゃんとCPUヘビーな並列処理ができます。

OpenMPD言語のようにfor文に少し手を入れるだけで並列化ができるので、頑張ってvectorizeしたり、multiprocessingのために関数をラップしたりしなくてよいわけです。

Threads.@threads for i = 1:10
    a[i] = Threads.threadid()
end

並列処理のためにプロセスをまたがる通信を行う必要がないため、大きなDataFrameを複数プロセスで共有するために共有メモリを作ったりしなくてもよくなります。

ただ、Pythonからすっと移行できるよ!とはよく言われますが、結構とっつきにくいところもあるなというのが個人的な印象です。

それでも、とりあえず1日あればとりあえずボトルネックのコードはJuliaで書き直せるだろう、ぐらいの学習曲線だと思います。

DataFrames.jl

github.com

DataFrames.jlはJuliaにおけるPandasです。groupbyやaggに相当する機能など、集計に関する基本機能はすでに揃っており、categorical変数などももちろん扱えます。 Pandasに比較すると良いところとしては以下があります

  • Pandasは各行をiterateする処理が遅い (df.iterrowsなど)のですが、JuliaのDataFrames.jlはfor文が遅くないので、row-wiseの複雑な処理がやりやすいです
  • 後述のようにシリアライズ・デシリアライズが非常に速い形式が用意されています

ただし、もちろんPandasにしかない機能も大量に存在します。なので、複雑な処理をやりたい方は、まずJuliaでデータフレームのサイズを小さくするような前処理を高速にごりごりやって、それからPython/Pandasを使う、などがおすすめかもしれません。わたしは巨大なデータフレームをフィルタしたりするときによく使っています。

Pandasは(DataFrame.jlも)列志向のデータ構造なので、基本的には列で処理をするのがよいとされていますが、複雑な処理を行う際には、列単位で書くと煩雑になる場合も結構ありますよね。そういった場合、JuliaのDataFrame.jlは(列志向にも関わらず比較的)高速な処理が可能です。 実際にDataFrameの各行をiterateするfor loopを比較してみましょう。

Python:

# irisデータを読み込みます
from sklearn.datasets import load_iris
import pandas as pd
iris = load_iris()
df = pd.DataFrame(iris.data, columns=iris.feature_names)

# 行単位でループします
def iterate_iris(iris_df):
  result = 0
  for index, row in iris_df.iterrows():
    result += row["sepal length (cm)"]
  return result

# pd.DataFrame.sumメソッドを使います
def sum_iris(iris_df):
  return iris_df["sepal length (cm)"].sum()

Pythonだと、for文で6ms、sum関数で65μsでした。

In [27]: %timeit -n 1000 iterate_iris(df)
6.05 ms ± 16.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

In [28]: %timeit -n 1000  sum_iris(df)
65 µs ± 4.19 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Julia:

# irisデータを読み込みます
using RDatasets
iris = dataset("datasets", "iris")

変数irisにはDataFrames.jlのDataFrameが格納されています。Pandasっぽいですね。

julia> iris = dataset("datasets", "iris")
150×5 DataFrame
 Row │ SepalLength  SepalWidth  PetalLength  PetalWidth  Species
     │ Float64      Float64     Float64      Float64     Cat…
─────┼─────────────────────────────────────────────────────────────
   1 │         5.1         3.5          1.4         0.2  setosa
   2 │         4.9         3.0          1.4         0.2  setosa
   3 │         4.7         3.2          1.3         0.2  setosa
   4 │         4.6         3.1          1.5         0.2  setosa
   5 │         5.0         3.6          1.4         0.2  setosa
   6 │         5.4         3.9          1.7         0.4  setosa
   7 │         4.6         3.4          1.4         0.3  setosa
   8 │         5.0         3.4          1.5         0.2  setosa
   9 │         4.4         2.9          1.4         0.2  setosa
  10 │         4.9         3.1          1.5         0.1  setosa
  11 │         5.4         3.7          1.5         0.2  setosa
  12 │         4.8         3.4          1.6         0.2  setosa
  13 │         4.8         3.0          1.4         0.1  setosa
# 行単位でループします
function iterate_iris(iris_df)
  result = 0
  for row in eachrow(iris_df)
    result += row[:SepalLength] 
  end
  return result
end


# 多重Dispatchにより、SepalLengthのカラムの型(Array{Float64,1})に対応したsum関数が呼ばれます
function sum_iris(iris_df)
  return sum( iris[!,:SepalLength])
end

Juliaだと、for文で12μs、sum関数で74nsでした。

julia>using Benchmark # Benchmark.jlを使います
julia> @benchmark iterate_iris(iris) #@ほにゃほにゃ、というのがJuliaのマクロで、Pythonのdecoratorみたいなものだと考えるのがわかりやすいかもしれません
BenchmarkTools.Trial:
  memory estimate:  4.69 KiB
  allocs estimate:  300
  --------------
  minimum time:     10.170 μs (0.00% GC)
  median time:      11.127 μs (0.00% GC)
  mean time:        12.400 μs (0.83% GC)
  maximum time:     1.041 ms (98.88% GC)
  --------------
  samples:          10000
  evals/sample:     1

julia> @benchmark sum_iris(iris)
BenchmarkTools.Trial:
  memory estimate:  16 bytes
  allocs estimate:  1
  --------------
  minimum time:     66.047 ns (0.00% GC)
  median time:      69.725 ns (0.00% GC)
  mean time:        73.842 ns (0.45% GC)
  maximum time:     1.191 μs (94.23% GC)
  --------------
  samples:          10000
  evals/sample:     964

Juliaのほうがそれぞれ桁違いに速いですね。Julia/Pythonの速度とPandas/DataFrames.jlの速度を分離してはかってないですし、全然網羅的な検証ではないので大きな主語で何かを言うつもりはないのですが、Julia + DataFrame.jlであれば、for文でも全然許容可能な水準そうであるとは言ってもよいのではないでしょうか。 もちろんPythonもnumba、Cythonなどなど頑張れば速くなるのですが、上記の例を見ればわかるように、JuliaはふつうのPythonに近い書き方をしてこれぐらいの速度が出ます。これはかなり使いやすいです。

さらに、DataFrames.jlは非常にload/dumpがはやいです。JDF.jlというライブラリはマルチスレッドでデータを保存してくれ、PandasのI/Oに慣れていると本当に信じられないスピードで保存・読み込みが行われます。

PyCall.jl

github.com

さらに、PyCall.jlを使えばJuliaの中でPythonをシームレスに読み出すことができます!どういう感じか、実際にみてみましょう

juliaを起動します。ちなみに、julia --project=.として起動すると、そのフォルダのvirtualenvから起動できます

$ julia
               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.5.3 (2020-11-09)
 _/ |\__'_|_|_|\__'_|  |  Official https://julialang.org/ release
|__/                   |

julia>
julia> using Pkg

usingがPythonのimportです。Pkgがpipに相当する機能を持つライブラリです。

julia> Pkg.add("PyCall")
   Updating registry at `~/.julia/registries/General`
  Resolving package versions...
  Installed VersionParsing ─ v1.2.0
  Installed Conda ────────── v1.5.0
  Installed MacroTools ───── v0.5.6
  Installed PyCall ───────── v1.92.1
Updating `~/projects/julia-python/Project.toml`
  [438e738f] + PyCall v1.92.1
Updating `~/projects/julia-python/Manifest.toml`
  [8f4d0f93] + Conda v1.5.0
  [1914dd2f] + MacroTools v0.5.6
  [438e738f] + PyCall v1.92.1
  [81def892] + VersionParsing v1.2.0
   Building Conda ─→ `~/.julia/packages/Conda/x5ml4/deps/build.log`
   Building PyCall → `~/.julia/packages/PyCall/BcTLp/deps/build.log`

julia> using PyCall
[ Info: Precompiling PyCall [438e738f-606a-5dbb-bf0a-cddfbfd45ab0]

Pkg.addでPyCallをインストールして、using PyCallします

julia> sys = pyimport("sys")
PyObject <module 'sys' (built-in)>
julia> sys.path
4-element Array{String,1}:
 "/Users/user/.pyenv/versions/3.8.2/lib/python38.zip"
 "/Users/user/.pyenv/versions/3.8.2/lib/python3.8"
 "/Users/user/.pyenv/versions/3.8.2/lib/python3.8/lib-dynload"
 "/Users/user/.pyenv/versions/julia-python/lib/python3.8/site-packages"

sys.pathを読み取ることができました!

julia> sys.path[1]
"/Users/yotaro/.pyenv/versions/3.8.2/lib/python38.zip"

Python側でのsys.path[0]が返ってきます。Juliaの1-originとPythonの0-originを自動で変換してくれます。

さて、PandasのデータフレームをPyCallを通じて触ってみましょう。先程のPythonコードを

from sklearn.datasets import load_iris
import pandas as pd
iris = load_iris()
df = pd.DataFrame(iris.data, columns=iris.feature_names)

Juliaではこう書き換えれば良さそうです。

using PyCall
datasets = pyimport("sklearn.datasets")
pd = pyimport("pandas")
iris = datasets.load_iris()
df = pd.DataFrame(iris.data, columns=iris.feature_names)

が、これではエラーが出てしまいます。

julia> df = pd.DataFrame(iris.data, columns=iris.feature_names)
ERROR: type Dict has no field feature_names
Stacktrace:
 [1] getproperty(::Dict{Any,Any}, ::Symbol) at ./Base.jl:33
 [2] top-level scope at REPL[5]:1

irisはJuliaのDictになっています。

julia> iris
Dict{Any,Any} with 7 entries:
  "feature_names" => ["sepal length (cm)", "sepal width (cm)", "petal length (cm)", "petal width (cm)"]
  "frame"         => nothing
  "target_names"  => PyObject array(['setosa', 'versicolor', 'virginica'], dtype='<U10')
  "data"          => [5.1 3.5 1.4 0.2; 4.9 3.0 1.4 0.2; … ; 6.2 3.4 5.4 2.3; 5.9 3.0 5.1 1.8]
  "filename"      => "/Users/yotaro/.pyenv/versions/julia-python/lib/python3.8/site-packages/sklearn/datasets/data/iris.csv"
  "target"        => [0, 0, 0, 0, 0, 0, 0, 0, 0, 0  …  2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
  "DESCR"         => ".. _iris_dataset:\n\nIris plants dataset\n--------------------\n\n**Data Set Characteristics:**\n\n    :Number of Instances: 150 (50 in each of three classes)\n    :Number of Attributes: 4 numeric, predictive …

もともとPythonではsklearn.utils.Bunchというクラスでした。

In [10]: type(iris)
Out[10]: sklearn.utils.Bunch

In [11]: iris.feature_names
Out[11]:
['sepal length (cm)',
 'sepal width (cm)',
 'petal length (cm)',
 'petal width (cm)']

PyCallが適宜PythonのオブジェクトをJuliaのオブジェクトに変換してくれているのです。 primitiveなobjectの場合はメモリコピーを発生させずに直接アクセス・書き換えを行っているらしく、Juliaの関数でPythonの大きなデータをガンガンいじることができます。

irisがDIctになっていることを反映して先程のコードを書き換えると、PandasのDataFrameを得ることができました。PandasのDataFrameは残念ながらJuliaのDataFramesに自動変換といったことはできません。PyObjectとなってしまいます。

julia> df = pd.DataFrame(iris["data"], columns=iris["feature_names"])
PyObject      sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)
0                  5.1               3.5                1.4               0.2
1                  4.9               3.0                1.4               0.2
2                  4.7               3.2                1.3               0.2
3                  4.6               3.1                1.5               0.2
4                  5.0               3.6                1.4               0.2
..                 ...               ...                ...               ...
145                6.7               3.0                5.2               2.3
146                6.3               2.5                5.0               1.9
147                6.5               3.0                5.2               2.0
148                6.2               3.4                5.4               2.3
149                5.9               3.0                5.1               1.8

[150 rows x 4 columns]

しかし、DataFrameに生えている関数は全部自由に使えます

julia> df["sepal length (cm)"]
PyObject 0      5.1
1      4.9
2      4.7
3      4.6
4      5.0
      ...
145    6.7
146    6.3
147    6.5
148    6.2
149    5.9
Name: sepal length (cm), Length: 150, dtype: float64

julia> df.describe()
PyObject        sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)
count         150.000000        150.000000         150.000000        150.000000
mean            5.843333          3.057333           3.758000          1.199333
std             0.828066          0.435866           1.765298          0.762238
min             4.300000          2.000000           1.000000          0.100000
25%             5.100000          2.800000           1.600000          0.300000
50%             5.800000          3.000000           4.350000          1.300000
75%             6.400000          3.300000           5.100000          1.800000
max             7.900000          4.400000           6.900000          2.500000

さらに、PyCall.jlがPyObjectに対するsum関数を定義していてくれるために、以下のコードも動きます。ここではJuliaの多重ディスパッチの機能が働いて、df["sepal length (cm)"]PyObjectであることから、PyCallが登録しているsumが使われ、Pythonの方でsumが実行されている(はずです)。

julia> sum(df["sepal length (cm)"])
876.5000000000002

このように、かなり自然にPythonをJuliaの中で扱うことができます。PyCallには他にも様々な機能があるので、ぜひ使ってみてください。

PythonとJuliaでデータ、特にDataFrameをやり取りする

PyCallがあるといっても、まぁわざわざJuliaからPythonを呼んで全部Juliaでやる、というのも大変ではあります。PandasとDataFrames.jlのデータ変換は簡単ではありませんし。

だいたいは、重い前処理をJuliaでやって、あとはPythonでやる、みたいな使い方からJuliaを使い始める場合が多いのではないでしょうか。

そういった場合に困るのが、どうやってPythonとJuliaでデータをやり取りするか、です。

小さければPyCallでPickleを使うのが楽かもしれません。 大きめのデータについては、基本的にPythonで読み書きされているデータフォーマットについてはだいたいJuliaにもライブラリが用意されています

しかし、DataFrameについてはきちんと考える必要があります。 DataFrameはCSV.jlCSVに入出力することはとても簡単なのですが、

  • CSVだと読み込み・書き込みが遅い。ファイルサイズが大きい
  • 浮動小数点の精度が心配
  • カテゴリカル変数などの情報が残らない

などなどの悲しみがあります。PythonのPandasだとFeather, Parquet, HDF5などがDataFrameのシリアライズのフォーマットとして使われていると思いますが、執筆日ではFeather.jlがお手軽そうです。

  • HDF5は読み込むのは可能なのですが、DataFrames.jlには自分で変換する必要があります。
  • Parquetfiles.jlはParquetフォーマットを直接DataFrameに変換できるすぐれものなのですが、執筆日現在では依存ライブラリの更新で動きません。かなしい。
using ParquetFiles, DataFrame
df = load("data.parquet") |> DataFrame
  • Featherの読み書きはFeather.jlを通して、Pandasと不自由なく読み書きできます
using Feather
Feather.write("data.feather", df)

というわけで、PythonとJuliaを併用して、データを取り回しよく扱う方法について書いてみました!ぜひJuliaを使ってみてください。

この記事を書いた人

f:id:mntsq:20201113152310j:plain

堅山耀太郎

MNTSQ社で取締役として機械学習自然言語処理に関わるもろもろをやっています。好きな食べ物は担々麺です。