import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# サンプルデータ作成
np.random.seed(666)
df = pd.DataFrame({'value': np.random.randn(365).cumsum()},
                  index=pd.date_range('2019-1-1', periods=365))
df
value
2019-01-01 0.824188
2019-01-02 1.304154
2019-01-03 2.477622
2019-01-04 3.386670
2019-01-05 2.814949
... ...
2019-12-27 -1.935362
2019-12-28 -1.170606
2019-12-29 -0.112895
2019-12-30 0.490068
2019-12-31 -0.184056
# 描画
fig = plt.figure(figsize=(15, 3))
df['value'].plot()
plt.axhline(0, color='r')
plt.show()

こういうデータがあり、値が0より大きい日が5日以上連続する区間をグラフ上で示したいとする。
筆者は過去に似たようなことをしたい時があり、適当な方法がわからなかったので、効率が悪いと知りつつ、次のように、DataFrameの各行をループ処理で1行ずつ調べて該当区間を求めるようにした。

●改良前のコード
# 'value' > 0 が5日以上連続する区間を求める
df['cont_days'] = 0  # 'value' > 0 が連続する日数
df['ge_5d'] = False  # 連続する日数が5日以上(greater than or equal to)かどうか
flag = False  # 1つ前が 'value' > 0 がどうか
for i in range(len(df)):
    if df.iloc[i]['value'] > 0:
        if flag == False:
            start_i = i    # value' > 0 の開始位置を保存
            flag = True
    else:
        if flag == True:
            end_i = i    # value' > 0 の終了位置
            if end_i - start_i >= 5:
                print("{} - {} ({} days)".format(
                    df.index[start_i].date(), df.index[end_i - 1].date(), end_i - start_i))
                df.loc[df.index[start_i:end_i], ['cont_days', 'ge_5d']] = end_i - start_i, (end_i - start_i >= 5)
            flag = False

df
●実行結果
2019-01-01 - 2019-01-16 (16 days)
2019-03-17 - 2019-03-29 (13 days)
2019-04-20 - 2019-05-01 (12 days)
2019-05-14 - 2019-05-26 (13 days)
2019-06-12 - 2019-07-20 (39 days)
2019-07-22 - 2019-07-26 (5 days)
2019-07-29 - 2019-08-04 (7 days)
2019-08-13 - 2019-10-18 (67 days)
value cont_days ge_5d
2019-01-01 0.824188 16 True
2019-01-02 1.304154 16 True
2019-01-03 2.477622 16 True
2019-01-04 3.386670 16 True
2019-01-05 2.814949 16 True
... ... ... ...
2019-12-27 -1.935362 0 False
2019-12-28 -1.170606 0 False
2019-12-29 -0.112895 0 False
2019-12-30 0.490068 0 False
2019-12-31 -0.184056 0 False
●結果の描画コード
# 描画
fig = plt.figure(figsize=(15,3))
ax1 = fig.gca()

# 'value' > 0 が5日以上連続する区間を塗り潰す
ax2 = ax1.twinx()
ax2.fill_between(df.index, 0, df['ge_5d'], color='r', alpha=0.2, linewidth=0, step='post')
ax2.axes.yaxis.set_visible(False)

# 'value'の描画、先にするとX軸のラベルのフォーマットが変わるので後でする
df['value'].plot(ax=ax1)
ax1.axhline(color='r')

plt.show()
●描画結果

後日、そういうのは次のようにshift()とcumsum()をうまく使えばgroupby()で処理できるということを教えてもらった。

●改良後のコード
# 'value' > 0 が5日以上連続する区間を求める
df['flag'] = df['value'] > 0
df['cont_days'] = df.groupby((df['flag'] != df['flag'].shift()).cumsum())['flag'].transform(sum)
df['ge_5d'] = df['cont_days'] >= 5
df
●実行結果
value flag cont_days ge_5d
2019-01-01 0.824188 True 16 True
2019-01-02 1.304154 True 16 True
2019-01-03 2.477622 True 16 True
2019-01-04 3.386670 True 16 True
2019-01-05 2.814949 True 16 True
... ... ... ... ...
2019-12-27 -1.935362 False 0 False
2019-12-28 -1.170606 False 0 False
2019-12-29 -0.112895 False 0 False
2019-12-30 0.490068 True 1 False
2019-12-31 -0.184056 False 0 False
※結果の描画コードと描画結果は上と同じなので省略

改良後のコード中の groupby((df['flag'] != df['flag'].shift()).cumsum()) は初見ではややこしいが、次の例で説明すると、df['flag'].shift()が1つ前の値、df['flag'] != df['flag'].shift()が1つ前と同じかどうかで、それを累積(cumsum)することにより、'flag'が前と同じ値の所は同じ番号、変化があった所で次の番号となり、これをgroupby()のキーにすることにより、'flag'の同じ値が連続する区間毎にグループ分けされる。

# groupby((df['flag'] != df['flag'].shift()).cumsum()) の解説用
df = pd.DataFrame({
    'flag': [False, False, True, True, False, True, True, True, False, False]})
df['shift'] = df['flag'].shift()
df['diff'] = df['flag'] != df['shift']
df['cont_group'] = df['diff'].cumsum()
df
flag shift diff cont_group
0 False NaN True 1
1 False False False 1
2 True False True 2
3 True True False 2
4 False True True 3
5 True False True 4
6 True True False 4
7 True True False 4
8 False True True 5
9 False False False 5

改良前のコードと改良後のコードを比較すると、改良後のコードは断然短いし、処理時間も圧倒的に短く(筆者の環境では改良前約200ms、改良後約7.5ms)、しかもデータサイズが100倍になっても処理時間が少ししか伸びない(改良前約14秒、改良後約12.5ms)。

教えてもらった所の他の人のコメントを見ると、その筋では「shiftを使えばいい」だけで以上のことが通じるらしいことになっていた。
pandas documentation"Cookbook"の"Grouping like Python's itertools.groupby"の所に載っているし、stackoverflowのあるページに"uses some common idioms"と書かれているので、きっとよく知られたパターンなのだろう。

[Pandas] groupby.aggのnested renamingの代替手段

In [1]:
import numpy as np
import pandas as pd

np.random.seed(9)
df = pd.DataFrame({
    '組': np.random.randint(3, size=20),
    '身長': np.random.randint(950, 1050, size=20) / 10,
    '地域': np.random.randint(5, size=20)})
df['組'] = df['組'].map({0: 'もも', 1: 'さくら', 2: 'ばら'})
df['地域'] = df['地域'].map({0: 'A町', 1: 'B町',  2: 'C町',  3: 'D町',  4: 'E町'}) 
df.head()
Out [1]:
身長 地域
0 ばら 101.0 B町
1 もも 100.9 A町
2 ばら 103.8 D町
3 さくら 102.4 C町
4 ばら 100.6 B町

こういうDataFrameがあり、組ごとに、
・身長の最低値と最高値
・A町の子が含まれているか、B町の子が含まれているか
を求めたいとする。
あまりいい例題では無いが、筆者が仕事で実際に必要になった処理と等価な、他にましな例題を思い付かなかった。

筆者は当初、df.groupby('組')['地域'].agg(lambda x: ...)のようにして集計結果を1列ずつ求め、後で結合していたのだが、先週、1回のgroupby.agg()でできる、次のような書き方があることを知った。

In [2]:
df.groupby('組')[['身長', '地域']].agg({
    '身長': {
        '最低身長': np.min,
        '最高身長': np.max
    },
    '地域': {
        'A町の子あり': lambda x: any(x == 'A町'),
        'B町の子あり': lambda x: any(x == 'B町')
    }})
Out [2]:
最低身長 最高身長 A町の子あり B町の子あり
さくら 95.9 104.9 True False
ばら 100.6 103.8 False True
もも 95.1 104.3 True False

同じ列に複数の集約関数を適用し、しかもそれぞれの結果の列に任意の列名を付与できるのである。
これは便利、と思って早速これを使うように書き直して、ローカルPCで動作確認して別PCにコピーして実行すると、

SpecificationError: nested renamer is not supported
というエラーになってしまった。

調べてみると、上のdict-of-dictを渡す書き方(nested renamingというらしい)はPandas v0.20.0でdeprecatedとされ、v1.0で廃止されたらしい。
What's new in 1.0.0より:
Removed support for nested renaming in DataFrame.aggregate(), Series.aggregate(), core.groupby.DataFrameGroupBy.aggregate(), ...
ローカルPCのPandasはv0.25.3だったので、nested renamingが動いた。

それでは代わりの方法は無いのかと思って探すと、"named aggregation"が推奨と書かれているのを見つけた。
What's new in 0.25.0より:

Named aggregation is the recommended replacement for the deprecated "dict-of-dicts" approach to naming the output of column-specific aggregations
他に、aggに列と関数のリストだけのdictを与えて、後で列名をrenameする方法もあるが、通常はaggに渡す関数名が結果の列名になるのに対し、lambda関数を渡すと列名が勝手に付けられるので、面倒なことになる。

Named aggregationを使うと、上のv1.0でエラーになったコードは次のように書ける。

In [3]:
df.groupby('組').agg(
    最低身長=('身長', np.min),
    最高身長=('身長', np.max),
    A町の子あり=('地域', lambda x: any(x == 'A町')),
    B町の子あり=('地域', lambda x: any(x == 'B町')))
Out [3]:
最低身長 最高身長 A町の子あり B町の子あり
さくら 95.9 104.9 True False
ばら 100.6 103.8 False True
もも 95.1 104.3 True False

列名をクォーテーションマークで括ったり括らなかったりするのが統一感に欠けるが、得られる結果が少しわかりやすくなったと思う。それから、前のコードでは[['身長', '地域']]でやっていた、aggに渡す前に列を絞るのが不要になった(絞らないとnested renamingでは列がMultiIndexになってしまう)ので、すっきりしたと感じる。

In [1]:
import pandas as pd
df = pd.DataFrame({'name': ['a', 'b', 'c', 'd'] * 3,
                               'value': range(12),
                               'flag': [1, 0, 0, 0, 1, 0] * 2})
df
Out [1]:
name value flag
0 a 0 1
1 b 1 0
2 c 2 0
3 d 3 0
4 a 4 1
5 b 5 0
6 c 6 1
7 d 7 0
8 a 8 0
9 b 9 0
10 c 10 1
11 d 11 0

こういうDataFrameがあり、'name'でgroupbyして、'flag'に1つでも1があるグループの、'flag'が1の行がグループ内の'value'の平均、それ以外の行は0という列(この例ではインデックスが0と4の所が'a'の平均、6と10の所が'c'の平均、それ以外は0という列)を追加したいとする。
大体そういう感じのことをtransformでやりたかったが、スマートなやり方がわからず、悩んでいる。

目的の列をグループ毎に返す関数を、transformの代わりにapplyを使って、

In [2]:

def func(groupdf):
    ret = pd.Series(0, index=groupdf.index)
    if any(groupdf['flag']):
        ret[groupdf['flag'] == True] = groupdf['value'].mean()
    return ret
    
df.groupby('name').apply(func)

又は

In [3]:

df.groupby('name').apply(lambda x: x['value'].mean() * x['flag'] * any(x['flag']))

Out [3]:

name    
a     0     4.0
      4     4.0
      8     0.0
b     1     0.0
      5     0.0
      9     0.0
c     2     0.0
      6     6.0
      10    6.0
d     3     0.0
      7     0.0
      11    0.0
Name: flag, dtype: float64

というように作ることができたのだが、[2]のfuncも[3]のlambda関数も、transformに与えると、'flag'という列が無いというエラーになる。
applyなら呼び出される関数に複数列のDataFrameが渡されるので複数列を参照しながら計算ができるが、aggregateやtransformだと呼び出される関数に1列分のSeriesしか渡されないので、複数列を参照しながら計算ができない。

もし、flagが1の行だけがグループの平均という条件を外し、'flag'に1つでも1があるグループはグループ内の'value'の平均、それ以外の行は0という列で良い、つまりグループ内は全て同じ値になるなら、Webでサンプルコードがいくつか見つかり、大きく分けて2つの方法が見つかった。
1つは、列毎にtransformした結果を組み合わせて何とかするという方法である。

In [4]:

grouped = df.groupby('name')
df['ave'] = grouped['value'].transform(np.mean) * grouped['flag'].transform(any)
df
Out [4]:
(省略)

もう1つは、applyを使ってグループ毎に計算した結果をmergeする方法である。

In [5]:

_ = df.groupby('name').apply(lambda x: x['value'].mean() if any(x['flag']) else 0).rename('ave')
df = df.merge(_, how='left', on='name')
df
Out [5]:
name value flag ave
0 a 0 1 4
1 b 1 0 0
2 c 2 0 6
3 d 3 0 0
4 a 4 1 4
5 b 5 0 0
6 c 6 1 6
7 d 7 0 0
8 a 8 0 4
9 b 9 0 0
10 c 10 1 6
11 d 11 0 0

前者は列毎にtransformした後で何とかなる場合しか使えないのに対し、後者は複数の列を参照しながら計算できるので、後者の方が汎用的だと思う。
速度面では、前者は中間データを作成して処理時間がかかりがちなtransformを複数回実行するので不利なように思えたが、筆者のJupyter Notebookの%timeitで計測した限り、DataFrameのサイズを10,000倍とかにしても、処理時間は大差なかった。

元のやりたいことについては、前者(In [4]の例)を応用すると、次のようなのができた。

In [6]:

grouped = df.groupby('name')
df['ave'] = grouped['value'].transform(np.mean) * grouped['flag'].transform(any) * df['flag']
df
Out [6]:
name value flag ave
0 a 0 1 4.0
1 0 0 0 0.0
2 0 0 0 0.0
3 0 0 0 0.0
4 a 4 1 4.0
5 0 0 0 0.0
6 c 6 1 6.0
7 0 0 0 0.0
8 0 0 0 0.0
9 0 0 0 0.0
10 c 10 1 6.0
11 0 0 0 0.0

しかし、transformを2回使うし、式が場当たり的で汎用的でないのが不満である。
後者(In [5]の例)を応用すると、次のようにするしか思い付かない。

In [7]:

_ = df.groupby('name').apply(lambda x: x['value'].mean() if any(x['flag']) else 0).rename('ave')
df = df.merge(_, how='left', on='name')
df[df['flag'] == 0] = 0
df
Out [7]:
(Out [6] と同じ)

これも、後のboolean indexing部分はグループを無視して処理しているので、そのようにできない時は同じようにできないし、これによって処理時間が大幅に伸びるし、transformの出番と思われるのにtransformを使ってないのが不満である。