pandas の for ループは本当に悪いのでしょうか? いつ気にするべきでしょうか? 質問する

pandas の for ループは本当に悪いのでしょうか? いつ気にするべきでしょうか? 質問する

ループは本当に「悪い」のでしょうかfor? そうでないなら、どのような状況で、より従来の「ベクトル化された」アプローチを使用するよりもループのほうが優れているのでしょうか? 1

私は「ベクトル化」の概念と、Pandas がベクトル化技術を使用して計算を高速化する方法に精通しています。ベクトル化された関数は、シリーズ全体または DataFrame 全体に操作をブロードキャストして、従来のデータ反復処理よりもはるかに高速化を実現します。

しかし、ループやリスト内包表記を使用してデータをループする問題に対するソリューションを提供するコード (Stack Overflow の回答を含む) が多数あることに、私は非常に驚いていますfor。ドキュメントと API では、ループは「悪い」ものであり、配列、シリーズ、または DataFrame を「決して」反復処理してはならないとされています。では、なぜユーザーがループベースのソリューションを提案しているのを時々見かけるのでしょうか。


1 - この質問は確かにやや広範囲に聞こえるかもしれませんが、実際には、ループが従来のデータ反復処理よりも優れている非常に特殊な状況がありますfor。この投稿は、後世のためにこれを記録することを目指しています。

ベストアンサー1

TLDR; いいえ、forループは必ずしも「悪い」というわけではありません。おそらくより正確に言うと、ベクトル化された操作は反復処理よりも遅い。、反復処理はベクトル化された操作よりも高速であると言うのではなく、いつ、なぜ反復処理が高速であるかを知ることが、コードから最大限のパフォーマンスを引き出す鍵となります。簡単に言えば、ベクトル化された pandas 関数の代替を検討する価値がある状況は次のとおりです。

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

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


小規模データにおける反復処理とベクトル化

パンダは「設定よりも規約」API 設計におけるアプローチ。つまり、同じ API が幅広いデータとユースケースに対応できるように適合されているということです。

pandas関数が呼び出されると、関数が内部的に以下の処理を行わなければならず、関数が確実に機能するようにする必要がある。

  1. インデックス/軸の配置
  2. 混合データ型の処理
  3. 欠損データの処理

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

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

リスト内包表記はパターンに従う

[f(x) for x in seq]

seqパンダシリーズまたはDataFrame列はどこにありますか。または、複数の列を操作する場合は、

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

ここでseq1、 とseq2は列です。

数値比較
単純なブールインデックス操作を考えてみましょう。リストの内包法は、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 テストを実行するパッケージ。上記の操作のタイミングは次のとおりです。

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

リストの内包表記は、中程度のサイズの N に対しては優れたパフォーマンスを発揮しquery、小さな N に対してはベクトル化された不等号比較よりも優れたパフォーマンスを発揮します。残念ながら、リストの内包表記は線形に拡張されるため、N が大きい場合のパフォーマンスの向上はあまり見られません。

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

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

これは、pandas とリスト内包表記の両方の同等機能よりも優れています。NumPy

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

価値が重要
別の例を見てみましょう。今回は、別のバニラPython構造です。もっと早くforループよりも -collections.Counter一般的な要件は、値の数を計算し、その結果を辞書として返すことです。これは次のように行われます。value_countsnp.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)。はCounterCアクセラレータしたがって、基礎となる C データ型ではなく Python オブジェクトで動作する必要がありますが、それでもループよりも高速ですfor。Python のパワー!

もちろん、ここでのポイントは、パフォーマンスはデータと使用例に依存するということです。これらの例のポイントは、これらのソリューションを正当な選択肢として除外しないように説得することです。それでも必要なパフォーマンスが得られない場合は、シトンそしてナンバーこのテストをミックスに追加してみましょう。

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


混合/ objectdtypesの操作

文字列ベースの比較
最初のセクションのフィルタリングの例をもう一度見てみましょう。比較する列が文字列の場合はどうなるでしょうか? 上記と同じ 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つの操作のタイミング(例外処理)、mapstr.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)

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

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

リストのフラット化
最後の例はリストのフラット化です。これはもう 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ソリューションよりもはるかにスケーラビリティに優れています。

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

Lastly, the applicability of these solutions depend widely on your data. So, the best thing to do would be to test these operations on your data before deciding what to go with. Notice how I have not timed apply on these solutions, because it would skew the graph (yes, it's that slow).


Regex Operations, and .str Accessor Methods

Pandas can apply regex operations such as str.contains, str.extract, and str.extractall, as well as other "vectorized" string operations (such as str.split, str.find, str.translate, and so on) on string columns. These functions are slower than list comprehensions, and are meant to be more convenience functions than anything else.

It is usually much faster to pre-compile a regex pattern and iterate over your data with re.compile (also see Is it worth using Python's re.compile?). The list comp equivalent to str.contains looks something like this:

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

Or,

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

If you need to handle NaNs, you can do something like

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

The list comp equivalent to str.extract (without groups) will look something like:

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

If you need to handle no-matches and NaNs, you can use a custom function (still faster!):

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']]

The matcher function is very extensible. It can be fitted to return a list for each capture group, as needed. Just extract query the group or groups attribute of the matcher object.

For str.extractall, change p.search to p.findall.

String Extraction
Consider a simple filtering operation. The idea is to extract 4 digits if it is preceded by an upper case letter.

# 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

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

More Examples
Full disclosure - I am the author (in part or whole) of these posts listed below.


Conclusion

As shown from the examples above, iteration shines when working with small rows of DataFrames, mixed datatypes, and regular expressions.

The speedup you get depends on your data and your problem, so your mileage may vary. The best thing to do is to carefully run tests and see if the payout is worth the effort.

The "vectorized" functions shine in their simplicity and readability, so if performance is not critical, you should definitely prefer those.

Another side note, certain string operations deal with constraints that favour the use of NumPy. Here are two examples where careful NumPy vectorization outperforms python:

Additionally, sometimes just operating on the underlying arrays via .values as opposed to on the Series or DataFrames can offer a healthy enough speedup for most usual scenarios (see the Note in the Numeric Comparisonセクションを参照してください。したがって、たとえば、 は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
)

おすすめ記事