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 は「とりあえず動く」コードを書くには便利ですが、本番環境では遅さが問題になることが多いです。パフォーマンスが重要な場面では、ベクトル化への書き換えを意識してください。