Python でデータ処理をしている際、numpyにはまらないごちゃごちゃした前処理があり、ちょっと遅いんだよなぁ。。。となること、ないでしょうか。 ルーチンになっている解析であれば高速化を頑張る意味がありそうですが、新しい解析を試行錯誤している最中など、わざわざ高速化のためのコードを ガリ ガリ 書いていくのは辛いぐらいのフェーズ、ないでしょうか。 こんなとき、私はJuliaを使っています。Juliaは特別な書き方をしなくても高速になる場合が多く、並列処理も簡単にできます。 julialang.org Julia、いいらしいが名前は聞いたことがあるけど使うまでには至ってない、という方がと思います。今まで使っているコードの資産を書き直すのは嫌ですよね。 しかし、Juliaには Python の資産を活かしつつ高速にデータ処理がするための道具がそろっています。 今回の記事は Python とJuliaをいったりきたりしながらデータ解析を行うのに役立つライブラリなどを紹介していきます。 そもそもなんでJuliaを使うのか DataFrames.jl PyCall.jl PythonとJuliaでデータ、特にDataFrameをやり取りする この記事を書いた人 そもそもなんで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 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をインストールしてパスが通っている前提です ( 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 に入出力することはとても簡単なのですが、 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を使ってみてください。 この記事を書いた人 堅山耀太郎 MNTSQ社で取締役として 機械学習 ・ 自然言語処理 に関わるもろもろをやっています。好きな食べ物は担々麺です。