pandas の apply は便利なメソッドですが、大きなデータセットでは非常に遅くなります。多くの場合、ベクトル化された操作に書き換えることで劇的に高速化できます。
apply が遅い理由
apply は内部で Python のループを実行しています。1 行ずつ(または 1 要素ずつ)関数を呼び出すため、オーバーヘッドが大きくなります。
import pandas as pd import numpy as np import time # 100万行のデータ df = pd.DataFrame({ 'a': np.random.randn(1_000_000), 'b': np.random.randn(1_000_000) }) # apply で計算 start = time.time() df['c'] = df.apply(lambda row: row['a'] + row['b'], axis=1) print(f"apply: {time.time() - start:.2f}秒") # ベクトル化で計算 start = time.time() df['c'] = df['a'] + df['b'] print(f"ベクトル化: {time.time() - start:.2f}秒") # apply: 15.23秒 # ベクトル化: 0.01秒
この例では 1000 倍以上の速度差があります。apply は行ごとに Python の関数呼び出しが発生するのに対し、ベクトル化は C/Fortran で実装された NumPy の演算を使うためです。
Python レベルのループ。柔軟だが遅い。
NumPy/pandas の内部実装を使う。制約はあるが高速。
ベクトル化の基本パターン
多くの apply は、pandas や NumPy の組み込み関数で置き換えられます。
import pandas as pd import numpy as np df = pd.DataFrame({ 'value': [10, -5, 20, -15, 30] }) # 悪い例:apply で絶対値 df['abs_slow'] = df['value'].apply(lambda x: abs(x)) # 良い例:ベクトル化 df['abs_fast'] = df['value'].abs() # または df['abs_fast'] = np.abs(df['value'])
文字列操作も str アクセサを使えばベクトル化できます。
df = pd.DataFrame({ 'name': ['TANAKA', 'SATO', 'SUZUKI'] }) # 悪い例 df['lower_slow'] = df['name'].apply(lambda x: x.lower()) # 良い例 df['lower_fast'] = df['name'].str.lower()
条件分岐の高速化
条件分岐を含む apply は、np.where や np.select で置き換えられます。
import pandas as pd import numpy as np df = pd.DataFrame({ 'score': [45, 65, 85, 55, 95] }) # 悪い例:apply で条件分岐 def grade(score): if score >= 80: return 'A' elif score >= 60: return 'B' else: return 'C' df['grade_slow'] = df['score'].apply(grade) # 良い例:np.select conditions = [ df['score'] >= 80, df['score'] >= 60 ] choices = ['A', 'B'] df['grade_fast'] = np.select(conditions, choices, default='C')
単純な二択なら np.where がシンプルです。
# 合否判定 df['pass'] = np.where(df['score'] >= 60, '合格', '不合格')
複数列を使った計算
複数列を参照する計算も、ベクトル化で書けます。
df = pd.DataFrame({ 'price': [100, 200, 300], 'quantity': [5, 3, 2], 'discount': [0.1, 0.2, 0.0] }) # 悪い例 df['total_slow'] = df.apply( lambda row: row['price'] * row['quantity'] * (1 - row['discount']), axis=1 ) # 良い例 df['total_fast'] = df['price'] * df['quantity'] * (1 - df['discount'])
axis=1 を使った行単位の apply は特に遅いです。可能な限り列単位の演算に書き換えましょう。
文字列の複雑な処理
正規表現を使った抽出や置換も str アクセサで高速化できます。
df = pd.DataFrame({ 'text': ['価格:1000円', '価格:2500円', '価格:800円'] }) # 悪い例 import re df['price_slow'] = df['text'].apply( lambda x: int(re.search(r'\d+', x).group()) ) # 良い例 df['price_fast'] = df['text'].str.extract(r'(\d+)').astype(int)
| 処理 | apply | ベクトル化 |
|---|---|---|
| 小文字変換 | apply(str.lower) | str.lower() |
| 分割 | apply(str.split) | str.split() |
| 置換 | apply(lambda x: x.replace(...)) | str.replace() |
| 抽出 | apply + re.search | str.extract() |
groupby との組み合わせ
groupby 後の apply も遅くなりがちです。組み込みの集計関数を使いましょう。
df = pd.DataFrame({ 'category': ['A', 'A', 'B', 'B', 'B'], 'value': [10, 20, 30, 40, 50] }) # 悪い例 result_slow = df.groupby('category').apply(lambda x: x['value'].sum()) # 良い例 result_fast = df.groupby('category')['value'].sum()
複数の集計を行う場合は agg を使います。
# 悪い例 def summary(group): return pd.Series({ 'mean': group['value'].mean(), 'std': group['value'].std(), 'count': len(group) }) result_slow = df.groupby('category').apply(summary) # 良い例 result_fast = df.groupby('category')['value'].agg(['mean', 'std', 'count'])
apply が必要なケース
全てを無理にベクトル化する必要はありません。以下のケースでは apply が適切です。
ベクトル化できない外部関数を使う場合
可読性を優先すべき場合。ただしパフォーマンスが問題なら検討が必要
apply を使う場合でも、raw=True オプションで少し高速化できます。
# raw=True で NumPy 配列として渡す df['sum'] = df[['a', 'b']].apply(lambda x: x.sum(), axis=1, raw=True)
raw=True は pandas の Series ではなく NumPy 配列を関数に渡すため、オーバーヘッドが減ります。
Numba による高速化
どうしても apply が必要な場合、Numba で JIT コンパイルすると高速化できます。
from numba import jit import numpy as np @jit(nopython=True) def complex_calc(a, b): # 複雑な計算 result = 0.0 for i in range(100): result += a * np.sin(b + i) return result # Numba を使った高速 apply df['result'] = df.apply(lambda row: complex_calc(row['a'], row['b']), axis=1)
ただし、Numba は NumPy の機能しか使えないなど制約があります。まずはベクトル化を検討し、それでも無理な場合の最終手段として使いましょう。
apply は「とりあえず動く」コードを書くには便利ですが、本番環境では遅さが問題になることが多いです。パフォーマンスが重要な場面では、ベクトル化への書き換えを意識してください。