web-dev-qa-db-ja.com

pandas列を条件付きで作成する最速の方法

Pandas DataFrameで、別の列の値に基づいて条件付きで新しい列を作成したい。私のアプリケーションでは、DataFrameには通常、数百万行と一意の条件値の数があります。は小さく、1のオーダーです。パフォーマンスは非常に重要です:新しい列を生成する最速の方法は何ですか?

以下にサンプルケースを作成し、すでにさまざまな方法を試して比較しました。この例では、条件付き入力は、列label(ここでは_1, 2, 3_の1つ)の値に基づくディクショナリルックアップによって表されます。

_lookup_dict = {
    1: 100,   # arbitrary
    2: 200,   # arbitrary
    3: 300,   # arbitrary
    }
_

次に、DataFrameが次のように入力されることを期待します。

_       label  output
0      3     300
1      2     200
2      3     300
3      3     300
4      2     200
5      2     200
6      1     100
7      1     100
_

以下は、1,000万行でテストされた6つの異なるメソッドです(テストコードのパラメーターNlines)。

  • メソッド1:pandas.groupby().apply()
  • メソッド2:pandas.groupby().indices.items()
  • 方法3:_pandas.Series.map_
  • 方法4:ラベルのforループ
  • 方法5:_numpy.select_
  • 方法6:numba

完全なコードは、すべてのメソッドのランタイムとともに、回答の最後にあります。すべてのメソッドの出力は、パフォーマンスが比較される前に等しいとアサートされます。

メソッド1:pandas.groupby().apply()

labelpandas.groupby()を使用し、次にapply()を使用して各ブロックに同じ値を入力します。

_def fill_output(r):
    ''' called by groupby().apply(): all r.label values are the same '''
    r.loc[:, 'output'] = lookup_dict[r.iloc[0]['label']]
    return r

df = df.groupby('label').apply(fill_output)
_

私は得る

_>>> method_1_groupby ran in 2.29s (average over 3 iterations)
_

Groupby()。apply()が最初のグループで2回実行され、使用するコードパスが決定されることに注意してください( パンダ#2936 を参照)。これにより、少数のグループの処理が遅くなる可能性があります。方法1で最初のダミーグループを追加できるように騙しましたが、あまり改善されませんでした。

メソッド2:pandas.groupby().indices.items()

2つ目はバリアントです。applyを使用する代わりに、groupby().indices.items()を使用してインデックスに直接アクセスします。これは最終的に方法1の2倍の速度になり、これは私が長い間使用してきた方法です。

_dgb = df.groupby('label')
for label, idx in dgb.indices.items():
    df.loc[idx, 'output'] = lookup_dict[label]
_

入手した:

_method_2_indices ran in 1.21s (average over 3 iterations)
_

方法3:_pandas.Series.map_

Pandas.Series.map を使用しました。

_df['output'] = df.label.map(lookup_dict.get)
_

ルックアップされた値の数が行の数と同等である同様のケースで、非常に良い結果が得られました。この場合、mapは方法1の2倍遅くなります。

method_3_mapは3.07秒で実行されました(3回の反復の平均)

ルックアップ値の数が少ないためだと思いますが、実装方法に問題がある可能性があります。

方法4:ラベルのforループ

4番目の方法は非常に単純です。すべてのラベルをループして、DataFrameの一致する部分を選択するだけです。

_for label, value in lookup_dict.items():
    df.loc[df.label == label, 'output'] = value
_

しかし、驚いたことに、以前の場合よりもはるかに高速な結果が得られました。 Pandasはここで_df.label == label_と3回比較する必要があるため、groupbyベースのソリューションはこれよりも高速であると期待していました。結果は私が間違っていることを証明しています。

_method_4_forloop ran in 0.54s (average over 3 iterations)
_

方法5:_numpy.select_

5番目の方法は、これに基づいてnumpy select関数を使用します StackOverflow answer

_conditions = [df.label == k for k in lookup_dict.keys()]
choices = list(lookup_dict.values())

df['output'] = np.select(conditions, choices)
_

これにより、最良の結果が得られます。

_method_5_select ran in 0.29s (average over 3 iterations)
_

最終的に、私は方法6でnumbaアプローチを試しました。

方法6:numba

例のために、条件付き入力値はコンパイルされた関数のハードコードです。 Numbaにランタイム定数としてリストを与える方法がわかりません:

_@jit(int64[:](int64[:]), nopython=True)
def hardcoded_conditional_filling(column):
    output = np.zeros_like(column)
    i = 0
    for c in column:
        if c == 1:
            output[i] = 100
        Elif c == 2:
            output[i] = 200
        Elif c == 3:
            output[i] = 300
        i += 1
    return output

df['output'] = hardcoded_conditional_filling(df.label.values)
_

方法5よりも50%速く、最高の時間になりました。

_method_6_numba ran in 0.19s (average over 3 iterations)
_

上記の理由により、これを実装していません。パフォーマンスを大幅に低下させることなく、実行時定数としてNumbaにリストを与える方法がわかりません。


完全なコード

_import pandas as pd
import numpy as np
from timeit import timeit
from numba import jit, int64

lookup_dict = {
        1: 100,   # arbitrary
        2: 200,   # arbitrary
        3: 300,   # arbitrary
        }

Nlines = int(1e7)

# Generate 
label = np.round(np.random.Rand(Nlines)*2+1).astype(np.int64)
df0 = pd.DataFrame(label, columns=['label'])

# Now the goal is to assign the look_up_dict values to a new column 'output' 
# based on the value of label

# Method 1
# using groupby().apply()

def method_1_groupby(df):

    def fill_output(r):
        ''' called by groupby().apply(): all r.label values are the same '''
        #print(r.iloc[0]['label'])   # activate to reveal the #2936 issue in Pandas
        r.loc[:, 'output'] = lookup_dict[r.iloc[0]['label']]
        return r

    df = df.groupby('label').apply(fill_output)
    return df 

def method_2_indices(df):

    dgb = df.groupby('label')
    for label, idx in dgb.indices.items():
        df.loc[idx, 'output'] = lookup_dict[label]

    return df

def method_3_map(df):

    df['output'] = df.label.map(lookup_dict.get)

    return df

def method_4_forloop(df):
    ''' naive '''

    for label, value in lookup_dict.items():
        df.loc[df.label == label, 'output'] = value

    return df

def method_5_select(df):
    ''' Based on answer from 
    https://stackoverflow.com/a/19913845/5622825
    '''

    conditions = [df.label == k for k in lookup_dict.keys()]
    choices = list(lookup_dict.values())

    df['output'] = np.select(conditions, choices)

    return df

def method_6_numba(df):
    ''' This works, but it is hardcoded and i don't really know how
    to make it compile with list as runtime constants'''


    @jit(int64[:](int64[:]), nopython=True)
    def hardcoded_conditional_filling(column):
        output = np.zeros_like(column)
        i = 0
        for c in column:
            if c == 1:
                output[i] = 100
            Elif c == 2:
                output[i] = 200
            Elif c == 3:
                output[i] = 300
            i += 1
        return output

    df['output'] = hardcoded_conditional_filling(df.label.values)

    return df

df1 = method_1_groupby(df0)
df2 = method_2_indices(df0.copy())
df3 = method_3_map(df0.copy())
df4 = method_4_forloop(df0.copy())
df5 = method_5_select(df0.copy())
df6 = method_6_numba(df0.copy())

# make sure we havent modified the input (would bias the results)
assert 'output' not in df0.columns 

# Test validity
assert (df1 == df2).all().all()
assert (df1 == df3).all().all()
assert (df1 == df4).all().all()
assert (df1 == df5).all().all()
assert (df1 == df6).all().all()

# Compare performances
Nites = 3
print('Compare performances for {0:.1g} lines'.format(Nlines))
print('-'*30)
for method in [
               'method_1_groupby', 'method_2_indices', 
               'method_3_map', 'method_4_forloop', 
               'method_5_select', 'method_6_numba']:
    print('{0} ran in {1:.2f}s (average over {2} iterations)'.format(
            method, 
            timeit("{0}(df)".format(method), setup="from __main__ import df0, {0}; df=df0.copy()".format(method), number=Nites)/Nites,
            Nites))
_

出力:

_Compare performances for 1e+07 lines
------------------------------
method_1_groupby ran in 2.29s (average over 3 iterations)
method_2_indices ran in 1.21s (average over 3 iterations)
method_3_map ran in 3.07s (average over 3 iterations)
method_4_forloop ran in 0.54s (average over 3 iterations)
method_5_select ran in 0.29s (average over 3 iterations)
method_6_numba ran in 0.19s (average over 3 iterations)
_

より良いパフォーマンスを生み出すことができる他のソリューションに興味があります。私はもともとPandasベースのメソッドを探していましたが、numba/cythonベースのソリューションも受け入れます。


編集

追加 Chrisbのメソッド 比較のために:

_def method_3b_mapdirect(df):
    ''' Suggested by https://stackoverflow.com/a/51388828/5622825'''

    df['output'] = df.label.map(lookup_dict)

    return df

def method_7_take(df):
    ''' Based on answer from 
    https://stackoverflow.com/a/19913845/5622825

    Exploiting that labels are continuous integers
    '''

    lookup_arr = np.array(list(lookup_dict.values()))
    df['output'] = lookup_arr.take(df['label'] - 1)

    return df
_

ランタイム:

_method_3_mapdirect ran in 0.23s (average over 3 iterations)
method_7_take ran in 0.11s (average over 3 iterations)
_

これにより、#3は他のどの方法よりも速く(#6は別として)、最もエレガントでもあります。ユーザーケースに互換性がある場合は、#7を使用してください。

15
erwanp

.map(#3)は、これを行うための慣用的な方法だと思いますが、.getを渡さないでください。辞書を単独で使用すると、かなり大幅な改善が見られるはずです。

df = pd.DataFrame({'label': np.random.randint(, 4, size=1000000, dtype='i8')})

%timeit df['output'] = df.label.map(lookup_dict.get)
261 ms ± 12.7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit df['output'] = df.label.map(lookup_dict)
69.6 ms ± 3.08 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

条件の数が少なく、比較が安価な場合(つまり、intとルックアップテーブル)、値(4、特に5)の直接比較は.mapよりも高速ですが、これは常に当てはまるとは限りません。文字列のセットがある場合。

ルックアップラベルが実際に連続した整数である場合は、これを利用して、takeを使用してルックアップできます。これはnumbaとほぼ同じ速度である必要があります。これは基本的に可能な限り高速だと思います-同等のものをcythonで書くことはできますが、速くはなりません。

%%timeit
lookup_arr = np.array(list(lookup_dict.values()))
df['output'] = lookup_arr.take(df['label'] - 1)
8.68 ms ± 332 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
7
chrisb