Pandas Apply: カスタム関数でDataFrameとSeriesを変換する
Updated on
データ変換は、すべてのデータ分析ワークフローの中心にあります。pandasは一般的な操作のために数百の組み込みメソッドを提供していますが、実世界のデータは標準関数では処理できないカスタムロジックを必要とすることがよくあります。これにより、ジレンマが生じます。何千、何百万もの行に対して複雑なユーザー定義の変換を効率的に適用するにはどうすればよいでしょうか?
apply()メソッドは、DataFrameの列、行、またはSeries要素に対して任意のPython関数を実行できるようにすることで、この問題を解決します。一貫性のない文字列形式をクリーンアップしたり、条件付きビジネスロジックを実装したり、機械学習モデルの特徴量を設計したりする必要がある場合でも、apply()はpandasの組み込みツールキット以外の操作を処理する柔軟性を提供します。しかし、この力には多くのデータサイエンティストが見落とすパフォーマンストレードオフが伴い、最適化された代替手段よりも10〜100倍遅いコードになります。
このガイドでは、apply()を効果的に使用する方法、完全に避けるべき時期、およびどのベクトル化された代替手段が同じ結果を短時間で提供するかを明らかにします。
pandas apply()の基本を理解する
apply()メソッドには2つの形式があります。列または行全体の操作のためのDataFrame.apply()と、単一の列の要素ごとの変換のためのSeries.apply()です。
Series.apply()の構文
単一の列(Series)を操作する場合、apply()は各要素に対して関数を実行します。
import pandas as pd
import numpy as np
# サンプルデータを作成
df = pd.DataFrame({
'price': [29.99, 45.50, 15.75, 89.00],
'quantity': [2, 1, 5, 3],
'product': ['widget', 'gadget', 'tool', 'device']
})
# Seriesに関数を適用
def add_tax(price):
return price * 1.08
df['price_with_tax'] = df['price'].apply(add_tax)
print(df)出力:
price quantity product price_with_tax
0 29.99 2 widget 32.38920
1 45.50 1 gadget 49.14000
2 15.75 5 tool 17.01000
3 89.00 3 device 96.12000axisパラメータを持つDataFrame.apply()
axisパラメータは、apply()が列を処理するか行を処理するかを制御します。
axis=0(デフォルト): 各列に関数を適用(縦方向の操作)axis=1: 各行に関数を適用(横方向の操作)
# axis=0: 各列を処理
def get_range(column):
return column.max() - column.min()
ranges = df[['price', 'quantity']].apply(get_range, axis=0)
print(ranges)
# 出力:
# price 73.25
# quantity 4.00# axis=1: 各行を処理
def calculate_total(row):
return row['price'] * row['quantity']
df['total'] = df.apply(calculate_total, axis=1)
print(df)出力:
price quantity product total
0 29.99 2 widget 59.98
1 45.50 1 gadget 45.50
2 15.75 5 tool 78.75
3 89.00 3 device 267.00ラムダ関数 vs 名前付き関数
ラムダ関数は簡潔なインライン変換を提供し、名前付き関数は複雑なロジックに対してより良い可読性を提供します。
ラムダ関数
シンプルな1行の操作に最適:
# 製品名を大文字に変換
df['product_upper'] = df['product'].apply(lambda x: x.upper())
# 割引価格を計算
df['discounted'] = df['price'].apply(lambda x: x * 0.9 if x > 30 else x)
# 複数の列を結合
df['description'] = df.apply(
lambda row: f"{row['quantity']}x {row['product']} @ ${row['price']}",
axis=1
)
print(df['description'])出力:
0 2x widget @ $29.99
1 1x gadget @ $45.5
2 5x tool @ $15.75
3 3x device @ $89.0名前付き関数
複数ステップの変換と再利用可能なロジックに不可欠:
def categorize_price(price):
"""価格帯で製品を分類"""
if price < 20:
return 'Budget'
elif price < 50:
return 'Standard'
else:
return 'Premium'
df['tier'] = df['price'].apply(categorize_price)
def validate_order(row):
"""注文データにビジネスルールを適用"""
if row['quantity'] > 10:
return 'Bulk Order - Review Required'
elif row['price'] * row['quantity'] > 200:
return 'High Value - Priority Shipping'
else:
return 'Standard Processing'
df['order_status'] = df.apply(validate_order, axis=1)
print(df[['product', 'tier', 'order_status']])result_typeパラメータを理解する
result_typeパラメータ(DataFrame.apply()のみ)は、関数が複数の値を返す場合にapply()が出力をフォーマットする方法を制御します。
| result_type | 動作 | 使用例 |
|---|---|---|
| None(デフォルト) | 出力形式を自動的に推測 | 一般的な目的 |
| 'expand' | リスト形式の結果を別々の列に分割 | 複数の戻り値 |
| 'reduce' | DataFrameの代わりにSeriesを返そうとする | 集約操作 |
| 'broadcast' | 元の形状でDataFrameを返す | 要素ごとの変換 |
def get_stats(column):
"""複数の統計を返す"""
return [column.mean(), column.std(), column.max()]
# デフォルトの動作(構造を推測)
stats = df[['price', 'quantity']].apply(get_stats)
print(stats)
# 別々の行に展開
stats_expanded = df[['price', 'quantity']].apply(get_stats, result_type='expand')
print(stats_expanded)メソッドの比較: apply vs map vs applymap vs transform
各メソッドをいつ使用するかを理解することで、パフォーマンスのボトルネックを防ぎます。
| メソッド | 操作対象 | 入力関数が受け取るもの | 出力タイプ | 最適な用途 |
|---|---|---|---|---|
Series.apply() | 単一の列 | 各要素を個別に | Series | 1つの列での要素ごとの変換 |
Series.map() | 単一の列 | 各要素(dict/Seriesも受け付ける) | Series | 置換/検索操作 |
DataFrame.apply() | DataFrame全体 | 列全体(axis=0)または行(axis=1) | Series/DataFrame | 列/行ごとの操作 |
DataFrame.applymap()(非推奨) | DataFrame全体 | 各要素を個別に | DataFrame | すべての列での要素ごと(代わりにmap()を使用) |
DataFrame.transform() | DataFrame/GroupBy | 列/グループ全体 | 入力と同じ形状 | DataFrameの形状を保持する操作 |
# Series.apply() - 要素ごとのカスタム関数
df['price_rounded'] = df['price'].apply(lambda x: round(x, 0))
# Series.map() - 置換マッピング
tier_map = {'Budget': 1, 'Standard': 2, 'Premium': 3}
df['tier_code'] = df['tier'].map(tier_map)
# DataFrame.apply() - 列ごとの集約
totals = df[['price', 'quantity']].apply(sum, axis=0)
# DataFrame.transform() - groupbyで形状を保持
df['price_norm'] = df.groupby('tier')['price'].transform(
lambda x: (x - x.mean()) / x.std()
)パフォーマンス: なぜapply()は遅いのか
apply()メソッドはPythonループでデータを処理し、pandasの最適化されたC/Cython実装をバイパスします。大きなデータセットの場合、これにより深刻なパフォーマンスペナルティが発生します。
パフォーマンス比較
import time
# 大きなデータセットを作成
large_df = pd.DataFrame({
'values': np.random.randn(100000)
})
# 方法1: lambdaでapply()
start = time.time()
result1 = large_df['values'].apply(lambda x: x ** 2)
apply_time = time.time() - start
# 方法2: ベクトル化された操作
start = time.time()
result2 = large_df['values'] ** 2
vectorized_time = time.time() - start
print(f"apply()時間: {apply_time:.4f}s")
print(f"ベクトル化時間: {vectorized_time:.4f}s")
print(f"高速化: {apply_time/vectorized_time:.1f}x")典型的な出力:
apply()時間: 0.0847s
ベクトル化時間: 0.0012s
高速化: 70.6xapply()を避けるべき時
代わりにベクトル化された操作を使用する場合:
# ダメ: 算術演算にapplyを使用
df['total'] = df.apply(lambda row: row['price'] * row['quantity'], axis=1)
# 良い: ベクトル化された乗算を使用
df['total'] = df['price'] * df['quantity']
# ダメ: 文字列メソッドにapplyを使用
df['upper'] = df['product'].apply(lambda x: x.upper())
# 良い: 組み込み文字列アクセサを使用
df['upper'] = df['product'].str.upper()
# ダメ: 条件にapplyを使用
df['expensive'] = df['price'].apply(lambda x: 'Yes' if x > 50 else 'No')
# 良い: np.whereまたは直接比較を使用
df['expensive'] = np.where(df['price'] > 50, 'Yes', 'No')ベクトル化された代替手段
| 操作 | 遅い(apply) | 速い(ベクトル化) |
|---|---|---|
| 算術演算 | .apply(lambda x: x * 2) | * 2 |
| 条件式 | .apply(lambda x: 'A' if x > 10 else 'B') | np.where(condition, 'A', 'B') |
| 文字列操作 | .apply(lambda x: x.lower()) | .str.lower() |
| 日付操作 | .apply(lambda x: x.year) | .dt.year |
| 複数の条件 | .apply(complex_if_elif_else) | np.select([cond1, cond2], [val1, val2], default) |
# np.selectで複雑な条件
conditions = [
df['price'] < 20,
(df['price'] >= 20) & (df['price'] < 50),
df['price'] >= 50
]
choices = ['Budget', 'Standard', 'Premium']
df['tier_fast'] = np.select(conditions, choices, default='Unknown')pandas apply()の一般的な使用例
パフォーマンスの制限にもかかわらず、apply()はベクトル化された同等物がない操作に不可欠です。
1. 複雑なロジックを使った文字列クリーニング
# 乱雑なデータのサンプル
messy_df = pd.DataFrame({
'email': ['John.Doe@COMPANY.com', ' jane_smith@test.co.uk ', 'ADMIN@Site.NET']
})
def clean_email(email):
"""メール形式を標準化"""
email = email.strip().lower()
# 余分なドットを削除
username, domain = email.split('@')
username = username.replace('..', '.')
return f"{username}@{domain}"
messy_df['email_clean'] = messy_df['email'].apply(clean_email)
print(messy_df)2. 外部データを使った条件付きロジック
# 外部検索に基づく価格調整
discount_rules = {
'widget': 0.10,
'gadget': 0.15,
'device': 0.05
}
def apply_discount(row):
"""最小ロジックで製品固有の割引を適用"""
base_price = row['price']
discount = discount_rules.get(row['product'], 0)
discounted = base_price * (1 - discount)
# 最低価格
return max(discounted, 9.99)
df['final_price'] = df.apply(apply_discount, axis=1)3. MLのための特徴量エンジニアリング
# 相互作用特徴量を作成
def create_features(row):
"""予測モデリングのための特徴量を生成"""
features = {}
features['price_per_unit'] = row['price'] / row['quantity']
features['is_bulk'] = 1 if row['quantity'] > 5 else 0
features['revenue_tier'] = pd.cut(
[row['price'] * row['quantity']],
bins=[0, 50, 150, 300],
labels=['Low', 'Medium', 'High']
)[0]
return pd.Series(features)
feature_df = df.apply(create_features, axis=1)
df = pd.concat([df, feature_df], axis=1)4. API呼び出しと外部検索
def geocode_address(address):
"""ジオコーディングのために外部APIを呼び出す"""
# 実際のAPI呼び出しのプレースホルダー
# 実際には: requests.get(f"api.geocode.com?q={address}")
return {'lat': 40.7128, 'lon': -74.0060}
# レート制限付きで適用
import time
def safe_geocode(address):
time.sleep(0.1) # レート制限
return geocode_address(address)
# df['coords'] = df['address'].apply(safe_geocode)高度なテクニック
追加の引数を渡す
関数はargsとkwargsを介して追加のパラメータを受け取ることができます。
def apply_markup(price, markup_pct, min_profit):
"""最小利益でパーセンテージマークアップを追加"""
markup_amount = price * (markup_pct / 100)
return price + max(markup_amount, min_profit)
# 追加の引数を渡す
df['retail_price'] = df['price'].apply(
apply_markup,
args=(25,), # markup_pct=25
min_profit=5.0 # キーワード引数
)groupby()でapply()を使用する
複雑な集約のためにgroupbyとapplyを組み合わせる:
# グループレベルの変換
def normalize_group(group):
"""グループ内でのZ-score正規化"""
return (group - group.mean()) / group.std()
df['price_normalized'] = df.groupby('tier')['price'].apply(normalize_group)
# カスタムグループ集約
def group_summary(group):
"""グループの要約統計を作成"""
return pd.Series({
'total_revenue': (group['price'] * group['quantity']).sum(),
'avg_price': group['price'].mean(),
'item_count': len(group)
})
tier_summary = df.groupby('tier').apply(group_summary)
print(tier_summary)tqdmでプログレスバー
長時間実行されるapply操作を監視:
from tqdm import tqdm
tqdm.pandas()
# applyの代わりにprogress_applyを使用
# df['result'] = df['column'].progress_apply(slow_function)エラーを優雅に処理する
def safe_transform(value):
"""エラー処理付きで変換を適用"""
try:
return complex_operation(value)
except Exception as e:
return None # またはnp.nan、またはデフォルト値
df['result'] = df['column'].apply(safe_transform)よくある間違いとデバッグ
間違い1: ベクトル化が可能な時にaxis=1を使用
# 間違い: 遅い行ごとの操作
df['total'] = df.apply(lambda row: row['price'] * row['quantity'], axis=1)
# 正しい: 速いベクトル化された操作
df['total'] = df['price'] * df['quantity']間違い2: return文を忘れる
# 間違い: 戻り値なし
def broken_function(x):
x * 2 # returnが欠けている!
# 正しい: 明示的なreturn
def working_function(x):
return x * 2間違い3: apply()内でDataFrameを変更する
# 間違い: イテレーション中にDataFrameを変更しようとする
def bad_function(row):
df.loc[row.name, 'new_col'] = row['price'] * 2 # 安全でない!
return row['price']
# 正しい: 値を返して後で割り当てる
def good_function(row):
return row['price'] * 2
df['new_col'] = df.apply(good_function, axis=1)apply()関数のデバッグ
# 最初に単一の行/要素で関数をテスト
test_row = df.iloc[0]
result = your_function(test_row)
print(f"テスト結果: {result}")
# 関数内にデバッグプリントを追加
def debug_function(row):
print(f"行{row.name}を処理中: {row.to_dict()}")
result = complex_logic(row)
print(f"結果: {result}")
return result
# 小さなサブセットでテスト
df.head(3).apply(debug_function, axis=1)PyGWalkerで結果を可視化
apply()でDataFrameを変換した後、結果を可視化することで変換を検証しパターンを発見できます。PyGWalkerは、Jupyterノートブック内でpandas DataFrameをTableauスタイルのインタラクティブなインターフェースに変換します。
import pygwalker as pyg
# 変換されたデータをインタラクティブに可視化
walker = pyg.walk(df)PyGWalkerは適用された変換のドラッグアンドドロップ分析を可能にします:
- サイドバイサイドのチャートで元の列と変換された列を比較
- フィルタリングとグループ化により条件付きロジックを検証
- 分布プロットを通じて計算されたフィールドの外れ値を発見
- ドキュメントのための可視化をエクスポート
github.com/Kanaries/pygwalker (opens in a new tab)でPyGWalkerを探索し、データ探索を静的なプロットからインタラクティブな分析に変換しましょう。
FAQ
複数の列に一度に関数を適用するにはどうすればよいですか?
選択した各列を処理するためにDataFrame.apply()をaxis=0で使用するか、複数の列に対してベクトル化された操作を直接使用します。
# 複数の列に適用
df[['price', 'quantity']] = df[['price', 'quantity']].apply(lambda x: x * 1.1)
# またはベクトル化(より高速)
df[['price', 'quantity']] = df[['price', 'quantity']] * 1.1apply()とmap()の違いは何ですか?
apply()は要素ごとに任意の呼び出し可能な関数を実行しますが、map()は辞書、Series、または関数を介した置換に最適化されています。検索と置換にはmap()を使用し(より高速)、複雑なロジックを必要とするカスタム変換にはapply()を使用してください。
なぜapply()関数はこんなに遅いのですか?
apply()はコンパイルされたCコードではなくPythonループで実行されるため、ベクトル化された操作よりも10〜100倍遅くなります。apply()を使用する前に、pandasに組み込みメソッド(.str、.dt、算術演算子)があるか、np.where()/np.select()がロジックを置き換えることができるかを常に確認してください。
複数の列にアクセスするラムダ関数でapply()を使用できますか?
はい、行を処理するにはaxis=1を使用します。
df['result'] = df.apply(lambda row: row['col1'] + row['col2'] * 0.5, axis=1)ただし、これは大きなDataFrameでは遅いです。以下を優先してください: df['result'] = df['col1'] + df['col2'] * 0.5
1回のapply()呼び出しから複数の列を返すにはどうすればよいですか?
名前付きインデックスを持つpd.Seriesを返すと、pandasが自動的に列に展開します。
def multi_output(row):
return pd.Series({
'sum': row['a'] + row['b'],
'product': row['a'] * row['b']
})
df[['sum', 'product']] = df.apply(multi_output, axis=1)結論
pandas apply()メソッドは、pandasの組み込み操作の範囲外にあるカスタム変換に不可欠な柔軟性を提供します。Pythonループの実装はパフォーマンスのトレードオフを生み出しますが、apply()とベクトル化された代替手段をいつ使用するかを理解することで、効率的なデータワークフローと本番データセットで停止するワークフローを区別できます。
重要なポイント: ベクトル化されたメソッド、文字列アクセサ、またはNumPy関数が目標を達成できない場合にのみapply()を使用してください。DataFrameに適用する前に、単一の行で関数をテストしてください。大規模なデータセットでの行ごとの操作については、Cython、Numba JITコンパイル、または並列実行のためのpolarsへの切り替えを調査してください。
apply()のパワーと制限の両方をマスターすれば、データ変換ツールキットはpandasが投げかけるあらゆる課題に対処できるようになります。