pandas.DataFrame.applyは(時空間効率の観点からは)使用するべきではない – 使用すべき明確な理由がない限りpandas.DataFrame.applyはリーズナブルチョイスになり得ない

要点(時空間効率を主眼に置く場合)

・Pandasで「apply()」と言っても色々な種類が存在
– pandas.DataFrame.apply(本趣旨)
– pandas.core.groupby.GroupBy.apply
– pandas.Series.apply
(pandas.Series ~ numpy.ndarray→このメソッドを使う理由はない)
・pandas.DataFrame.applyは利便性の為のメソッドで時空間効率を気にするなら使ったら駄目
– 高機能 ~ オーバーヘッド大
– pandas.DataFrameから列方向にイテレート(ピュアにループ)する分のコスト大
・pandas.DataFrame.applyにnumpy.ufuncを渡すと,numpy関数として処理されるので効率的
– 指定軸方向にイテレート(ピュアにループ)され,pandas.Seriesにnumpy関数が適用
– pandas.Series.apply(numpy.ufunc, raw=True)でnumpy.ufunc(numpy.ndarray)と等価的
(効率を考えればpd.Series(numpy.ufunc(numpy.ndarray))/pd.DataFrame(numpy.ufunc(numpy.ndarray))すべき)
・数値データの場合,時空間効率を気にするなら,numpyソリューション一択
– pandas.DataFrame ~ 構造化データ,構造化された数値データ ~ numpy.ndarray
– numpyで処理して,pandas.DataFrame()なりpandas.Series()なりする方が効率的
・文字列や混合データの場合,ピュアPythonに置き換えて処理
– リスト(オブジェクト型)の偉大さ
– numpy.char関数の方が速いケースは殆ど無い(と思う)
・applyがリーズナブルなケース(実装効率も踏まえればファーストチョイス)
– pandas.core.groupby.GroupBy.apply
(勿論,数値データの場合はnumpyに置き換えた方が時空間効率は良い)

最初に,誤解のないように付け加えておくと,pandas.DataFrame.applyが非効率的な訳ではなくて,高機能な分,どうしてもオーバーヘッドが大きいので,その用途に最適化された処理を行うのは難しいという事.実装効率という意味では,pandas.DataFrame.applyは便利だけど,時空間効率の観点から,pandas.DataFrame.applyがリーズナブルチョイスになる事はまず無い(numpy.ufuncを渡すケースや列数が十分に少ないケース等,ベストではなくても十分に効率的なケースはある).

Pandasをある程度使用していれば,誰しも暗黙的に理解している話だけど,一つの記事として纏まったものはなかなか無いので,良いリファレンスになるなと思ったのでメモ.ただ,ちょっとだけ語弊があるのではないかなと思う部分があるのと,個人的に,例えばnumpy.ufuncを渡した場合の処理とか(numpy.ufunc化された関数を渡す場合と,そうでない場合では処理が異なるので,当然効率も全く異なるが,その部分の説明が不足している様にみえる),誤解があったらもったいないなと思う部分があるので,引用しつつテストケースを追加して補足しながらまとめておく.
 
 

When should I ever want to use pandas apply() in my code? – StackOverflow

Numeric Data
If you’re working with numeric data, there is likely already a vectorized cython function that does exactly what you’re trying to do (if not, please either ask a question on Stack Overflow or open a feature request on GitHub).

Contrast the performance of apply for a simple addition operation.

df = pd.DataFrame({“A”: [9, 4, 2, 1], “B”: [12, 7, 5, 4]})
df

A B
0 9 12
1 4 7
2 2 5
3 1 4
df.apply(np.sum)

A 16
B 28
dtype: int64

df.sum()

A 16
B 28
dtype: int64
Performance wise, there’s no comparison, the cythonized equivalent is much faster. There’s no need for a graph, because the difference is obvious even for toy data.

%timeit df.apply(np.sum)
%timeit df.sum()
2.22 ms ± 41.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
471 µs ± 8.16 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

これは,サンプルデータのN数が小さいので,関数呼び出しのコストが大きい為,一見applyの方が効率が悪いようにみえるだけ.pandas.DataFrame.applyは関数を渡すと指定軸毎にピュアにループして関数が適用されるので,関数呼び出しのコスト分,内包表記よりも遅くなるが,渡す関数がnumpy.ufunc化されている場合,numpy関数にシリーズデータ(numpy.ndarray)を渡すのと等価的に処理される為,効率的.もちろん,関数呼び出しのコストはあるので,効率を本当に気にするのであれば,numeric dataはnumpy.ndarrayにしてから処理して,適宜pandas.DataFrameに戻す方がリーズナブルなのは言うまでもない.

import pandas as pd
import numpy as np


df = pd.DataFrame({"A": np.random.randint(1, 10000, size=10000000), 
                   "B": np.random.randint(1, 10000, size=10000000)})
%timeit df.sum()
%timeit df.apply(np.sum)
%timeit df.apply(np.sum, raw=True)
%timeit pd.Series(df.values.sum(0), index=df.columns)
1 loop, best of 3: 259 ms per loop
10 loops, best of 3: 161 ms per loop
10 loops, best of 3: 33.8 ms per loop
10 loops, best of 3: 33.7 ms per loop

「raw=True」にしてnumpy.ndarrayを渡せば,numpyソリューションと変わらない.ただ,そもそも提示されているケースであれば,N数100万オーダーでやっとこさ数10 msオーダーの差なので,このケースであれば,実際的には,気にする事は然程有意味ではない.列方向のデータ数を増やすと,pandas.DataFrame.applyが何をやっているのか一目瞭然で,

df = pd.DataFrame(np.random.randint(1, 10000, size=(100000, 100)))
%timeit df.sum()
%timeit df.apply(np.sum)
%timeit df.apply(np.sum, raw=True)
%timeit pd.Series(df.values.sum(0), index=df.columns)

df = pd.DataFrame(np.random.randint(1, 10000, size=(1000, 1000)))
%timeit df.sum()
%timeit df.apply(np.sum)
%timeit df.apply(np.sum, raw=True)
%timeit pd.Series(df.values.sum(0), index=df.columns)

df = pd.DataFrame(np.random.randint(1, 10000, size=(100, 100000)))
%timeit df.sum()
%timeit df.apply(np.sum)
%timeit df.apply(np.sum, raw=True)
%timeit pd.Series(df.values.sum(0), index=df.columns)
10 loops, best of 3: 72.4 ms per loop
10 loops, best of 3: 187 ms per loop
10 loops, best of 3: 131 ms per loop
100 loops, best of 3: 12.8 ms per loop

100 loops, best of 3: 7.4 ms per loop
10 loops, best of 3: 94.7 ms per loop
100 loops, best of 3: 11.9 ms per loop
1000 loops, best of 3: 690 µs per loop

10 loops, best of 3: 73.4 ms per loop
1 loop, best of 3: 8.18 s per loop
1 loop, best of 3: 398 ms per loop
100 loops, best of 3: 13.3 ms per loop

pandas.DataFrame.applyの場合,列数が増えればデータフレームから列(pandas.Series)をイテレートするコストがやはり大きくなるが,numpy.ufuncを渡すと関数適用部分は非常に効率的なのが分かる.

df.apply(lambda x: x.max() – x.min())

A 8
B 8
dtype: int64

df.max() – df.min()

A 8
B 8
dtype: int64

%timeit df.apply(lambda x: x.max() – x.min())
%timeit df.max() – df.min()

2.43 ms ± 450 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
1.23 ms ± 14.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

これも同様で,列数が少ない場合は,applyの方が速い.
そして,applyを用いる場合,numpy.ufuncを適用する方がより効率的で,
そもそもpandas.DataFrame ~ 構造化データ,そして特に数値データの場合,
構造化された数値データ ~ numpy.ndarrayなので,numpyソリューションを考えるべき.
(raw=Trueが有効なケースは,そもそもその枠組みで処理した方がリーズナブル)

df = pd.DataFrame({"A": np.random.randint(1, 10000, size=10000000), 
                   "B": np.random.randint(1, 10000, size=10000000)})
%timeit df.max() - df.min()
%timeit df.apply(lambda x: x.max() - x.min())
%timeit df.apply(lambda x: x.max() - x.min(), raw=True)
%timeit df.apply(np.ptp)
%timeit df.apply(np.ptp, raw=True)
%timeit pd.Series(df.values.max(0)-df.values.min(0), index=df.columns)
%timeit pd.Series(np.ptp(df.values, 0), index=df.columns)

df = pd.DataFrame(np.random.randint(1, 10000, size=(100000, 100)))
%timeit df.max() - df.min()
%timeit df.apply(lambda x: x.max() - x.min())
%timeit df.apply(lambda x: x.max() - x.min(), raw=True)
%timeit df.apply(np.ptp)
%timeit df.apply(np.ptp, raw=True)
%timeit pd.Series(df.values.max(0)-df.values.min(0), index=df.columns)
%timeit pd.Series(np.ptp(df.values, 0), index=df.columns)

df = pd.DataFrame(np.random.randint(1, 10000, size=(1000, 1000)))
%timeit df.max() - df.min()
%timeit df.apply(lambda x: x.max() - x.min())
%timeit df.apply(lambda x: x.max() - x.min(), raw=True)
%timeit df.apply(np.ptp)
%timeit df.apply(np.ptp, raw=True)
%timeit pd.Series(df.values.max(0)-df.values.min(0), index=df.columns)
%timeit pd.Series(np.ptp(df.values, 0), index=df.columns)

df = pd.DataFrame(np.random.randint(1, 10000, size=(100, 100000)))
%timeit df.max() - df.min()
%timeit df.apply(lambda x: x.max() - x.min())
%timeit df.apply(lambda x: x.max() - x.min(), raw=True)
%timeit df.apply(np.ptp)
%timeit df.apply(np.ptp, raw=True)
%timeit pd.Series(df.values.max(0)-df.values.min(0), index=df.columns)
%timeit pd.Series(np.ptp(df.values, 0), index=df.columns)
1 loop, best of 3: 526 ms per loop
1 loop, best of 3: 330 ms per loop
10 loops, best of 3: 57.7 ms per loop
1 loop, best of 3: 306 ms per loop
10 loops, best of 3: 57.8 ms per loop
10 loops, best of 3: 56.8 ms per loop
10 loops, best of 3: 57.2 ms per loop

10 loops, best of 3: 154 ms per loop
1 loop, best of 3: 239 ms per loop
10 loops, best of 3: 227 ms per loop
1 loop, best of 3: 259 ms per loop
10 loops, best of 3: 146 ms per loop
10 loops, best of 3: 34.7 ms per loop
10 loops, best of 3: 35.3 ms per loop

10 loops, best of 3: 19.8 ms per loop
10 loops, best of 3: 143 ms per loop
100 loops, best of 3: 14 ms per loop
10 loops, best of 3: 140 ms per loop
100 loops, best of 3: 15.5 ms per loop
100 loops, best of 3: 3.12 ms per loop
100 loops, best of 3: 2.96 ms per loop

10 loops, best of 3: 154 ms per loop
1 loop, best of 3: 11.3 s per loop
1 loop, best of 3: 534 ms per loop
1 loop, best of 3: 10.8 s per loop
1 loop, best of 3: 705 ms per loop
10 loops, best of 3: 32.4 ms per loop
10 loops, best of 3: 32.4 ms per loop

String/Regex
Pandas provides “vectorized” string functions in most situations, but there are rare cases where those functions do not… “apply”, so to speak.—略—

あまり引用しすぎてもあれなんで..おそらく,「pandas.core.strings.StringMethods」の事を言っているのかなと.実際の所,「pandas.core.strings.StringMethods」が使えるケースでも,それ程効率が良くない事も多い.便利なので,たいていの場合に,処理はできてしまう.そして,小規模だと数msで処理できるので,なーなーで終わらせられる.しかし,何百万,何千万というデータになった時,途端に何十分と時間がかかり,途方に暮れる事になる.文字列や正規表現処理が必要になった場合,時空間効率を考えるのであれば,リスト型に置き換えて,ピュアに処理したり,reモジュールで処理する事を考えるのが良い.

ここで挙げられているケースの場合,これは文字列処理に限った話ではなくて,「列方向の処理はpandas.DataFrame.apply(lambda x: …, axis=1)をすると簡便だが,効率は非常に悪い」という話で,列方向のイテレート(列ごとの処理や列間の処理)はforループを回す方が効率的で,特にクリーンデータ(定形的であったり,整形されたデータ)の場合,内包表記がリーズナブルになる.という事が,このサンプルケースでは表されていると考えられる.SO等で(そういう表現はされていないが端的に云えば)「applyをaxis=1で使うのはアンチパターン!」というのは,以下の通り,非常に効率が悪いから.

df = pd.DataFrame({
    'Name': ['mickey', 'donald', 'minnie'],
    'Title': ['wonderland', "welcome to donald's castle", 'Minnie mouse clubhouse'],
    'Value': [20, 10, 86]})

%timeit df[df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), 1)]
%timeit df[df.apply(lambda x: x[0].lower() in x[1].lower(), 1, raw=True)]
%timeit df[df.T.apply(lambda x: x['Name'].lower() in x['Title'].lower())]
%timeit df[df.T.apply(lambda x: x[0].lower() in x[1].lower(), raw=True)]
%timeit df[[y.lower() in x.lower() for x, y in zip(df['Title'], df['Name'])]]
%timeit df[np.char.find(df['Title'].str.lower().values.astype(np.string_), df['Name'].str.lower().values.astype(np.string_)) != -1]

df = pd.concat([df]*10000, ignore_index=True)
%timeit df[df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), 1)]
%timeit df[df.apply(lambda x: x[0].lower() in x[1].lower(), 1, raw=True)]
%timeit df[df.T.apply(lambda x: x['Name'].lower() in x['Title'].lower())]
%timeit df[df.T.apply(lambda x: x[0].lower() in x[1].lower(), raw=True)]
%timeit df[[y.lower() in x.lower() for x, y in zip(df['Title'], df['Name'])]]
%timeit df[np.char.find(df['Title'].str.lower().values.astype(np.string_), df['Name'].str.lower().values.astype(np.string_)) != -1]

df = pd.DataFrame({
    'Name': ['mickey', 'donald', 'minnie'],
    'Title': ['wonderland', "welcome to donald's castle", 'Minnie mouse clubhouse'],
    'Value': [20, 10, 86]})
df = pd.concat([df]*100000, ignore_index=True)
%timeit df[df.T.apply(lambda x: x[0].lower() in x[1].lower(), raw=True)]
%timeit df[[y.lower() in x.lower() for x, y in zip(df['Title'], df['Name'])]]
%timeit df[np.char.find(df['Title'].str.lower().values.astype(np.string_), df['Name'].str.lower().values.astype(np.string_)) != -1]
1000 loops, best of 3: 732 µs per loop
1000 loops, best of 3: 752 µs per loop
1000 loops, best of 3: 883 µs per loop
1000 loops, best of 3: 621 µs per loop
1000 loops, best of 3: 283 µs per loop
1000 loops, best of 3: 623 µs per loop

1 loop, best of 3: 664 ms per loop
1 loop, best of 3: 805 ms per loop
1 loop, best of 3: 672 ms per loop
10 loops, best of 3: 28 ms per loop
100 loops, best of 3: 13.5 ms per loop
10 loops, best of 3: 36.8 ms per loop

1 loop, best of 3: 280 ms per loop
10 loops, best of 3: 124 ms per loop
1 loop, best of 3: 344 ms per loop

「raw=True」にしないと,列方向,行方向がピュアにイテレート(ちぎっては投げて)処理されるので,axis=0/1関係なく遅いが,「raw=True」にすると明らかに効率が異なる事が分かる.実際の所,データの向きさえ気を付ければ(転置はビューを返すのでコストは小さい),applyは駄目!と強く否定する事も無い.ただ,向きにビクビクする位なら,上記の例の様に,forループに置き換えたり,内包表記を用いたりする方がリーズナブルだろうし,applyは結構メモリを食うので,列ごとにちぎっては投げて処理したいケースでは,ループに置き換える(より簡潔なケースでは内包表記)とパターン化しておけばリーズナブル.

GroupBy operation involving two functions

pandas.core.groupby.GroupBy.applyの有用性については議論の余地はないだろう.ただ,提示されている様な(まだ比較的)シンプルなケースに関しては,例えば,key列がソート済みである事が仮定できて,かつデータ列が数値データで,かつ扱うデータが小規模である場合は,numpyソリューションが考えられる(結局の所,どの様な仮定が置けるか,置くか,が最も重要という当たり前の話に帰結する).

import pandas as pd
import numpy as np


df = pd.DataFrame({"A": list('aabcccddee'), "B": [12, 7, 5, 4, 5, 4, 3, 2, 1, 10]})
print(df)

print(df.groupby('A')['B'].apply(lambda x: x.cumsum().shift()))
   A   B
0  a  12
1  a   7
2  b   5
3  c   4
4  c   5
5  c   4
6  d   3
7  d   2
8  e   1
9  e  10
0     NaN
1    12.0
2     NaN
3     NaN
4     4.0
5     9.0
6     NaN
7     3.0
8     NaN
9     1.0
Name: B, dtype: float64

に対して,numpyソリューションの一例.

def specified_cumsum(a, lens):
    arr = a.copy()
    ind = np.cumsum([0] + lens)[:-1]
    arr[ind[1:]] -= np.add.reduceat(arr, ind)[:-1]
    return arr.cumsum(0)


arr = df['B'].values
sarr = np.concatenate(([df['A'].iat[0]], df['A'].values)).astype(np.string_)
idx1 = np.flatnonzero(sarr[1:]!=sarr[:-1])
idx2 = np.flatnonzero(sarr[1:]==sarr[:-1])
idx = idx2[1:] - 1
lens = np.diff(np.concatenate(([0], idx1, [len(arr)]))).tolist()
res = specified_cumsum(arr, lens)[idx]
pd.Series(res, index=idx+1, name='B').reindex(np.arange(len(arr)))
0     NaN
1    12.0
2     NaN
3     NaN
4     4.0
5     9.0
6     NaN
7     3.0
8     NaN
9     1.0
Name: B, dtype: float64
%timeit df.groupby('A')['B'].apply(lambda x: x.cumsum().shift())
%timeit arr = df['B'].values;sarr = np.concatenate(([df['A'].iat[0]], df['A'].values)).astype(np.string_);idx1 = np.flatnonzero(sarr[1:]!=sarr[:-1]);idx2 = np.flatnonzero(sarr[1:]==sarr[:-1]);idx = idx2[1:] - 1;lens = np.diff(np.concatenate(([0], idx1, [len(arr)]))).tolist();res = specified_cumsum(arr, lens)[idx];pd.Series(res, index=idx+1, name='B').reindex(np.arange(len(arr)))

df_ = pd.DataFrame({"A": list('aabcccddee'), "B": [12, 7, 5, 4, 5, 4, 3, 2, 1, 10]})
df = pd.concat([df_]*10000, ignore_index=True).sort_values(by='A').reset_index(drop=True)
res1 = df.groupby('A')['B'].apply(lambda x: x.cumsum().shift())
arr = df['B'].values;sarr = np.concatenate(([df['A'].iat[0]], df['A'].values)).astype(np.string_);idx1 = np.flatnonzero(sarr[1:]!=sarr[:-1]);idx2 = np.flatnonzero(sarr[1:]==sarr[:-1]);idx = idx2[1:] - 1;lens = np.diff(np.concatenate(([0], idx1, [len(arr)]))).tolist();res = specified_cumsum(arr, lens)[idx];res2 = pd.Series(res, index=idx+1, name='B').reindex(np.arange(len(arr)))
pd.testing.assert_series_equal(res1, res2)
%timeit df.groupby('A')['B'].apply(lambda x: x.cumsum().shift())
%timeit arr = df['B'].values;sarr = np.concatenate(([df['A'].iat[0]], df['A'].values)).astype(np.string_);idx1 = np.flatnonzero(sarr[1:]!=sarr[:-1]);idx2 = np.flatnonzero(sarr[1:]==sarr[:-1]);idx = idx2[1:] - 1;lens = np.diff(np.concatenate(([0], idx1, [len(arr)]))).tolist();res = specified_cumsum(arr, lens)[idx];pd.Series(res, index=idx+1, name='B').reindex(np.arange(len(arr)))

df_ = pd.DataFrame({"A": list('aabcccddee'), "B": [12, 7, 5, 4, 5, 4, 3, 2, 1, 10]})
df = pd.concat([df_]*1000, ignore_index=True).sort_values(by='A').reset_index(drop=True)
res1 = df.groupby('A')['B'].apply(lambda x: x.cumsum().shift())
arr = df['B'].values;sarr = np.concatenate(([df['A'].iat[0]], df['A'].values)).astype(np.string_);idx1 = np.flatnonzero(sarr[1:]!=sarr[:-1]);idx2 = np.flatnonzero(sarr[1:]==sarr[:-1]);idx = idx2[1:] - 1;lens = np.diff(np.concatenate(([0], idx1, [len(arr)]))).tolist();res = specified_cumsum(arr, lens)[idx];res2 = pd.Series(res, index=idx+1, name='B').reindex(np.arange(len(arr)))
pd.testing.assert_series_equal(res1, res2)
%timeit df.groupby('A')['B'].apply(lambda x: x.cumsum().shift())
%timeit arr = df['B'].values;sarr = np.concatenate(([df['A'].iat[0]], df['A'].values)).astype(np.string_);idx1 = np.flatnonzero(sarr[1:]!=sarr[:-1]);idx2 = np.flatnonzero(sarr[1:]==sarr[:-1]);idx = idx2[1:] - 1;lens = np.diff(np.concatenate(([0], idx1, [len(arr)]))).tolist();res = specified_cumsum(arr, lens)[idx];pd.Series(res, index=idx+1, name='B').reindex(np.arange(len(arr)))

df = pd.concat([df_]*10000, ignore_index=True).sort_values(by='A').reset_index(drop=True)
res1 = df.groupby('A')['B'].apply(lambda x: x.cumsum().shift())
arr = df['B'].values;sarr = np.concatenate(([df['A'].iat[0]], df['A'].values)).astype(np.string_);idx1 = np.flatnonzero(sarr[1:]!=sarr[:-1]);idx2 = np.flatnonzero(sarr[1:]==sarr[:-1]);idx = idx2[1:] - 1;lens = np.diff(np.concatenate(([0], idx1, [len(arr)]))).tolist();res = specified_cumsum(arr, lens)[idx];res2 = pd.Series(res, index=idx+1, name='B').reindex(np.arange(len(arr)))
pd.testing.assert_series_equal(res1, res2)
%timeit df.groupby('A')['B'].apply(lambda x: x.cumsum().shift())
%timeit arr = df['B'].values;sarr = np.concatenate(([df['A'].iat[0]], df['A'].values)).astype(np.string_);idx1 = np.flatnonzero(sarr[1:]!=sarr[:-1]);idx2 = np.flatnonzero(sarr[1:]==sarr[:-1]);idx = idx2[1:] - 1;lens = np.diff(np.concatenate(([0], idx1, [len(arr)]))).tolist();res = specified_cumsum(arr, lens)[idx];pd.Series(res, index=idx+1, name='B').reindex(np.arange(len(arr)))

df = pd.concat([df_]*100000, ignore_index=True).sort_values(by='A').reset_index(drop=True)
res1 = df.groupby('A')['B'].apply(lambda x: x.cumsum().shift())
arr = df['B'].values;sarr = np.concatenate(([df['A'].iat[0]], df['A'].values)).astype(np.string_);idx1 = np.flatnonzero(sarr[1:]!=sarr[:-1]);idx2 = np.flatnonzero(sarr[1:]==sarr[:-1]);idx = idx2[1:] - 1;lens = np.diff(np.concatenate(([0], idx1, [len(arr)]))).tolist();res = specified_cumsum(arr, lens)[idx];res2 = pd.Series(res, index=idx+1, name='B').reindex(np.arange(len(arr)))
pd.testing.assert_series_equal(res1, res2)
%timeit df.groupby('A')['B'].apply(lambda x: x.cumsum().shift())
%timeit arr = df['B'].values;sarr = np.concatenate(([df['A'].iat[0]], df['A'].values)).astype(np.string_);idx1 = np.flatnonzero(sarr[1:]!=sarr[:-1]);idx2 = np.flatnonzero(sarr[1:]==sarr[:-1]);idx = idx2[1:] - 1;lens = np.diff(np.concatenate(([0], idx1, [len(arr)]))).tolist();res = specified_cumsum(arr, lens)[idx];pd.Series(res, index=idx+1, name='B').reindex(np.arange(len(arr)))
100 loops, best of 3: 2.77 ms per loop
1000 loops, best of 3: 427 µs per loop

100 loops, best of 3: 3.15 ms per loop
1000 loops, best of 3: 1.68 ms per loop

100 loops, best of 3: 10.3 ms per loop
100 loops, best of 3: 14.8 ms per loop

10 loops, best of 3: 85.4 ms per loop
10 loops, best of 3: 177 ms per loop

key列が数値ならnumpyソリューションの方がリーズナブルだが(後述),文字列処理は基本的に……なので,小規模ならともかく,中規模以上ではPandasで処理する方が良い.しかも,単純とは言っても,groupby.applyを用いればカップラーメンを作るよりも速く処理できるケースでも,numpyソリューションを考える場合は,上の様なケースですら(ブログ内検索してコピペして)書くのに10数分掛かっている訳で,pandas.core.groupby.GroupBy.applyの利便性については言うまでもない.

例えば,

Pandas Group then rolling and sum get wrong results – StackOverflow

の様に,キー列が数値の場合,numpyらしい解き方ができれば,非常に効率的に処理できる.
numpyらしい解き方というのは,大きくは2通りあって,形状が合わせられれば,ストライドトリックを用いて,全体のビューを作成して処理するという方法と,もう一つはCライク(というかプリミティブ)にデータをリニア化して処理する方法.

import pandas as pd
import numpy as np


def slided_view(arr, N=3, S=1):
    s = arr.strides[0]
    nrows = (arr.size-N) // S + 1
    strided = np.lib.stride_tricks.as_strided
    return strided(arr, shape=(nrows, N), strides=(S*s, s))


def numpy_rolling(a, k=3):
    dtype = a.dtype
    p = np.zeros(k-1, dtype=dtype)
    arr = np.concatenate((p, a))
    return slided_view(arr)


def specified_cumsum(a, lens):
    arr = a.copy()
    ind = np.cumsum([0] + lens)[:-1]
    arr[ind[1:]] -= np.add.reduceat(arr, ind)[:-1]
    return arr.cumsum(0)


def get_ranges_arr(starts, ends):
    counts = ends - starts
    counts_csum = counts.cumsum()
    id_arr = np.ones(counts_csum[-1], dtype=int)
    id_arr[0] = starts[0]
    id_arr[counts_csum[:-1]] = starts[1:] - ends[:-1] + 1
    return id_arr.cumsum(0)


df = pd.DataFrame()
df['A'] = [1, 1, 1, 1, 2, 2, 2, 2]
df['B'] = [1, 2, 3, 4, 1, 2, 3, 4]
f = lambda x: x.shift(1).rolling(3, min_periods=0).sum()
print(df.assign(sum_B_previous_3=df.groupby('A').B.apply(f)))
%timeit df.assign(sum_B_previous_3=df.groupby('A').B.apply(f))

arr = np.concatenate((np.zeros((2,1)), df['B'].values.reshape(-1, 4)[:, :-1]), 1)
res = df.assign(sum_B_previous_3=pd.Series(np.apply_along_axis(numpy_rolling_sum, 1, arr).sum(-1).ravel()))
print(res)
%timeit df.assign(sum_B_previous_3=pd.Series(np.apply_along_axis(numpy_rolling_sum, 1, arr).sum(-1).ravel()))

lens = np.bincount(df['A'].values)[1:]
a = specified_cumsum(df['B'].values, lens.tolist())
idx_ = np.concatenate(([0], lens)).cumsum()
idx = get_ranges_arr(idx_[:-1], idx_[1:]-1)
res = np.zeros(len(a), dtype=a.dtype)
res[idx+1] = a[idx]
print(df.assign(sum_B_previous_3=pd.Series(res)))
%timeit lens = np.bincount(df['A'].values)[1:];a = specified_cumsum(df['B'].values, lens.tolist());idx_ = np.concatenate(([0], lens)).cumsum();idx = get_ranges_arr(idx_[:-1], idx_[1:]-1);res = np.zeros(len(a), dtype=a.dtype);res[idx+1] = a[idx];df.assign(sum_B_previous_3=pd.Series(res))

df = pd.concat([df]*1000000, ignore_index=True).sort_values(by='A').reset_index(drop=True)
%timeit df.assign(sum_B_previous_3=df.groupby('A').B.apply(f))
%timeit df.assign(sum_B_previous_3=pd.Series(np.apply_along_axis(numpy_rolling_sum, 1, arr).sum(-1).ravel()))
%timeit lens = np.bincount(df['A'].values)[1:];a = specified_cumsum(df['B'].values, lens.tolist());idx_ = np.concatenate(([0], lens)).cumsum();idx = get_ranges_arr(idx_[:-1], idx_[1:]-1);res = np.zeros(len(a), dtype=a.dtype);res[idx+1] = a[idx];df.assign(sum_B_previous_3=pd.Series(res))
   A  B  sum_B_previous_3
0  1  1               0.0
1  1  2               1.0
2  1  3               3.0
3  1  4               6.0
4  2  1               0.0
5  2  2               1.0
6  2  3               3.0
7  2  4               6.0
100 loops, best of 3: 2.35 ms per loop
   A  B  sum_B_previous_3
0  1  1               0.0
1  1  2               1.0
2  1  3               3.0
3  1  4               6.0
4  2  1               0.0
5  2  2               1.0
6  2  3               3.0
7  2  4               6.0
1000 loops, best of 3: 513 µs per loop
   A  B  sum_B_previous_3
0  1  1                 0
1  1  2                 1
2  1  3                 3
3  1  4                 6
4  2  1                 0
5  2  2                 1
6  2  3                 3
7  2  4                 6
1000 loops, best of 3: 498 µs per loop
1 loop, best of 3: 821 ms per loop
10 loops, best of 3: 125 ms per loop
1 loop, best of 3: 297 ms per loop

以上の様に,数値データであれば,pandas.core.groupby.GroupBy.applyを用いるケースでも,numpyソリューションの方が時空間効率はかなり良い(メモリについては提示していないが,pandasはindex/column分+中間処理にメモリを消費する為).小規模であれば,混合データ(文字列が含まれるデータ)でも,numpyソリューションの方が時空間効率が良い.ただ,上述した様な比較的単純なケース(強い仮定が置けるクリーンなデータのケース)ですら,少し難解かも知れない.そういった意味では,pandas.core.groupby.GroupBy.applyを用いるのは十分に有意味だろう(まあ,言うまでもない,って話だが).

 
 
まあ,上述した事を十分に理解した上で,それでもpandas.DataFrame.applyなんかは簡便なので,とりあえず小規模データやテストケースで答えの確認をしたい時とか,「とりあえず生で」的なノリでやっぱり使う訳だけど.「pandas.Series.applyは絶対に使わない」「axis=1を使うケースでは,raw=Trueできるケースでは転置してaxis=0で処理」とか位は習慣化しても良いかも知れない(内包表記に置き換えたり,forループに置き換えた方が良いケースというのは,割とケースバイケースなので,これをファーストチョイスにするのは少しリスクがあったりする).

 
 
関連:
データフレーム内各列の特定の値を数える方法

別のpandas.Seriesに基づいてPandas DataFrameの任意列を置換する最も効率的な方法は?

テキストファイルの文字列を別のテキストファイルの文字列で書き換える

2つのCSVファイルを比較

2つのテキストファイルを結合

Pandas.DataFrameにおける複数列のブーリアン処理

Pandasデータフレーム内の任意の文字列に合致する行を選択 – numpy/pandas/pythonにおける文字列のブーリアン演算

特定の値のギャップを数える – 任意の要素間のカウント

あるリストから別のリストの特定のアイテムにアイテムを追加

シーケンシャルな値の累積和(cumsum)

2D Numpy配列で最初の要素が重複している行の平均 – 2Dなnumpy.ndarrayの最初の要素(1列目)で各行をグルーピングして集計(平均を算出)

マルチカスタムインデックスでnumpy配列を生成

広告
カテゴリー: 未分類 パーマリンク

pandas.DataFrame.applyは(時空間効率の観点からは)使用するべきではない – 使用すべき明確な理由がない限りpandas.DataFrame.applyはリーズナブルチョイスになり得ない への6件のフィードバック

  1. ピンバック: データフレーム各行の連続したゼロをカウント | 粉末@それは風のように (日記)

  2. ピンバック: データフレームの任意列における条件から降順の累積和を求める | 粉末@それは風のように (日記)

  3. ピンバック: 条件を満たす行にだけ式を適用(XY問題) | 粉末@それは風のように (日記)

  4. ピンバック: pandasデータフレーム列内のNaNに基づいて累積和をリセット – 任意の条件に基づいた範囲で累積和を求める | 粉末@それは風のように (日記)

  5. ピンバック: データフレーム列内の連続した値の和(任意長の範囲で合計) | 粉末@それは風のように (日記)

  6. ピンバック: DataFrameでグループ間の差と平均を計算(グループへの集約関数の適用) | 粉末@それは風のように (日記)

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Google フォト

Google アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください