Pandasのapplyを超える操作の高速化
James Reed
Infrastructure Engineer · Leapcell

はじめに
データサイエンスと分析の分野では、PandasはPythonにおけるデータ操作と分析に不可欠なツールとなっています。その直感的なDataFrameとSeriesの構造は複雑な操作を簡素化し、実務家にとってのお気に入りとなっています。しかし、データセットのサイズと複雑さが増すにつれて、標準的なPandasの操作がパフォーマンスのボトルネックになることがあります。その典型的な例が、普及しているapply
メソッドです。特に大規模なデータセットを1行ずつまたは1列ずつ操作する場合、apply
は非常に柔軟ですが、汎用性のためにパフォーマンスを犠牲にすることがよくあります。この記事では、Pandasにおける高性能データ処理のためのapply
の代替手段やその他の戦略を掘り下げ、大量のデータであってもデータパイプラインがスムーズかつ迅速に実行されるようにします。これらの最適化手法を理解することは、データ分析の取り組みをスケールさせ、堅牢でパフォーマンスの高いデータ駆動型アプリケーションを構築しようとしている人にとって不可欠です。
Pandasでの効率的な操作のためのコアコンセプト
代替手段を詳しく見ていく前に、Pandasでの高性能コンピューティングの基礎となるいくつかのコアコンセプトを定義しましょう。
- ベクトル化: これは、個々の要素を反復処理するのではなく、一度に配列全体またはSeries全体で操作を実行することを指します。NumPyをベースにしたPandasは、ベクトル化された操作に優れています。2つの列を追加するために各行をループする代わりに、単純に列を直接追加します(
df['col1'] + df['col2']
)。これにより、操作は最適化されたCコードに委任され、大幅な速度向上がもたらされます。 - ブロードキャスティング: NumPyから継承された強力な機能であるブロードキャスティングは、異なる形状の配列間の操作を可能にし、互換性がある限り、小さい方の配列を自動的に大きい方に一致するように拡張します。これにより、明示的なループやメモリの重複が回避されます。
- ユニバーサル関数 (ufuncs): これらは、NumPy配列に要素ごとに操作を実行する関数です。Pandas SeriesとDataFrameは、高度に最適化された操作のためにufuncsを利用します。例としては、
np.sin()
、np.sqrt()
、np.add()
などがあります。 - ジャストインタイム (JIT) コンパイル: Numbaのような技術は、Pythonコードをランタイム時に高度に最適化されたマシンコードにコンパイルできます。これにより、特に反復的または複雑な数値タスクで、純粋なPythonでは遅い計算を大幅に高速化できます。
apply
の落とし穴とその代替手段
apply
メソッドは、汎用性が高い一方で、本質的にPython関数を各イテレーションで呼び出すため、行ごとまたは列ごとの操作となり、パフォーマンスのボトルネックになることがよくあります。このループのような動作は、ベクトル化されたPandas操作を支える最適化されたC拡張を迂回します。
カスタムメトリックを各行で計算したい例でこれを説明しましょう。
import pandas as pd import numpy as np import time # サンプルDataFrameを作成 data_size = 1_000_000 df = pd.DataFrame({ 'col_a': np.random.rand(data_size), 'col_b': np.random.rand(data_size), 'col_c': np.random.randint(1, 100, data_size) }) # 適用するカスタム関数 def custom_calculation(row): return (row['col_a'] * row['col_b']) / row['col_c'] if row['col_c'] != 0 else 0 print("--- .apply() を使用 --- ") start_time = time.time() df['result_apply'] = df.apply(custom_calculation, axis=1) end_time = time.time() print(f".apply() でかかった時間: {end_time - start_time:.4f} 秒")
100万行に対するapply
の出力は、お使いのコンピューターによって数秒、あるいは数十秒かかることがあります。
代替手段1:ベクトル化された操作
最も基本的で、しばしば最も効果的な代替手段は、ベクトル化された操作を使用することです。多くのカスタム関数は、基本的な算術演算、NumPy関数、またはSeries全体またはDataFrame全体で操作する組み込みPandasメソッドを使用して書き直すことができます。
print("\n--- ベクトル化された操作を使用 --- ") start_time = time.time() df['result_vectorized'] = (df['col_a'] * df['col_b']) / df['col_c'] # 必要に応じて、後でゼロ除算を明示的に処理するか、 # 分母がゼロにならないようにします。 # 簡単にするために、randint(1, 100)に基づきcol_cがゼロにならないと仮定します。 end_time = time.time() print(f"ベクトル化された操作にかかった時間: {end_time - start_time:.4f} 秒") # 検証(結果は浮動小数点精度の違いを考慮してほぼ同じになるはずです) print(f"結果は同じですか? {(df['result_apply'] == df['result_vectorized']).all()}")
劇的な速度向上が観察されるでしょう。ベクトル化された操作は最適化されたCコードを活用しており、apply
よりも桁違いに高速です。
代替手段2:df.eval()
と df.query()
複雑な文字列ベースの式では、df.eval()
は、numexprを使用して式をCで最適化された方法で解析および評価するため、apply
よりも大幅に高速になる可能性があります。同様に、df.query()
はフィルタリング操作を最適化します。
print("\n--- .eval() を使用 --- ") start_time = time.time() df['result_eval'] = df.eval('col_a * col_b / col_c') end_time = time.time() print(f".eval() でかかった時間: {end_time - start_time:.4f} 秒") # いくつかのデータを素早くフィルタリングしましょう print("\n--- .query() を使用 --- ") start_time = time.time() filtered_df = df.query('col_a > 0.5 and col_c < 50') end_time = time.time() print(f".query() でかかった時間: {end_time - start_time:.4f} 秒") print(f"フィルタリングされたDataFrameの形状: {filtered_df.shape}")
代替手段3:自動最適化のためのswifter
swifter
は、Pandas DataFrameまたはSeries全体に関数を適用する最も効率的な方法をインテリジェントに決定しようとするライブラリです。ベクトル化された操作を最初に試し、次にDaskを試し、関数が複雑すぎてベクトル化できない場合のみapply
にフォールバックします。
import swifter # 'swifter' をインストールしてください: pip install swifter print("\n--- swifter を使用 --- ") start_time = time.time() df['result_swifter'] = df.swifter.apply(custom_calculation, axis=1) end_time = time.time() print(f"swifter でかかった時間: {end_time - start_time:.4f} 秒")
swifter
は、関数が簡単にベクトル化できるかどうか不確かな場合に、利便性とパフォーマンスの良好なバランスを提供できます。
代替手段4:JITコンパイルのためのNumba
操作が複雑で簡単にベクトル化できないが、集中的な数値計算を伴う場合、NumbaはPython関数をマシンコードにコンパイルすることで大幅な速度向上を提供できます。
import numba from numba import guvectorize, float64 # 要素ごとの配列操作のためのNumbaでJITされた関数を定義 @numba.vectorize(['float64(float64, float64, float64)']) def numba_calculation_elementwise(col_a, col_b, col_c): return (col_a * col_b) / col_c if col_c != 0 else 0 print("\n--- Numba (Vectorize) を使用 --- ") start_time = time.time() df['result_numba_elementwise'] = numba_calculation_elementwise(df['col_a'], df['col_b'], df['col_c']) end_time = time.time() print(f"Numba vectorize でかかった時間: {end_time - start_time:.4f} 秒") # ベクトル化が難しい行ごとの操作の場合 @numba.jit(nopython=True) def custom_calculation_numba(col_a, col_b, col_c): return (col_a * col_b) / col_c if col_c != 0 else 0 # このJITされた関数を適用 # 注意: JITされた関数をdf.apply()で直接適用すると、Pandasのオーバーヘッドにより依然として遅くなる可能性があります。 # 最善の方法は、列をNumPy配列として抽出し、Numba関数を適用し、元に戻することです。 print("\n--- Numba (JIT と再アセンブル) を使用 --- ") start_time = time.time() df['result_numba_jit_direct'] = [custom_calculation_numba(a, b, c) for a, b, c in zip(df['col_a'].values, df['col_b'].values, df['col_c'].values)] end_time = time.time() print(f"Numba JIT (直接ループ) でかかった時間: {end_time - start_time:.4f} 秒")
Numbaは、複雑なループや条件分岐があり、ベクトル化で表現するのが難しい場合に威力を発揮します。重要なのは、apply
で反復処理するのではなく、NumPy配列をNumbaでJITされた関数に渡すことです。なぜなら、apply
は依然としてPythonのオーバーヘッドをもたらすからです。
結論
Pandasのパフォーマンスをapply
を超えて最適化することは、スケーラブルなデータ処理に不可欠です。ベクトル化された操作を優先し、df.eval()
とdf.query()
を活用し、swifter
のようなスマートなライブラリを採用し、複雑な数値タスクにはNumbaによるJITコンパイルを検討することで、データワークフローを大幅に加速できます。最も重要な原則は、最適化されたCコードに計算を委任するメソッドを常に模索し、遅いPythonループを可能な限り回避することです。この戦略的なアプローチは、Pandasコードを単なる機能的なものから、極めて高速なものへと変革するでしょう。