1. ホーム
  2. python

[解決済み] pandasのforループは本当にダメなのか?どのような場合に気をつけるべきですか?

2022-06-22 17:02:37

質問

あなたは for ループは本当に悪いのでしょうか?もしそうでないなら、どのような状況で、より従来型の "ベクトル化された" アプローチを使うよりも良いのでしょうか? 1

私はベクトル化の概念と、pandasがベクトル化技術を使用して計算を高速化する方法についてよく知っています。ベクトル化された関数は、シリーズまたはDataFrame全体に対して操作をブロードキャストし、従来のデータに対する反復処理よりもはるかに大きなスピードアップを達成します。

しかし、多くのコード (Stack Overflow での回答も含む) が for ループとリスト内包を使用したデータ ループを含む問題の解決策を提供する多くのコード (Stack Overflow での回答を含む) を目にし、非常に驚きました。ドキュメントと API には、ループは悪いものであり、配列、系列、または DataFrame に対して反復処理を行うべきではありませんと書かれています。それなのに、ループベースのソリューションを提案するユーザーを時々見かけるのはなぜでしょうか?


1 - この質問はやや幅広く聞こえるのは事実ですが、実際には、次のような非常に特殊な状況があります。 for ループの方が、従来通りデータを繰り返し処理するよりも良い場合があります。この投稿は、後世に残すためにこれを捕らえることを目的としています。

どのように解決するのですか?

TLDR; いいえ。 for ループは、少なくとも、常に、ブランケット "悪い"ではありません。それはおそらく より正確には、いくつかのベクトル化されたオペレーションは反復処理より遅いです。 と言う方が正確でしょう。いつ、なぜそうなるのかを知ることは、コードのパフォーマンスを最大限に引き出すための鍵です。一言で言えば、これらはベクトル化されたpandas関数の代替を検討する価値がある状況です。

  1. データが小さい場合(...何をしているかにもよりますが)。
  2. を扱う場合 object /mixed dtypes
  3. を使用する場合 str /regex アクセサ関数

これらの状況を個別に検証してみましょう。


小さなデータでの反復処理とベクトル化

Pandasは "Convention Over Configuration" アプローチでAPIを設計しています。これは、同じAPIが幅広いデータとユースケースに対応するように適合されていることを意味します。

pandasの関数が呼び出されたとき、次のようなことが(とりわけ)関数によって内部的に処理され、動作が保証される必要があります。

  1. インデックス/軸のアライメント
  2. 混合データ型の処理
  3. 欠落データの処理

ほとんどすべての関数が、程度の差こそあれ、これらを処理する必要があります。 オーバーヘッド . 数値関数の場合はオーバーヘッドが少なくなります(例えば。 Series.add など)、文字列関数では顕著になります(例えば Series.str.replace ).

for 一方、ループは思ったより速いです。さらに良いのは リスト内包 (これは for ループでリストを作成するもの) は、リスト作成のための最適化された反復メカニズムであるため、さらに高速です。

リスト内包はパターンに従って

[f(x) for x in seq]

ここで seq はpandasの系列またはDataFrameのカラムです。または、複数のカラムに対して操作する場合。

[f(x, y) for x, y in zip(seq1, seq2)]

ここで seq1seq2 は列である。

数値の比較

単純なブーリアンインデックス演算を考えてみましょう。リスト内包メソッドは、時間に対して Series.ne ( != ) と query . 以下はその機能です。

# Boolean indexing with Numeric value comparison.
df[df.A != df.B]                            # vectorized !=
df.query('A != B')                          # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]]    # list comp

わかりやすくするために perfplot パッケージを使用して、この記事のすべてのtimeitテストを実行しました。上記の操作のタイミングは以下の通りです。

リスト内包は query を上回り、小さなNではベクトル化されたnot equal比較さえも上回ります。残念ながら、リスト内包は線形にスケールするので、大きなNではあまり性能向上は望めません。

<ブロッククオート

ノート

リスト内包の利点の多くは、インデックスのアライメントを気にする必要がないことから来ていることは言及に値します。 しかし、これはあなたのコードがインデックスのアライメントに依存している場合、それが壊れることを意味します。 これは、コードがインデックスのアライメントに依存している場合、これが壊れることを意味します。いくつかのケースでは、ベクトル化された操作で 場合によっては、NumPyの配列に対するベクトル化された操作は、両方の良いところを取り入れたと考えることができます。 両方の世界" をもたらすと考えることができ、ベクトル化を可能にします。 を使わずに pandasの関数のすべての不要なオーバーヘッドなしでベクトル化を可能にします。これは、上記の操作を次のように書き換えることができることを意味します。

df[df.A.values != df.B.values]

これはpandasとリスト内包の等価物の両方を凌駕しています。



NumPyのベクトル化はこの記事の範囲外ですが、パフォーマンスが重要であれば、間違いなく検討する価値があります。

値のカウント

別の例として、今度は別のバニラパイソンの構成である より速く ループよりも高速です。 collections.Counter . よくある要件は、値のカウントを計算し、その結果を辞書として返すことです。これは value_counts , np.unique そして Counter :

# Value Counts comparison.
ser.value_counts(sort=False).to_dict()           # value_counts
dict(zip(*np.unique(ser, return_counts=True)))   # np.unique
Counter(ser)                                     # Counter

より顕著になりました。 Counter は、より大きな範囲の小さなN (~3500) に対して、両方のベクトル化されたメソッドよりも勝っています。



さらなるトリビア(提供:@user2357112)。その Counter が実装されているのは C アクセラレータ , ということで、まだ Python のオブジェクトを使う必要がありますが の代わりに python のオブジェクトを扱う必要がありますが、それでも for ループよりも高速です。Python のパワー!

もちろん、ここから得られる教訓は、パフォーマンスはデータとユースケースに依存するということです。これらの例のポイントは、これらのソリューションを正当な選択肢として除外しないように説得することです。これらのソリューションで必要なパフォーマンスが得られない場合は、常に cython numba . このテストを追加してみましょう。

from numba import njit, prange

@njit(parallel=True)
def get_mask(x, y):
    result = [False] * len(x)
    for i in prange(len(x)):
        result[i] = x[i] != y[i]
    
    return np.array(result)

df[get_mask(df.A.values, df.B.values)] # numba

Numbaは、ループするPythonコードを非常に強力なベクトル化されたコードにJITコンパイルする機能を提供します。numbaをどのように動作させるかを理解するには、学習曲線が伴います。


Mixed/を使った操作 object dtypes

文字列ベースの比較

最初のセクションのフィルタリングの例を見て、比較されるカラムが文字列の場合はどうでしょうか?上の3つの関数と同じですが、入力のDataFrameを文字列にキャストして考えてみましょう。

# Boolean indexing with string value comparison.
df[df.A != df.B]                            # vectorized !=
df.query('A != B')                          # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]]    # list comp

では、何が変わったのでしょうか?ここで注目すべきは 文字列の操作は本質的にベクトル化するのが難しいということです。 Pandasは文字列をオブジェクトとして扱い、オブジェクトに対するすべての操作は遅くてループする実装にフォールバックします。

さて、このルービーな実装は、上記のすべてのオーバーヘッドに囲まれているため、これらのソリューションの間には、同じスケールであっても一定の大きさの違いがあります。

ミュータブル/複雑なオブジェクトに対する操作に関しては、比較の対象にはなりません。リスト内包は、ディクテとリストを含むすべての操作を凌駕します。

キーによる辞書の値へのアクセス

以下は、辞書のカラムから値を取り出す2つの操作のタイミングです。 map とリスト内包です。セットアップは付録の「コード・スニペット」の見出しの下にあります。

# Dictionary value extraction.
ser.map(operator.itemgetter('value'))     # map
pd.Series([x.get('value') for x in ser])  # list comprehension

位置決めリストインデックス

列のリストから0番目の要素を抽出する3つの操作のタイミング(例外処理)。 map , str.get アクセサーメソッド とリスト内包があります。

# List positional indexing. 
def get_0th(lst):
    try:
        return lst[0]
    # Handle empty lists and NaNs gracefully.
    except (IndexError, TypeError):
        return np.nan

ser.map(get_0th)                                          # map
ser.str[0]                                                # str accessor
pd.Series([x[0] if len(x) > 0 else np.nan for x in ser])  # list comp
pd.Series([get_0th(x) for x in ser])                      # list comp safe

<ブロッククオート

ノート

インデックスが重要なら、そうしたいものです。

pd.Series([...], index=ser.index)


系列を再構築する場合。

リストの平坦化

最後の例は、リストを平坦化することです。これもよくある問題で、純粋なPythonがいかに強力であるかを示しています。

# Nested list flattening.
pd.DataFrame(ser.tolist()).stack().reset_index(drop=True)  # stack
pd.Series(list(chain.from_iterable(ser.tolist())))         # itertools.chain
pd.Series([y for x in ser for y in x])                     # nested list comp

どちらも itertools.chain.from_iterable とネストされたリスト内包は純粋なPythonの構造であり、その拡張性は stack ソリューションよりもずっとうまくいきます。

これらのタイミングは、pandasが混合されたdtypesで動作するように装備されていないという事実の強い兆候であり、おそらくそうするためにそれを使用することを控えるべきである。可能な限り、データは別々の列にスカラー値(ints/floats/strings)として存在すべきです。

最後に、これらのソリューションの適用可能性は、データによって大きく異なります。したがって、最善の方法は、何を採用するかを決定する前に、自分のデータでこれらの操作をテストすることでしょう。私がどのように時間を計っていないかに注目してください。 apply の時間を計っていないことに注意してください。グラフが歪んでしまうからです (そう、それほど遅いのです)。


Regex 操作、および .str アクセサメソッド

Pandasは以下のような正規表現操作を行うことができます。 str.contains , str.extract そして str.extractall といった文字列操作や、他のベクトル化された文字列操作(例えば str.split , str.find , str.translate など) を文字列列に対して実行します。これらの関数はリスト内包よりも遅く、どちらかというと便利な関数であることを意図しています。

通常、正規表現パターンを事前にコンパイルし re.compile (また Pythonのre.compileを使う価値はありますか? ). リストコンパイルに相当するのは str.contains はこのようになります。

p = re.compile(...)
ser2 = pd.Series([x for x in ser if p.search(x)])

または

ser2 = ser[[bool(p.search(x)) for x in ser]]

NaNを処理する必要がある場合は、以下のようにします。

ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]

と同等のリストコンパ str.extract (グループなし)は以下のような感じになります。

df['col2'] = [p.search(x).group(0) for x in df['col']]

ノーマッチやNaNを処理する必要がある場合は、カスタム関数を使用することができます(それでも高速です!)。

def matcher(x):
    m = p.search(str(x))
    if m:
        return m.group(0)
    return np.nan

df['col2'] = [matcher(x) for x in df['col']]

matcher 関数は非常に拡張性があります。必要に応じて、各キャプチャグループのリストを返すようにすることができます。ただ、クエリを抽出し group または groups 属性で指定します。

については str.extractall を変更します。 p.searchp.findall .

文字列抽出

簡単なフィルタリング操作を考えてみましょう。大文字が前にあれば、4桁の数字を抽出するというものです。

# Extracting strings.
p = re.compile(r'(?<=[A-Z])(\d{4})')
def matcher(x):
    m = p.search(x)
    if m:
        return m.group(0)
    return np.nan

ser.str.extract(r'(?<=[A-Z])(\d{4})', expand=False)   #  str.extract
pd.Series([matcher(x) for x in ser])                  #  list comprehension

その他の例

完全な開示 - 私は以下の投稿の著者です(一部または全部)。


結論

上記の例からわかるように、反復処理は小さな行のDataFrame、混合データ型、および正規表現を扱うときに輝きます。

得られる速度向上はデータと問題に依存するため、あなたのマイレージは異なるかもしれません。最も良いのは、慎重にテストを実行し、その労力に見合うだけの利益が得られるかどうかを確認することです。

vectorized"関数は、そのシンプルさと可読性において輝いています。

もう一つの側面は、ある文字列操作がNumPyの使用に有利な制約を扱っていることです。ここでは、注意深くNumPyのベクトル化がpythonを凌駕する2つの例を紹介します。

さらに、時には、基礎となる配列を .values で操作するだけで、通常のシナリオでは十分な速度が得られることがあります ( 数値の比較 の項を参照)。ですから、例えば df[df.A.values != df.B.values]df[df.A != df.B] . 使用方法 .values を使うことは、すべての状況で適切であるとは限りませんが、知っておくと便利なハックです。

上述したように、これらの解決策がわざわざ実装する価値があるかどうかは、あなた次第です。


付録 コード・スニペット

import perfplot  
import operator 
import pandas as pd
import numpy as np
import re

from collections import Counter
from itertools import chain

<!- ->

# Boolean indexing with Numeric value comparison.
perfplot.show(
    setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=['A','B']),
    kernels=[
        lambda df: df[df.A != df.B],
        lambda df: df.query('A != B'),
        lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
        lambda df: df[get_mask(df.A.values, df.B.values)]
    ],
    labels=['vectorized !=', 'query (numexpr)', 'list comp', 'numba'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N'
)

<!- ->

# Value Counts comparison.
perfplot.show(
    setup=lambda n: pd.Series(np.random.choice(1000, n)),
    kernels=[
        lambda ser: ser.value_counts(sort=False).to_dict(),
        lambda ser: dict(zip(*np.unique(ser, return_counts=True))),
        lambda ser: Counter(ser),
    ],
    labels=['value_counts', 'np.unique', 'Counter'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=lambda x, y: dict(x) == dict(y)
)

<!- ->

# Boolean indexing with string value comparison.
perfplot.show(
    setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=['A','B'], dtype=str),
    kernels=[
        lambda df: df[df.A != df.B],
        lambda df: df.query('A != B'),
        lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
    ],
    labels=['vectorized !=', 'query (numexpr)', 'list comp'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)

<!- ->

# Dictionary value extraction.
ser1 = pd.Series([{'key': 'abc', 'value': 123}, {'key': 'xyz', 'value': 456}])
perfplot.show(
    setup=lambda n: pd.concat([ser1] * n, ignore_index=True),
    kernels=[
        lambda ser: ser.map(operator.itemgetter('value')),
        lambda ser: pd.Series([x.get('value') for x in ser]),
    ],
    labels=['map', 'list comprehension'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)

<!- ->

# List positional indexing. 
ser2 = pd.Series([['a', 'b', 'c'], [1, 2], []])        
perfplot.show(
    setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
    kernels=[
        lambda ser: ser.map(get_0th),
        lambda ser: ser.str[0],
        lambda ser: pd.Series([x[0] if len(x) > 0 else np.nan for x in ser]),
        lambda ser: pd.Series([get_0th(x) for x in ser]),
    ],
    labels=['map', 'str accessor', 'list comprehension', 'list comp safe'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)

<!- ->

# Nested list flattening.
ser3 = pd.Series([['a', 'b', 'c'], ['d', 'e'], ['f', 'g']])
perfplot.show(
    setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
    kernels=[
        lambda ser: pd.DataFrame(ser.tolist()).stack().reset_index(drop=True),
        lambda ser: pd.Series(list(chain.from_iterable(ser.tolist()))),
        lambda ser: pd.Series([y for x in ser for y in x]),
    ],
    labels=['stack', 'itertools.chain', 'nested list comp'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',    
    equality_check=None
    
)

<!- _>

# Extracting strings.
ser4 = pd.Series(['foo xyz', 'test A1234', 'D3345 xtz'])
perfplot.show(
    setup=lambda n: pd.concat([ser4] * n, ignore_index=True),
    kernels=[
        lambda ser: ser.str.extract(r'(?<=[A-Z])(\d{4})', expand=False),
        lambda ser: pd.Series([matcher(x) for x in ser])
    ],
    labels=['str.extract', 'list comprehension'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)