Pythonでデータ処理をしている際、numpyにはまらないごちゃごちゃした前処理があり、ちょっと遅いんだよなぁ。。。となること、ないでしょうか。
ルーチンになっている解析であれば高速化を頑張る意味がありそうですが、新しい解析を試行錯誤している最中など、わざわざ高速化のためのコードをガリガリ書いていくのは辛いぐらいのフェーズ、ないでしょうか。
こんなとき、私はJuliaを使っています。Juliaは特別な書き方をしなくても高速になる場合が多く、並列処理も簡単にできます。
Julia、いいらしいが名前は聞いたことがあるけど使うまでには至ってない、という方がと思います。今まで使っているコードの資産を書き直すのは嫌ですよね。
しかし、JuliaにはPythonの資産を活かしつつ高速にデータ処理がするための道具がそろっています。
今回の記事はPythonとJuliaをいったりきたりしながらデータ解析を行うのに役立つライブラリなどを紹介していきます。
そもそもなんでJuliaを使うのか
いろんなところに書いてあることではありますが、Juliaは
- Pythonと同様に動的型付けなので、型を明示しなくても良い (明示してもよい)
- Pythonと同様にREPL/Jupyter Notebook対応があるためEDAしやすい
- Pythonと違ってJITがあるために、がんばってnumpyやnumbaで処理を書き換えなくても速い
- Pythonよりも並列処理がやりやすい
という点がハッピーです。特に並列処理に関しては、Pythonと異なりGIL(Global Interpreter Lock)がないため、プロセスより通信のオーバーヘッドが小さいスレッドでちゃんとCPUヘビーな並列処理ができます。
OpenMPやD言語のようにfor文に少し手を入れるだけで並列化ができるので、頑張ってvectorizeしたり、multiprocessingのために関数をラップしたりしなくてよいわけです。
Threads.@threads for i = 1:10 a[i] = Threads.threadid() end
並列処理のためにプロセスをまたがる通信を行う必要がないため、大きなDataFrameを複数プロセスで共有するために共有メモリを作ったりしなくてもよくなります。
ただ、Pythonからすっと移行できるよ!とはよく言われますが、結構とっつきにくいところもあるなというのが個人的な印象です。
- オブジェクト指向ではなく、多重ディスパッチ(C++などでもありますね)によるプログラミングモデル
- クラスはありませんが、Cなどの構造体に相当する複合型が存在します
- 関数と型の組み合わせですべてを記述していきます。引数の型に応じて自動的に使われる関数が選択されます
- Pythonでいうと、すべての関数をfrom xxx import *しているのに相当する書き方(using XXX)が使われることが多いです
- 変数のスコープに関して仕様が複雑
- REPL上とscirpt内での挙動が変わります
- 配列が1-origin
- array[0]はエラーになります
- ここが地味にPythonからの移行で一番面倒な気がします
それでも、とりあえず1日あればとりあえずボトルネックのコードはJuliaで書き直せるだろう、ぐらいの学習曲線だと思います。
DataFrames.jl
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を比較してみましょう。
# 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
さらに、PyCall.jlを使えばJuliaの中でPythonをシームレスに読み出すことができます!どういう感じか、実際にみてみましょう
- Juliaをインストールしてパスが通っている前提です (Macであればbrew install juliaでOKです)
- 使うPython環境をPYTHON環境変数に指定してください
- PythonがJuliaから呼べるようにビルドされている必要があります
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にもライブラリが用意されています
- JSON.jl
- Msgpack.jl
- その他、だいたいなんでもあります
しかし、DataFrameについてはきちんと考える必要があります。 DataFrameはCSV.jlで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を使ってみてください。