データサイエンティストの多くが活用していない高度なPandasのパターン
この記事は、データサイエンティストが陥りやすい非効率なコーディング習慣を指摘し、メソッドチェーンやパイプ機能などの高度な Pandas パターンを紹介することで、コードの可読性と実行速度を向上させる実践的なガイドを提供しています。
キーポイント
メソッドチェーンによる可読性の向上
中間変数の多用を避け、一連の変換処理を一つの式で記述するメソッドチェーンの手法を紹介し、コードのノイズを削減します。
パイプ機能とラムダ式の活用
複雑な変換ロジックをモジュール化して再利用可能にする `pipe()` 関数や、チェーン内でデータフレーム自身を参照するラムダ式の重要性を解説します。
パフォーマンスとベストプラクティス
`iterrows()` の使用回避やベクトル化された条件処理など、実務で頻出する操作における速度向上とコードの簡潔化のための具体的なパターンを提示します。
チェーン内でのラムダ関数の使用
メソッドチェーン内で現在の DataFrame に参照するには lambda 関数を使用する必要があります。これを忘れると NameError が発生するか、古い変数を参照してしまうミスにつながります。
inplace=True の非推奨
inplace=True を使用するとメソッドが None を返すためチェーンが中断され、メモリ節約のメリットもありません。可読性を高めるためにチェーン内での使用は避けるべきです。
pipe() パターンの活用
複雑な変換ロジックを独立した関数に分離し pipe() でつなぐことで、コードの自己文書化と単体テストが可能になります。デバッグ時に特定のステップをスキップする際にも便利です。
join キーの重複による行数の爆発を防ぐ
結合キーに重複がある場合、merge() はデフォルトでカルテシアン積を生成し予期せぬ行数増加を引き起こすため、validate パラメータで結合の性質(one_to_one など)を明示してエラーを検知する。
影響分析・編集コメントを表示
影響分析
この記事は、データ分析現場におけるコード品質とパフォーマンスの向上に即座に寄与する実用的な知見を提供しています。特に大規模データを扱う際の速度改善や、チーム開発におけるコードの保守性向上において、多くのデータサイエンティストが実践すべき基準を示すものと言えます。
編集コメント
AI モデルの開発や学習データの処理において、背後で動くデータ前処理の効率化はインフラコスト削減に直結します。この記事で紹介されるような「意識的なコード改善」が、大規模データ処理におけるボトルネック解消の鍵となります。
画像
著者による画像
# はじめに
ほとんどのデータサイエンティストは、チュートリアルを読み、機能するパターンをコピーすることでpandasを学んでいます。
これは入門には問題ありませんが、しばしば初心者に悪い癖がついてしまいます。iterrows()ループ、中間変数の代入、反復的なmerge()呼び出しの使用は、技術的には正確だが、必要以上に遅く、読みづらくなるコードの例です。
以下に示すパターンはエッジケースではありません。これらは、フィルタリング、変換、結合、グループ化、条件付きカラムの計算など、データサイエンスにおける日常的な最も一般的な操作をカバーしています。
それぞれに共通のアプローチとより良いアプローチがあり、その違いは通常、複雑さというよりは認識の有無によるものです。
これら6つが最も大きな影響を持ちます:メソッドチェーン、pipe()パターン、効率的な結合とマージ、groupbyの最適化、ベクトル化された条件付きロジック、そしてパフォーマンス上の落とし穴です。
**

# メソッドチェーン
中間変数はコードを整理されたように見せることができますが、多くの場合ノイズを追加するだけです。メソッドチェーン**を使えば、一連の変換を単一の式として記述でき、自然な読み心地になり、一意の識別子が必要ないオブジェクトに名前をつける必要がなくなります。
以下のように書く代わりに:
df1 = df[df['status'] == 'active']
df2 = df1.dropna(subset=['revenue'])
df3 = df2.assign(revenue_k=df2['revenue'] / 1000)
result = df3.sort_values('revenue_k', ascending=False)
以下のように書きます:
result = (
df
.query("status == 'active'")
.dropna(subset=['revenue'])
.assign(revenue_k=lambda x: x['revenue'] / 1000)
.sort_values('revenue_k', ascending=False)
)
ここでassign()内のラムダ式が重要です。
チェーン操作中は、DataFrameの現在の状態を名前でアクセスできません。ラムダ式を使用して参照する必要があります。チェーンが壊れる最も一般的な原因はこれを忘れることで、通常はNameErrorやスクリプトの前半で定義された変数への古い参照が生じます。
知っておくべきもう一つのミスは、チェーン内でinplace=Trueを使用することです。inplace=Trueを持つメソッドはNoneを返すため、チェーンが即座に壊れます。メモリ上の利点がないため、チェーンコードを書く際にはインプレース操作を避けるべきです。これらはコードの追跡を困難にします。
# The Pipe() Pattern
**
変換処理が十分に複雑で、独自の関数として分離する価値がある場合、pipe()を使用することで、その処理をチェーン内部に保持することができます。
pipe()は、DataFrameを任意の呼び出し可能な関数(callable)の第1引数として渡します:
def normalize_columns(df, cols):
df[cols] = (df[cols] - df[cols].mean()) / df[cols].std()
return df
result = (
df
.query("status == 'active'")
.pipe(normalize_columns, cols=['revenue', 'sessions'])
.sort_values('revenue', ascending=False)
)
これにより、複雑な変換ロジックを名前付きでテスト可能な関数内に保持しつつ、チェーンの構造を維持できます。各パイプ処理された関数は個別にテスト可能であり、ロジックが広範なチェーンの内部にインラインで隠れている場合、これを実現するのは困難になります。
pipe()の実用的な価値は外見以上のものです。処理パイプラインをラベル付きの関数に分割し、pipe()でそれらをリンクさせることで、コードが自己文書化されるようになります。関数名だけを見れば、実装の詳細を解析することなく、シーケンスの各ステップを理解できる任何人の読者がいます。
また、デバッグ中にステップの置換やスキップも容易になります。あるpipe()呼び出しをコメントアウトしても、残りのチェーンはスムーズに実行されます。
# 効率的な結合とマージ
**
pandasで最も誤用されやすい関数の一つがmerge()です。私たちが最も頻繁に見かける2つのミスは、多対多結合と、行の増加が静かに発生することです。
両方のデータフレーム(dataframe)に結合キーで重複する値がある場合、merge()はそれらの行の直積(cartesian product)を実行します。例えば、結合キーが少なくとも片方の側で一意でない場合、「users」テーブルの500行が「events」テーブルと結合すると、数百万行の結果になることがあります。
これはエラーを発生させず、形状を確認するまで正しく見えるが予想よりも大きいDataFrameを生成します。
解決策はvalidateパラメータを使用することです:
df.merge(other, on='user_id', validate='many_to_one')
これにより、多対一の前提が違反された場合、すぐにMergeErrorが発生します。結合から期待する内容に応じて、「one_to_one」、「one_to_many」、または「many_to_one」を使用してください。
indicator=Trueパラメータもデバッグに非常に有用です:
result = df.merge(other, on='user_id', how='left', indicator=True)
result['_merge'].value_counts()
このパラメータは、各行が「left_only」、「right_only」、または「both」のどちらから来たかを示す_merge列を追加します。これは、一致すると期待した行が結合に失敗した場合を最も早く検出する方法です。
両方のデータフレームが共通のインデックスを持つ場合、merge() よりも join() の方が高速です。これは、指定された列を検索するのではなく、インデックスに対して直接操作を行うためです。
# GroupBy の最適化
GroupBy を使用する際、あまり使われていないメソッドに transform() があります。agg() と transform() の違いは、返されるデータの形状(shape)にあります。
agg() メソッド は、各グループごとに 1 行を返します。一方、transform() は元の DataFrame と同じ形状を返し、各行にはそのグループの集計値が埋められます。これにより、後続のマージ処理を必要とせずに、グループレベルの統計情報を新しい列として追加するのに最適です。また、pandas が事後に 2 つのデータフレームを整列させる必要がないため、手動で集計してマージするアプローチよりも高速です:
df['avg_revenue_by_segment'] = df.groupby('segment')['revenue'].transform('mean')
これにより、各セグメントの平均収益が各行に直接追加されます。agg() を使用して同じ結果を得る場合、平均値を計算してからセグメントキーでマージし直す必要があり、2 つの手順が必要になります。
カテゴリカルなグループ化カラムを使用する場合は、常に observed=True を指定してください:
df.groupby('segment', observed=True)['revenue'].sum()
この引数を指定しないと、pandas は列の dtype で定義されているすべてのカテゴリについて結果を計算します。これには実際のデータに存在しない組み合わせも含まれます。カテゴリ数が多くて大きなデータフレームの場合、これは空のグループと不要な計算処理を引き起こします。
# ベクトル化された条件論理
各行に対して ラムダ関数 を使用した apply() は、条件付きの値を計算する最も非効率な方法です。各行に対して独立して Python 関数を実行するため、pandas を高速化する C レベルの演算を回避してしまいます。
二値条件の場合、NumPy の np.where() が直接的な代替手段となります:
df['label'] = np.where(df['revenue'] > 1000, 'high', 'low')
複数の条件がある場合、np.select() はそれらをきれいに処理します:
conditions = [
df['revenue'] > 10000,
df['revenue'] > 1000,
df['revenue'] > 100,
]
choices = ['enterprise', 'mid-market', 'small']
df['segment'] = np.select(conditions, choices, default='micro')
np.select() 関数は、条件を順番に評価して最初に一致するオプションを割り当てることで、ベクトル化された速度で if/elif/else 構造に直接マッピングします。これは、100 万件の行を持つ DataFrame に対して同等の apply() を実行するよりも、通常 50〜100 倍高速です。
数値のビン分けにおいては、条件付き代入は完全に pd.cut()(等幅ビン)および pd.qcut()(分位数ベースのビン)に置き換えられ、これらは自動的にカテゴリカルな列を返すため、NumPy が必要ありません。ビン数やビンの境界値を渡すだけで、ラベル付けや端の値の処理を含むすべての作業を Pandas が行ってくれます。
パフォーマンスの落とし穴
**
一部の一般的なパターンは、他のいかなる要因よりも pandas コードの速度を低下させます。
例えば、iterrows()** は、DataFrame の各行を (インデックス, Series) のペアとして反復処理します。これは直感的ですが、非常に遅いアプローチです。10万件の行を持つ DataFrame において、この関数呼び出しはベクトル化された同等の処理よりも約100倍も遅くなることがあります。
この非効率性の原因は、各行ごとに完全な Series オブジェクトを構築し、それに対して Python コードを1行ずつ実行することにあります。for _, row in df.iterrows() と記述しているのを見かけたら、すぐに止まって、np.where()、np.select()、または groupby 操作でこれを置き換えられるか検討してください。多くの場合、そのいずれかで代替可能です。
apply(axis=1) は iterrows() よりも高速ですが、各行に対して Python レベルで実行されるという同じ問題を抱えています。NumPy や pandas の組み込み関数を使用して表現できる操作であれば、常に組み込みメソッドの方が高速です。
Object 型のカラムも、見落としがちな低速化の原因となります。pandas が文字列を Object 型として保存する場合、それらのカラムに対する操作は C 言語ではなく Python で実行されます。ステータスコード、地域名、カテゴリなど、低一意性(low cardinality)のカラムの場合、それらを categorical 型に変換することで、groupby や value_counts() の処理を有意に高速化できます。
df['status'] = df['status'].astype('category')
最後に、連鎖代入(chained assignment)を避けてください。df[df['revenue'] > 0]['label'] = 'positive' のような記述は、pandas が裏側でコピーを生成したかどうかによって初期 DataFrame を変更する可能性があり、その動作は未定義です。代わりに、論理マスク(boolean mask)と .loc を組み合わせて使用してください:
df.loc[df['revenue'] > 0, 'label'] = 'positive'
これは曖昧さがなく、SettingWithCopyWarning を発生させません。
結論
これらのパターンは、「動作するコード」と「よく設計されたコード」を区別します。それは、実データ上で実行するのに十分な効率性を持ち、保守可能なほど読みやすく、テストが容易な構造を持っています。
メソッドチェーンと pipe() は可読性を高め、join や groupby のパターンは正確性とパフォーマンスに対応します。ベクトル化ロジックと注意点セクションは速度に対処しています。

私たちがレビューする pandas のコードのほとんどは、少なくとも 2 つから 3 つの問題を含んでいます。これらは静かに蓄積していきます——どこかで遅いループ、どこかで検証されていないマージ、あるいは誰も気づかなかった object 型の列などです。これらの問題のいずれも明らかな失敗を引き起こすものではありませんが、そのため存続し続けています。一つずつ修正していくことが、着手するには適切な方法です。
Nate Rosidi はデータサイエンティストであり、製品戦略に携わっています。また、分析を教える非常勤教授でもあり、トップ企業からの実際の面接質問を通じてデータサイエンティストの準備を支援するプラットフォーム「StrataScratch」の創設者でもあります。Nate はキャリア市場における最新のトレンドについて執筆し、面接のアドバイスを提供し、データサイエンスプロジェクトを共有し、SQL に関するあらゆるトピックを取り上げています。
原文を表示

**
Image by Author
# Introduction
Most data scientists learn pandas** by reading tutorials and copying patterns that work.
That is fine for getting started, but it often results in beginners developing bad habits. The use of iterrows() loops, intermediate variable assignments, and repetitive merge() calls are some examples of code that is technically accurate but slower than necessary and more difficult to read than it should be.
The patterns below are not edge cases. They cover the most common daily operations in data science, such as filtering, transforming, joining, grouping, and computing conditional columns.
In each of them, there is a common approach and a better approach, and the distinction is typically one of awareness rather than complexity.
These six have the greatest impact: method chaining, the pipe() pattern, efficient joins and merges, groupby optimizations, vectorized conditional logic, and performance pitfalls.
**

# Method Chaining
Intermediate variables can make code feel more organized, but often just add noise. Method chaining** lets you write a sequence of transformations as a single expression, which reads naturally and avoids naming objects that do not need unique identifiers.
Instead of this:
df1 = df[df['status'] == 'active']
df2 = df1.dropna(subset=['revenue'])
df3 = df2.assign(revenue_k=df2['revenue'] / 1000)
result = df3.sort_values('revenue_k', ascending=False)You write this:
result = (
df
.query("status == 'active'")
.dropna(subset=['revenue'])
.assign(revenue_k=lambda x: x['revenue'] / 1000)
.sort_values('revenue_k', ascending=False)
)The lambda in assign() is important here.
When chaining, the current state of the DataFrame cannot be accessed by name; you have to use a lambda to refer to it. The most frequent cause of chains breaking is forgetting this, which typically results in a NameError or a stale reference to a variable that was defined earlier in the script.
One other mistake worth knowing is the use of inplace=True inside a chain. Methods with inplace=True return None, which breaks the chain immediately. In-place operations should be avoided when writing chained code, as they offer no memory advantage and make the code harder to follow.
# The Pipe() Pattern
**
When one of your transformations is sufficiently complex to deserve its own separate function, using pipe()** allows you to maintain it inside the chain.
pipe() passes the DataFrame as the first argument to any callable:
def normalize_columns(df, cols):
df[cols] = (df[cols] - df[cols].mean()) / df[cols].std()
return df
result = (
df
.query("status == 'active'")
.pipe(normalize_columns, cols=['revenue', 'sessions'])
.sort_values('revenue', ascending=False)
)This keeps complex transformation logic within a named, testable function while preserving the chain. Each piped function can be individually tested, which is something that becomes challenging when the logic is hidden inline within an extensive chain.
The practical value of pipe() extends beyond appearance. Dividing a processing pipeline into labeled functions and linking them with pipe() allows the code to self-document. Anyone reading the sequence can understand each step from the function name without needing to parse the implementation.
It also makes it easy to swap out or skip steps during debugging: if you comment out one pipe() call, the rest of the chain will still run smoothly.
# Efficient Joins And Merges
**
One of the most commonly misused functions in pandas is merge()**. The two mistakes we see most often are many-to-many joins and silent row inflation.
If both dataframes have duplicate values in the join key, merge() performs a cartesian product of those rows. For example, if the join key is not unique on at least one side, a 500-row "users" table joining to an "events" table can result in millions of rows.
This does not raise an error; it just produces a DataFrame that appears correct but is larger than expected until you examine its shape.
The fix is the validate parameter:
df.merge(other, on='user_id', validate='many_to_one')This raises a MergeError immediately if the many-to-one assumption is violated. Use "one_to_one", "one_to_many", or "many_to_one" depending on what you expect from the join.
The indicator=True parameter is equally useful for debugging:
result = df.merge(other, on='user_id', how='left', indicator=True)
result['_merge'].value_counts()This parameter adds a _merge column showing whether each row came from "left_only", "right_only", or "both". It is the fastest way to catch rows that failed to join when you expected them to match.
In cases where both dataframes share an index, join() is quicker than merge() since it works directly on the index instead of searching through a specified column.
# Groupby Optimizations
**
When using a GroupBy, one underused method is transform(). The difference between agg() and transform() comes down to what shape you want back.
The agg() method** returns one row per group. On the other hand, transform() returns the same shape as the original DataFrame, with each row filled with its group's aggregated value. This makes it ideal for adding group-level statistics as new columns without requiring a subsequent merge. It is also faster than the manual aggregate and merge approach because pandas does not need to align two dataframes after the fact:
df['avg_revenue_by_segment'] = df.groupby('segment')['revenue'].transform('mean')This directly adds the average revenue for each segment to each row. The same result with agg() would require computing the mean and then merging back on the segment key, using two steps instead of one.
For categorical groupby columns, always use observed=True:
df.groupby('segment', observed=True)['revenue'].sum()Without this argument, pandas computes results for every category defined in the column's dtype, including combinations that do not appear in the actual data. On large dataframes with many categories, this results in empty groups and unnecessary computation.
# Vectorized Conditional Logic
**
Using apply() with a lambda function** for each row is the least efficient way to calculate conditional values. It avoids the C-level operations that speed up pandas by running a Python function on each row independently.
For binary conditions, NumPy's np.where() is the direct replacement:
df['label'] = np.where(df['revenue'] > 1000, 'high', 'low')For multiple conditions, np.select() handles them cleanly:
conditions = [
df['revenue'] > 10000,
df['revenue'] > 1000,
df['revenue'] > 100,
]
choices = ['enterprise', 'mid-market', 'small']
df['segment'] = np.select(conditions, choices, default='micro')The np.select() function maps directly to an if/elif/else structure at vectorized speed by evaluating conditions in order and assigning the first matching option. This is usually 50 to 100 times faster than an equivalent apply() on a DataFrame with a million rows.
For numeric binning, conditional assignment is completely replaced by pd.cut() (equal-width bins) and pd.qcut() (quantile-based bins), which automatically return a categorical column without the need for NumPy. Pandas takes care of everything, including labeling and handling edge values, when you pass it the number of bins or the bin edges.
# Performance Pitfalls
**
Some common patterns slow down pandas code more than anything else.
For example, iterrows()** iterates over DataFrame rows as (index, Series) pairs. It is an intuitive but slow approach. For a DataFrame with 100,000 rows, this function call can be 100 times slower than a vectorized equivalent.
The lack of efficiency comes from building a complete Series object for every row and executing Python code on it one at a time. Whenever you find yourself writing for _, row in df.iterrows(), stop and consider whether np.where(), np.select(), or a groupby operation can replace it. Most of the time, one of them can.
Using apply(axis=1) is faster than iterrows() but shares the same problem: executing at the Python level for each row. For every operation that can be represented using NumPy or pandas built-in functions, the built-in method is always faster.
Object dtype columns are also an easy-to-miss source of slowness. When pandas stores strings as object dtype, operations on those columns run in Python rather than C. For columns with low cardinality, such as status codes, region names, or categories, converting them to a categorical dtype can meaningfully speed up groupby and value_counts().
df['status'] = df['status'].astype('category')Finally, avoid chained assignment. Using df[df['revenue'] > 0]['label'] = 'positive' could alter the initial DataFrame, depending on whether pandas generated a copy behind the scenes. The behavior is undefined. Utilize .loc alongside a boolean mask instead:
df.loc[df['revenue'] > 0, 'label'] = 'positive'This is unambiguous and raises no SettingWithCopyWarning.
# Conclusion
**
These patterns distinguish code that works from code that works well: efficient enough to run on real data, readable enough to maintain, and structured in a way that makes testing easy.
Method chaining and pipe() address readability, while the join and groupby patterns address correctness and performance. Vectorized logic and the pitfall section address speed.

Most pandas code we review has at least two or three of these issues. They accumulate quietly — a slow loop here, an unvalidated merge there, or an object dtype column nobody noticed. None of them causes obvious failures, which is why they persist. Fixing them one at a time is a reasonable place to start.
Nate Rosidi** is a data scientist and in product strategy. He's also an adjunct professor teaching analytics, and is the founder of StrataScratch, a platform helping data scientists prepare for their interviews with real interview questions from top companies. Nate writes on the latest trends in the career market, gives interview advice, shares data science projects, and covers everything SQL.
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み