pandas の apply は遅い:ベクトル化で高速化する

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 の演算を使うためです。

apply

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.searchstr.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 が適切です。

外部 API やライブラリの呼び出し

ベクトル化できない外部関数を使う場合

非常に複雑なロジック

可読性を優先すべき場合。ただしパフォーマンスが問題なら検討が必要

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