パンダのforループは本当に悪いのですか?いつ気にすべきですか?


105

あるforループは、実際には「悪いですか」?そうでない場合、どのような状況で、従来の「ベクトル化された」アプローチを使用するよりも優れていますか?1

私は「ベクトル化」の概念と、パンダが計算を高速化するためにベクトル化された手法をどのように使用するかをよく知っています。ベクトル化された関数は、シリーズ全体またはDataFrame全体に操作をブロードキャストし、従来のデータの反復よりはるかに高速化します。

しかし、forループとリスト内包表記を使用してデータをループすることを含む問題の解決策を提供する多くのコード(Stack Overflowでの回答を含む)を提供していることに驚いています。ドキュメントとAPIは、ループは「悪い」ものであり、配列、系列、またはデータフレームを反復することは「決して」すべきではないと述べています。それで、ループベースのソリューションを提案しているユーザーを時々見るのはなぜですか?


1-質問がいくぶん広範に聞こえることは事実ですが、forループは通常、従来のデータの反復よりも優れているという非常に特殊な状況があります。この投稿は、後世のためにこれを捉えることを目的としています。

回答:


142

TLDR; いいえ、forループは、必ずしも常にではありませんが、包括的ではありません。いくつかのベクトル化された操作は反復よりも遅いと言う方が、おそらくより正確であり、反復はいくつかのベクトル化された操作よりも速いと言います。コードから最大のパフォーマンスを引き出すには、いつ、どのような理由でそれを知ることが重要です。一言で言えば、これらはベクトル化されたパンダ関数の代替を検討する価値がある状況です:

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

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


小さなデータでの反復対ベクトル化

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

pandas関数が呼び出されたとき、機能することを保証するために、(特に)次のことを関数によって内部的に処理する必要があります。

  1. インデックス/軸合わせ
  2. 混合データ型の処理
  3. 欠落データの処理

ほとんどすべての関数はこれらをさまざまな範囲で処理する必要があり、これによりオーバーヘッドが発生します。オーバーヘッドは、数値関数(たとえば、Series.add)の方が少なく、文字列関数(たとえば、Series.str.replace)の場合はより顕著です。

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

リスト内包表記は次のパターンに従います

[f(x) for x in seq]

seqpandasシリーズまたは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の場合よりも優れており、小さなNの場合のベクトル化不等比較よりも優れています。残念ながら、リスト内包表記は線形にスケーリングするため、より大きなNの場合、パフォーマンスはあまり向上しません。


リスト内包表記の利点の多くはインデックスアライメントを気にする必要がないことからもたらされますが、これは、コードがインデックスアライメントに依存している場合、機能しなくなることを意味します。場合によっては、基になるNumPy配列に対するベクトル化された操作は、「両方の長所」をもたらすと見なすことができ、パンダ関数の不要なオーバーヘッドなしでベクトル化が可能になります。これは、上記の操作を次のように書き換えることができることを意味します

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

これはパンダとリスト内包表記の両方を上回っています

。NumPyベクトル化はこの投稿の範囲外ですが、パフォーマンスが重要な場合は、検討する価値があります。

値のカウント
別の例として、今回は、forループより高速な別のpythonコンストラクトを使用しますcollections.Counter。一般的な要件は、値の数を計算し、結果を辞書として返すことです。これはで行われvalue_countsnp.uniqueCounter

# 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アクセラレータ代わりに基礎となるCのデータ型のオブジェクト、それはより速くまだそれはまだのpythonで動作するように持っているようにしながら、forループ。Pythonパワー!

もちろん、ここからのポイントは、パフォーマンスはデータとユースケースに依存するということです。これらの例のポイントは、これらのソリューションを正当なオプションとして除外しないように説得することです。それでも必要なパフォーマンスが得られない場合は、常にcythonnumbaがあります。このテストをミックスに追加しましょう。

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を機能させる方法を理解するには、学習曲線が必要です。


混合/ objectdtypeの操作

文字列ベースの比較
最初のセクションのフィルタリングの例に戻り、比較される列が文字列の場合はどうなりますか?上記と同じ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番目の要素(例外の処理)mapstr.getアクセサメソッド、およびリスト内包を抽出する3つの操作の定位置リストインデックスのタイミング:

# 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)

シリーズを再構築するとき。

ここに画像の説明を入力してください

リストのフラット化
最後の例は、リストのフラット化です。これはもう1つの一般的な問題であり、純粋な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ソリューションよりもはるかに優れています。

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

最後に、これらのソリューションの適用性はデータに大きく依存します。したがって、何をすべきかを決定する前に、データに対してこれらの操作をテストするのが最善の方法です。applyグラフをゆがめるため、これらのソリューションで時間を計っていないことに注意してください(そう、それは遅いです)。


正規表現の操作と.strアクセサーメソッド

パンダは、次のような正規表現の操作を適用することができstr.containsstr.extractおよびstr.extractall、ならびに(例えば、他の「ベクトル化」文字列操作str.splitstr.find、,文字列の列に、等str.translate`)。これらの関数はリスト内包表記よりも遅く、何よりも便利な関数であることが意図されています。

通常、正規表現パターンをプリコンパイルしてデータを反復処理する方がはるかに高速ですre.compilePythonのre.compileを使用する価値はありますか?も参照してください)。に相当するリストcomp 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(グループなしの)に相当するリストcomp は次のようになります。

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機能は非常に拡張可能です。必要に応じて、各キャプチャグループのリストを返すように調整できます。matcherオブジェクトのgroupor 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の小さな行、混合データ型、および正規表現を操作する場合に、反復が効果的です。

あなたが得るスピードアップはあなたのデータとあなたの問題に依存するので、あなたのマイレージは異なるかもしれません。最善の方法は、慎重にテストを実行し、支払いの価値があるかどうかを確認することです。

「ベクトル化された」関数は、そのシンプルさと読みやすさの点で優れています。そのため、パフォーマンスが重要でない場合は、これらを優先する必要があります。

別の注意点として、特定の文字列操作はNumPyの使用を好む制約を扱います。以下は、注意深いNumPyベクトル化がPythonよりも優れている2つの例です。

さらに、.valuesSeriesまたはDataFramesとは対照的に、基になるアレイを操作するだけで、ほとんどの通常のシナリオで十分な速度の向上が得られる場合があります(上記の数値比較セクションのを参照)。したがって、たとえばは、を超えるとすぐにパフォーマンスが向上します。使用はすべての状況で適切であるとは限りませんが、知っておくと便利です。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
)

pd.Seriesそしてpd.DataFrame今、反復可能オブジェクトから構築をサポート。つまり、最初にリストを作成する必要はなく(リスト内包表記を使用)、Pythonジェネレーターをコンストラクター関数に渡すだけで済みます。多くの場合、これは遅くなる可能性があります。ただし、発電機出力のサイズを事前に決定することはできません。どのくらいの時間/メモリのオーバーヘッドが発生するかわかりません。
GZ0

@ GZ0 IIRC、イテレータの受け入れは、APIへの最近の追加です。「それは、リストを最初に作成する必要がなく、Pythonジェネレーターをコンストラクター関数に単に渡すことができることを意味します」に関しては、私は同意しません。たぶんメモリ、パフォーマンスなし。私の経験では、通常、ほとんどの場合、リストを生成して渡す方が高速です。comps FTWをリストします。
cs95

@ cs95ジェネレーターがパフォーマンスの向上につながると思いませんか?それともテストをしましたか?
GZ0

@ GZ0ジェネレーターがパフォーマンスの向上につながらないとは言いませんでした。つまり、リスト内包表記を使用した場合よりも多くの効果が得られないと言っています。Genexpsの初期化、状態の維持などに関連するオーバーヘッドがありますが、リストコンプにはありません。ここでは、ジェネレータの内包表記と比較して比較できるリスト内包表記の例がたくさんあります。すぐに実行できる例を次に示しますser = pd.Series([['a', 'b', 'c'], ['d', 'e'], ['f', 'g']] * 10000)%timeit pd.Series(y for x in ser for y in x); %timeit pd.Series([y for x in ser for y in x])
cs95

@ cs95あなたはおそらく正しいです。私はコンストラクターのコードをざっと見てみましたpd.Seriesが、反復可能な出力のサイズを決定できない場合は、とにかく反復可能なリストからリストが作成されることがわかりました。したがって、ほとんどの場合、ジェネレーターを使用するメリットはありません。しかし、私が実行したとき、あなたがジェネレーターバージョンに与えた例は実際には遅いです。違いがどこから来るのかわかりません。
GZ0

1

要するに

  • forループ+ iterrowsは非常に遅いです。オーバーヘッドは〜1k行では重要ではありませんが、10k +行では顕著です。
  • ループ+のためにitertuplesはるかに高速よりもありますiterrowsapply
  • ベクトル化は通常、 itertuples

基準 ここに画像の説明を入力してください

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.