うわっ…私のpandas、遅すぎ…?って時にやるべきこと(先人の知恵より)
こちらに書かれている通り、pandasはたしかに遅い、特に使い方によっては極端に遅い。かといってforループを回避するためにあれこれ読みにくいコードを書きたくない。……だったらJuliaにしたらいいよ! という記事です。
pandasはどのくらい遅いか?
元記事のデータは非公開のコードで加工されたもののようなので、ここでは他の公共データセットのデータを使わせてもらいます。Scikit-learnに入っているCalifornia Housingを使いましょう。約2万件しかデータがないのはちょっと物足りないので10回連結して約20万件に水増しします。
import sklearn
from sklearn.datasets import fetch_california_housing
import pandas as pd
cal_housing = fetch_california_housing(as_frame=True)["data"]
cal_housing.to_csv("california_housing.csv", index=False) # save it for Julia
cal_housing.shape # (20640, 8)
cal_housing_long = pd.concat([cal_housing for i in range(10)])
cal_housing_long.shape # (206400, 8)
このデータでは(Kaggleにあるものと違って)合計のHousehold数のカラムがありません。PopulationとAverage Occupancyから計算してみることにしましょう(こんなものはわざわざforループを使うまでもない? おっしゃる通りです。その点も含めて比較します。ただ、実際のユースケースでは複雑な処理を行うこともあり、素直にロジックを書き下していけるforループは便利かと思います。)。
Forループで計算
まずは安直にforループで。
def calc_numhouses_iter(df: pd.DataFrame) -> pd.DataFrame:
for i, row in df.iterrows():
df.at[i, "NumHouses"] = row["Population"] / row["AveOccup"]
return df
%time calc_numhouses_iter(cal_housing_long)
CPU times: user 1min, sys: 708 ms, total: 1min 1s
Wall time: 1min 5s
一分以上! ずいぶんかかってしまいました。もっとも、forループが遅いのは既知の話ですが。
Applyは速いか?
こういう場合の定石はapplyを使うことです。続いて試してみましょう。
def calc_numhouses_apply(row: pd.Series) -> float:
return row["Population"] / row["AveOccup"]
%time cal_housing_long.apply(calc_numhouses_apply, axis=1)
CPU times: user 4.75 s, sys: 42.4 ms, total: 4.79 s
Wall time: 5.2 s
大幅に速くなりました。これならあまり問題ないかもしれません。でももっと大きなデータだったらどうなるでしょう? データ量に対してどんなオーダーで所要時間が延びるのでしょう? さらに10倍に増やしてみましょう。
cal_housing_long_long = pd.concat([cal_housing_long for i in range(10)])
cal_housing_long_long.shape # (2064000, 8)
%time cal_housing_long_long.apply(calc_numhouses_apply, axis=1)
CPU times: user 47.6 s, sys: 438 ms, total: 48.1 s
Wall time: 50.3 s
実行時間も10倍になりました。自然な結果ですが、200万件、200MB程度のCSVでこれだけかかるのはちょと厳しい用途もあるかもしれません。
列を減らしてみる
列が多すぎるのがpandasが遅くなる要因ということなので、減らして実験してみます。
cal_housing_long_long_fewcol = cal_housing_long_long.loc[:, ["Population", "AveOccup"]]
%time cal_housing_long_long_fewcol.apply(calc_numhouses_apply, axis=1)
CPU times: user 47.7 s, sys: 368 ms, total: 48 s
Wall time: 49.9 s
差が見られません。今回のデータではもともと8列しかないこと、またすべての列が浮動小数点型なので行方向にpd.Seriesを作ったときの型がfloatのままでいけるという事情が効いているかもしれません。
その他?
元記事で挙げられているmapを使う方法はいったん一つの列に統合する必要があります。文字列結合ならすでに高速なメソッドが用意されているので簡単ですが、数値の場合はたとえばtupleにまとめるにしても、けっきょく一度applyを使う必要があり、素直に速くすることは難しそうです。できたとしても見通しがいいコードにはなりそうにありません。ほかにもdask、vaex、swifterなどあれこれ使う方法もありますが、そこまでするならもうPythonでなくてもいいような気がしてきませんか?
Julia
続いてJuliaでやってみましょう。同様に下準備、こちらはパッケージのインストールから書いています。データは先ほどScikit-learnから保存したCSVを読み込みます。
import Pkg
Pkg.add("DataFrames")
Pkg.add("DataFramesMeta")
Pkg.add("CSV")
Pkg.add("BenchmarkTools")
using DataFrames
using DataFramesMeta
using CSV
using BenchmarkTools
cal_housing = DataFrame!(CSV.File("california_housing.csv"));
cal_housing_long = vcat([cal_housing for i in 1:10]...); # list expansion by ...
cal_housing_long_long = vcat([cal_housing_long for i in 1:10]...);
Forループで十分速い
function calc_numhouses_iter(df::DataFrame)::DataFrame
len = size(df, 1)
df[!, :NumHouses] .= 0.0
for i in 1:len
df[i, :NumHouses] = df[i, :Population] / df[i, :AveOccup]
end
return df
end
@benchmark calc_numhouses_iter(cal_housing_long_long)
BenchmarkTools.Trial:
memory estimate: 330.66 MiB
allocs estimate: 20637969
minimum time: 470.266 ms (4.60% GC)
median time: 475.537 ms (6.13% GC)
mean time: 477.809 ms (6.20% GC)
maximum time: 491.495 ms (5.62% GC)
samples: 11
evals/sample: 1
こちらも極めて安直なforループです。Juliaを知らなくてもまったく支障なく読めるかと思います。そしていきなりオリジナルの100倍サイズでやっていますが、十分に高速です。ざっとPythonの100倍速い。これがこんなシンプルで可読性の高いコードでできるのはさすがJuliaですね。
単に四則演算する
実際は今回の問題はただの四則演算なので、もっと簡単にできてしまいます。
function calc_justcalc(df::DataFrame)::DataFrame
df[!,:NumHouses] = df[!,:Population] ./ df[!,:AveOccup]
return df
end
@benchmark calc_justcalc(cal_housing_long_long)
BenchmarkTools.Trial:
memory estimate: 15.75 MiB
allocs estimate: 5
minimum time: 2.293 ms (0.00% GC)
median time: 8.054 ms (0.00% GC)
mean time: 8.260 ms (21.57% GC)
maximum time: 95.126 ms (90.65% GC)
samples: 609
evals/sample: 1
予想通り、こちらの方が大幅に速いですね。ちなみに、記述が前後しますが同様に単にベクトルの四則演算でPythonで計算すると以下の通りで、これでもまだJuliaのほうが速いという結果になりました。
CPU times: user 16.6 ms, sys: 14.9 ms, total: 31.5 ms
Wall time: 38.4 ms
自作の関数を適用
ループの一回一回に依存関係がなければ、自分で作った関数を簡単にベクトル化して適用することもできます。試してみましょう。
function calc_eachrow(x1::Float64, x2::Float64)::Float64
x1 / x2
end
function calc_byfunc(df::DataFrame)::DataFrame
df[!, :NumHouses] = calc_eachrow.(df[!, :Population], df[!, :AveOccup])
df
end
@benchmark calc_byfunc(cal_housing_long_long)
BenchmarkTools.Trial:
memory estimate: 15.75 MiB
allocs estimate: 5
minimum time: 2.489 ms (0.00% GC)
median time: 9.123 ms (0.00% GC)
mean time: 10.485 ms (22.33% GC)
maximum time: 182.663 ms (86.56% GC)
samples: 478
evals/sample: 1
こちらも極めて速い結果、単に四則演算をベクトルに適用したのと同じ速さになりました。Pythonでnumpyの関数を使う場合は用意された機能以外のことをしたくなった途端には遅くなってしまいますが、このJuliaの関数なら自由に拡張し放題です。ここで、いちいち return
を書かずに省略しているところがあります。Juliaでは最後に評価したexpressionの値が返されるので、 return
があるものと思って読んでください。
条件分岐があっても速いか?
ここまでに行った処理は最適化も容易な一律な四則演算のみでした。では、処理内容に条件分岐があったらどうなるでしょう? SIMD命令での最適化といったコンパイラの強みが効かなくなって遅くなってしまうのでしょうか? 実験してみます。
function calc_numhouses_iter_if(df::DataFrame)::DataFrame
len = size(df, 1)
df[!, :NumHouses] .= 0.0
for i in 1:len
if df[i, :AveBedrms] < 1
df[i, :NumHouses] = - (df[i, :Population] / df[i, :AveOccup])
else
df[i, :NumHouses] = df[i, :Population] / df[i, :AveOccup]
end
end
return df
end
@benchmark calc_numhouses_iter_if(cal_housing_long_long)
BenchmarkTools.Trial:
memory estimate: 431.84 MiB
allocs estimate: 27268958
minimum time: 706.858 ms (0.00% GC)
median time: 773.217 ms (12.54% GC)
mean time: 769.360 ms (7.56% GC)
maximum time: 826.242 ms (12.69% GC)
samples: 7
evals/sample: 1
If文を挟んで、Avebedrmsが1未満のときは結果の符号を反転するようにしました。意味のない計算ですが、ともかく実行時間は1.5倍程度、いぜんとして1秒未満で済みました。200MB程度のCSVこれだったらまったく気にならない範囲ではないでしょうか。
自作の関数をベクトル化して適用する場合はどうでしょうか。
function calc_eachrow_if(x1::Float64, x2::Float64, x3::Float64)::Float64
if x3 < 1
return -(x1 / x2)
else
return x1 / x2
end
end
function calc_byfunc_if(df::DataFrame)::DataFrame
df[!, :NumHouses] = calc_eachrow_if.(df[!, :Population], df[!, :AveOccup], df[!, :AveBedrms])
df
end
@benchmark calc_byfunc_if(cal_housing_long_long)
BenchmarkTools.Trial:
memory estimate: 15.75 MiB
allocs estimate: 5
minimum time: 3.015 ms (0.00% GC)
median time: 8.502 ms (0.00% GC)
mean time: 9.026 ms (20.55% GC)
maximum time: 103.301 ms (89.51% GC)
samples: 562
evals/sample: 1
If文を入れた影響がちっともみられないし、これだけ簡単に列方向に適用できるなら何の問題もないですね。比較するとfor文を使わない方が速いが、使っても十分速いからどちらでもいいという印象です。
(おまけ)LINQで処理してみる
Forループはなんでもできるけどちょっと遅い、一方であれこれ複数の種類の処理を重ねるには表現力のある記法がほしい、ということで、特にデータのフィルタ、集計などの処理をあれこれするのに便利なLINQ的な記法を試してみましょう。DataFramesMeta.jlを使います。
function calc_numhouses_linq(df::DataFrame)::DataFrame
df = @linq df |>
transform(NumHouses = :Population ./ :AveOccup)
return df
end
@benchmark calc_numhouses_linq(cal_housing_long_long)
BenchmarkTools.Trial:
memory estimate: 157.47 MiB
allocs estimate: 48
minimum time: 25.448 ms (0.00% GC)
median time: 40.034 ms (22.30% GC)
mean time: 43.995 ms (24.52% GC)
maximum time: 123.423 ms (68.54% GC)
samples: 114
evals/sample: 1
このシンプルさ! ループを書かなくていいし、この先あれこれ他の処理をつなげていくとしてもきわめて直感的な表現ができます。実は先ほども登場していますが、一点だけ見慣れないのは ./
でしょう。これはJuliaによくあるパターンで、 .
を頭(関数の場合は関数名と引数のあいだに f.()
のように)につけると演算子・関数をベクトル化して適用してくれます。 そして先ほどのforループの場合よりもさらに高速になりました。ここまで来るとJITコンパイルのオーバーヘッドのため初回実行が遅いのが足を引っ張りますが、いずれにせよこれだけ速ければ問題ないはずです。とはいえ、これだけ簡単に処理できるのは単なる四則演算だからで、あれこれ難しい処理をするとなるとこうはいきません。上述のForループならあれこれのロジックも簡単に書けるし、前後の行の間での相互関係にも対応できる柔軟性があるので、適宜使い分けになるでしょう。
並列化
JuliaならforループをOpenMPのようにマルチスレッドで処理するのも極めて簡単です。PythonだとGILがあるのでマルチプロセスにせざるを得えないし、そうするとメモリ周りが煩雑になることを考えると手軽にマルチスレッドにできるJuliaは強いです。
まずシェルで搭載コア数に応じて環境変数を設定してやります。私のマシンは2コアだったので劇的な差は期待できませんが、ともかく2に設定します。
export JULIA_NUM_THREADS=2
反映されたことを確認します。Jupyterで作業している場合はカーネル再起動ではなく、Jupyterごと再起動する必要があることに注意します。
Threads.nthreads() # 2
関数自体はループの頭に Threads.@threads
を付け加えるだけです。
function calc_numhouses_iter_p(df::DataFrame)::DataFrame
len = size(df, 1)
df[!, :NumHouses] .= 0.0
Threads.@threads for i in 1:len
df[i, :NumHouses] = df[i, :Population] / df[i, :AveOccup]
end
return df
end
@benchmark calc_numhouses_iter_p(cal_housing_long_long)
BenchmarkTools.Trial:
memory estimate: 330.66 MiB
allocs estimate: 20637984
minimum time: 263.896 ms (0.00% GC)
median time: 329.071 ms (0.00% GC)
mean time: 370.615 ms (13.02% GC)
maximum time: 637.808 ms (41.56% GC)
samples: 14
evals/sample: 1
Forループ以外の処理もあるので必然的に2倍には届かないものの、それでも速くなりました。ガベージコレクションで遅くなってしまっているのは気になりますが、大勢には影響なさそうです。このマルチスレッド化はループの一回一回に依存関係がなければこのようにごくごく簡単に使え、もちろんDataFrameの処理以外にも使えるので便利です。
おわりに
ということで、Juliaを使ったらテーブルデータを簡単かつ高速に処理できるよというお話でした。Juliaの最適化として不十分な部分もあるかと思いますし、おそらくPythonでももっとよい方法があると思うので、気づいたことがあったらどうぞご指摘ください。ただ、「難しいこと考えたりトリッキーなコードを書かなくても素直に簡単に速くなる」という意味でもやはりJuliaは使いやすいように感じます。当然のことではありますが、今回の結果はあくまで一例であり、ベンチマークは用いるデータや実行する計算によって大幅に変化します。みなさまのユースケースでもぜひベンチマークして結果を共有していただければ幸いです。