タグ「Pandas」が付けられているもの

先週、groupby.aggに複数の集約関数を与えてできた、列がMultiIndexのDataFrameを、元の列毎に処理する方法がわからず悩んだが、良い方法が見つかったのでメモしておく。

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

def get_data(date):
    n = np.random.randint(5, 10)
    return pd.DataFrame({
        'person': list("ABCDEFGHIJ"[:n]), 
        'date': date,
        'hour': map(lambda x: ['AM', 'PM'][x], sorted(np.random.randint(2, size=n))),
        'Left': np.random.randint(5, size=n),
        'Right': np.random.randint(10, size=n)
    }).set_index(['person', 'date', 'hour'])

# test
np.random.seed(3)
get_data('2020-03-01')
Out[1]:
Left Right
person date hour
A 2020-03-01 AM 3 4
B 2020-03-01 AM 2 7
C 2020-03-01 AM 3 8
D 2020-03-01 AM 1 1
E 2020-03-01 PM 1 6
F 2020-03-01 PM 2 2
G 2020-03-01 PM 0 2

このような形式で、来た人が午前か午後のどちらに左の通路と右の通路をそれぞれ何回通ったかというデータがあり、時間帯毎の1人当たりのそれぞれの通路を通った平均回数を計算したいとする。

実際のデータはサイズが大きく、1日分のデータはDRAMに載るが全期間のデータは載らずMemory Errorになったので、平均は合計÷人数ということで、次のように1日分ずつデータを読み込んで時間帯毎、通路毎に通った回数の合計と人数を加算していくようにした。

In [2]:
np.random.seed(3)

sumdf = None
for date in pd.date_range('2020-03-01', '2020-03-07'):
    df = get_data(date.date())

    df = df.groupby('hour').agg(['sum', 'size'])
    if sumdf is None:
        sumdf = df
    else:
        sumdf = sumdf.add(df, fill_value=0)

sumdf
Out [2]:
Left Right
sum size sum size
hour
AM 40 27 114 27
PM 49 23 72 23

groupby.aggに'sum', 'size'という複数の集約関数を渡しているので、結果の列がMultiIndexになっている。
後はこれの 'Left' と 'Right' をそれぞれの 'sum' / 'size' に置換すれば良いのだが、その方法がわからなかった。
結局、調べながら試行錯誤して筆者が最もシンプルだと思ったコードは次のようになった。

In [3]:
meandf = sumdf.groupby(level=0, axis=1).apply(lambda x: x[x.name]['sum'] / x[x.name]['size'])
meandf
Out [3]:
Left Right
hour
AM 1.481481 4.222222
PM 2.130435 3.130435
In [4]:
meandf.plot(kind='bar', title='The average per person')
Out [4]:

Pandasのプログラムで、あるDataFrameから、関連する別のDataFrameに存在する値を含む条件に合う行を選択するコードの書き方を見つけるのに、結構時間が掛かった。

In [1]:
df1 = pd.DataFrame({
    'name': list('AAABBBCCC'),
    'val': range(1, 10)
})
df1
Out [1]:
name val
0 A 1
1 A 2
2 A 3
3 B 4
4 B 5
5 B 6
6 C 7
7 C 8
8 C 9

こういうDataFrameと

In [2]:
df2 = df1.groupby('name').sum() + 1
df2.columns = ['val2']
df2
Out [2]:
val2
name
A 7
B 16
C 25

こういうDataFrameがあり、df1から"val2"の値が10より大きな行を選択したい、但しdf1とdf2とのmergeはワークメモリの都合でやりたくないとする。

df1とdf2をmergeして良いなら、やりたいことは次のことである。

In [3]:
df = df1.merge(df2, on='name')
df[df['val2'] > 10]
Out [3]:
name val val2
3 B 4 16
4 B 5 16
5 B 6 16
6 C 7 25
7 C 8 25
8 C 9 25

次のように書ければ筆者としては直観的なのだが、これはエラーになる。

In [4]:
df1[df2.loc[df1['name'], 'val2'] > 10]
Out [4]:
ValueError: cannot reindex from a duplicate axis

括弧内の"df2.loc[df1['name'], 'val2'] > 10"はdf1と同じ行数のboolean値を返すので、このままboolean indexingとして通してくれても良さそうなものである。

In [5]:
df2.loc[df1['name'], 'val2'] > 10
Out [5]:
name
A    False
A    False
A    False
B     True
B     True
B     True
C     True
C     True
C     True
Name: val2, dtype: bool

indexが問題とのことなので、次のようにすれば成功するが、無駄に複雑というか、そんな問題をいちいち意識したくない。

In [6]:
# 成功例1
df1[(df2.loc[df1['name'], 'val2'] > 10).reset_index(drop=True)]
Out [6]:
name val
3 B 4
4 B 5
5 B 6
6 C 7
7 C 8
8 C 9

結局、Webで調べて試行錯誤しながら辿り着いたコードは、次のようにmapやapplyを使う形になった。

In [7]:
# 成功例2
df1[df1['name'].map(lambda x: df2.loc[x, 'val2'] > 10)]
Out [7]:
name val
3 B 4
4 B 5
5 B 6
6 C 7
7 C 8
8 C 9
In [8]:
# 成功例3
df1[df1.apply(lambda df: df2.loc[df['name'], 'val2'] > 10, axis=1)]
Out [8]:
(上と同じなので省略)

処理時間は、筆者の環境では成功例1と成功例3が大差なく、成功例2が半分ほどだった。

筆者が実際に困ったケースでは、2つのDataFrameを結合するキー(上の例の"name")が複数の列からなっていた。

In [9]:
df3 = pd.DataFrame({
    'name1': list('AAAABBBB'),
    'name2': list('xxyyxxyy'),
    'val': range(1, 9),
})
df3
Out [9]:
name1 name2 val
0 A x 1
1 A x 2
2 A y 3
3 A y 4
4 B x 5
5 B x 6
6 B y 7
7 B y 8
In [10]:
df4 = df3.groupby(['name1', 'name2']).agg(sum) + 1
df4.columns = ['val2']
df4
Out [10]:
val2
name1 name2
A x 4
y 8
B x 12
y 16

こういうDataFrameがあり、df3から"val2"の値が5より大きな行を選択したい、但しdf3とdf4とのmergeはワークメモリの都合でやりたくない、というような問題である。
DataFrameにはmapメソッドが無いので、成功例2の延長では書けず、成功例3の延長で、MultiIndexのindexingに苦戦しながら、次のようなコードに行き着いた。

In [11]:
# 成功例3'
df3[df3.apply(lambda df: df4.loc[tuple(df[['name1', 'name2']]), 'val2'] > 5, axis=1)]
Out [11]:
name1 name2 val
2 A y 3
3 A y 4
4 B x 5
5 B x 6
6 B y 7
7 B y 8

しかし、ここまで複雑になるのなら、成功列1のような方法でも良いかなとも思えてきた。

In [12]:
# 成功列1'
df3[(df4.loc[df3[['name1', 'name2']].values.tolist(), 'val2'] > 5).reset_index(drop=True)]
Out [12]:
(上と同じなので省略)

筆者にはやはり成功例3'より美しくない上に一回りややこしく感じるが、筆者の環境では、成功例1'の処理時間は成功例3'の6割ほどだった。

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

N = 10
np.random.seed(8)
df = pd.DataFrame({
    'A': np. random.randint(10, size=N),
    'B': np. random.randint(10, size=N),
    'C': np. random.randint(10, size=N)
}, index=pd.date_range('2020-09-01', periods=N))
df
A B C
2020-09-01 3 1 5
2020-09-02 4 3 5
2020-09-03 1 9 7
2020-09-04 9 2 9
2020-09-05 5 2 2
2020-09-06 8 6 6
2020-09-07 3 8 9
2020-09-08 8 9 5
2020-09-09 0 3 1
2020-09-10 5 4 6
このような時系列データがあるとし、A列を折れ線グラフ、B,C列を別のグラフに棒グラフで描画したものを、時間軸を合わせて縦に並べたいとする。
そこで、次のようなコードを実行すると、2段のグラフの内、上のグラフが描画されなかった。
fig, ax = plt.subplots(2, 1, sharex=True)
df['A'].plot(ax=ax[0], grid=True)
df[['B', 'C']].plot(kind='bar', ax=ax[1], grid=True)
plt.show()

下のグラフを描画しなければ、上のグラフが描画される。

fig, ax = plt.subplots(2, 1, sharex=True)
df['A'].plot(ax=ax[0], grid=True)
plt.show()

原因は、次のコードを実行するとわかった。

fig, ax = plt.subplots(2, 1, sharex=True)
df['A'].plot(ax=ax[0], grid=True)
print("xlim after 1st plot:", ax[0].get_xlim())
df[['B', 'C']].plot(kind='bar', ax=ax[1], grid=True)
print("xlim after 2nd plot:", ax[0].get_xlim())
実行結果
xlim after 1st plot: (18506.0, 18515.0)
xlim after 2nd plot: (-0.5, 9.5)
つまり、 pandas.DataFrame.plot は kind='line' と kind='bar' とで描画した後のX座標の範囲(xlim)が全く異なり、 matplotlib.pyplot.subplots(sharex=True) した状態でこの2つを描画すると、先に描画した座標系(Axes)のxlimが書き換えられてしまうのが原因である。
色々調べまくったが、棒グラフ表示にこだわると、pandas.DataFrame.plotを使って解決する方法は見つからなかった。(kind='scatter'の点グラフなら同じ問題が起こらないことを確認した。コードは省略)

Seabornを使っても、結果は同じだった。

import seaborn as sns
fig, ax = plt.subplots(2, 1, sharex=True)
df['A'].plot(ax=ax[0], grid=True)
print("xlim after 1st plot:", ax[0].get_xlim())
_ = df[['B', 'C']].melt(ignore_index=False).reset_index()
sns.barplot(x='index', y='value', hue='variable', data=_)
print("xlim after 2nd plot:", ax[0].get_xlim())
plt.show()
実行結果
xlim after 1st plot: (18506.0, 18515.0)
xlim after 2nd plot: (-0.5, 9.5)

結局、棒グラフだけ直接matplotlib APIを使って描画すると解決した。

fig, ax = plt.subplots(2, 1, sharex=True)
df['A'].plot(ax=ax[0], grid=True)

width = pd.Timedelta('0.4d')
ax[1].bar(df.index - width/2, df['B'], width=width, label='B')
ax[1].bar(df.index + width/2, df['C'], width=width, label='C')
ax[1].set_xlim(df.index[0] - width*2, df.index[-1] + width*2)
ax[1].grid(True)
ax[1].legend()

fig.autofmt_xdate()
plt.show()

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を使ってないのが不満である。

area age positive
0 京都 17 False
1 大阪 15 True
2 京都 4 False
3 大阪 9 True
4 大阪 10 True
... ... ... ...
45 京都 16 True
46 大阪 3 False
47 大阪 2 False
48 奈良 10 True
49 京都 13 False

これは下記のプログラムの中で生成される架空のデータだが、このようなデータの地域別、年齢層(5歳未満、5歳以上、10歳以上、15歳以上)毎の人数の分布を、陽性と陰性とに分けて比較できるように、ヒートマップを並べようとして、次のようなプログラムを書いた。

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

# サンプルデータ作成
np.random.seed(0)
df = pd.DataFrame({'area': [['京都', '大阪', '奈良'][i] for i in np.random.randint(3, size=50)],
                   'age': np.random.randint(20, size=50),
                   'positive': np.random.randint(2, size=50).astype(bool)})
df.loc[df['age'] < 5, 'positive'] = False  # 5歳未満の陽性は無しとする

# 年齢のビニング(区分け)
df['range'] = pd.cut(df['age'], bins=[0, 5, 10, 15, 20], labels=['5歳未満', '5歳以上', '10歳以上', '15歳以上'], right=False)

# 陽性、陰性の地域-年齢区分の分布を比較
df_positive = df[df.positive == True].pivot_table(index='range', columns='area', aggfunc='size', fill_value=0)
df_negative = df[df.positive == False].pivot_table(index='range', columns='area', aggfunc='size', fill_value=0)

# ヒートマップを並べて描画
import matplotlib.pyplot as plt
import seaborn as sns
plt.figure(figsize=(8, 2))
plt.subplot(121)
sns.heatmap(df_positive, cmap='Reds', cbar=False, annot=True)
plt.title("陽性")
plt.xlabel("")
plt.ylabel("")
plt.subplot(122)
sns.heatmap(df_negative, cmap='Blues', cbar=False, annot=True)
plt.title("陰性")
plt.xlabel("")
plt.ylabel("")
plt.subplots_adjust(wspace=0.4)
plt.show()

コード内のコメントに書いているように、5歳未満で陽性の人がいないものとする。
すると、次のように、陽性の5歳未満の行が抜けて、行の数が合わなくなってしまった。

Out [1]:

求める結果は、次のものである。

これが出るようにコードを修正しようとしたが、スマートな方法がわからなかった。

陽性のヒートマップの元になっているDataFrameを見ると、5歳未満の行が無い。

In [2]:
df_positive
Out [2]:
area 京都 大阪 奈良
range
5歳以上 2 2 2
10歳以上 0 4 3
15歳以上 2 2 1

しかし、このDataFrameのインデックスを見ると、データ型がカテゴリ変数で、そのカテゴリーには'5歳未満'が存在する。

In [3]:
df_positive.index
Out [3]:
CategoricalIndex(['5歳以上', '10歳以上', '15歳以上'], categories=['5歳未満', '5歳以上', '10歳以上', '15歳以上'], ordered=True, name='range', dtype='category')

であればこのインデックスを期待するものに書き換えれば良いのではないかと思って、次のようにインデックスのデータをカテゴリ変数のカテゴリーと同じになるように reindexしてみると、確かに望み通りの結果になった。

In [4]:
df_positive = df[df.positive == True].pivot_table(index='range', columns='area', aggfunc='size', fill_value=0)
df_positive = df_positive.reindex(index=pd.CategoricalIndex(data=df_positive.index.categories, categories=df_positive.index.categories, ordered=True), fill_value=0)
df_positive
Out [4]:
area 京都 大阪 奈良
range
5歳未満 0 0 0
5歳以上 2 2 2
10歳以上 0 4 3
15歳以上 2 2 1

しかし、何かやりたいことに対してコードが冗長だし、ordered=Trueまで面倒を見ないといけないなど、手間がかかり過ぎている感じがする。
次のようにインデックスに欠損が無いDataFrameを別に作ってインデックスをコピーする方が、コードがシンプルだし、考慮すべきことが少なくて楽だが、これだとプログラムの動作が冗長である。

In [5]:
df_all = df.pivot_table(index='range', columns='area', aggfunc='size', fill_value=0)
df_positive = df[df.positive == True].pivot_table(index='range', columns='area', aggfunc='size', fill_value=0)
df_positive = df_positive.reindex(index=df_all.index, fill_value=0)
df_positive
Out [5]:
area 京都 大阪 奈良
range
5歳未満 0 0 0
5歳以上 2 2 2
10歳以上 0 4 3
15歳以上 2 2 1

pivot_tableしてからreindexするのではなく、pivot_tableでインデックスに欠損を生じないようにできないかと思って調べると、dropnaという引数があり、次のように aggfunc='count', dropna=False とすればできることがわかった。

In [6]:
df_positive = df[df.positive == True].pivot_table(index='range', columns='area', values='positive', aggfunc='count', fill_value=0, dropna=False)
df_positive
Out [6]:
area 京都 大阪 奈良
range
5歳未満 0 0 0
5歳以上 2 2 2
10歳以上 0 4 3
15歳以上 2 2 1

Pandasのpivotとpivot_tableの違いについて

この間、Pandasの勉強をしていると、PandasのDataFrameクラスにはpivotとpivot_tableという2つのメソッドがあることに気付かず、違う方を使って自分で打ち込んだサンプルコードが動かなかったり、helpを読んで混乱することが度々あった。
pivotとpivot_tableが違うことに気付いた時にあまりにも悔しかったので、pivotとpivot_tableの違いに関してメモしておく。

以下、Jupyter Notebookで書いたコードと出力を使って記述する。

In [1]:
df = pd.DataFrame({'date': ['{}:{:02d}:{:02d}'.format(6, 30, s) for s in (2, 2, 2, 3, 3, 3, 4, 4, 4)],
                    'variable': ['A', 'B', 'C'] * 3,
                    'value': list('abcdefghi')})

df
Out [1]:
date variable value
0 6:30:02 A a
1 6:30:02 B b
2 6:30:02 C c
3 6:30:03 A d
4 6:30:03 B e
5 6:30:03 C f
6 6:30:04 A g
7 6:30:04 B h
8 6:30:04 C i

こういう、いわゆるlongフォーマットの時系列データを以下のwideフォーマットに変形することを考える。(筆者は最近実際にやることがあった)

In [2]:
df.pivot(index='date', columns='variable', values='value')
Out [2]:
variable A B C
date
6:30:02 a b c
6:30:03 d e f
6:30:04 g h i

上ではpivotを使ったが、pivot_tableを使っても同じことができる。
但し、pivot_tableはグループ毎(2次元の表にまとめられたセル毎)に集約関数が適用され、defaultの関数はnp.sumで、この例のようにセルの値が文字列だと適用できず、"DataError: No numeric types to aggregate"というエラーになる為、何か文字列を受け取れる関数を指定する必要がある。
(下記のようにaggfunc='first'とすると、DataFrameGroupBy.firstが用いられると思われる)

In [3]:
df.pivot_table(index='date', columns='variable', values='value', aggfunc='first')
Out [3]:
variable A B C
date
6:30:02 a b c
6:30:03 d e f
6:30:04 g h i

このように、pivot_tableはグループ毎に必ず集約関数が使われるのが、pivotとの主な違いのようである。
pivotでは集約ができないので、次のように結果が同じ行と同じ列に相当する行があると、それらが全く同じ値であってもエラーになる。(筆者は実際にそういうデータに遭遇して難儀した)

In [4]:
df = df.append(pd.DataFrame({'date': ['{}:{:02d}:{:02d}'.format(6, 30, s) for s in (6, 6, 6) * 2],
                    'variable': ['A', 'B', 'C'] * 2,
                    'value': list('jkl') * 2}))
df
Out [4]:
date variable value
0 6:30:02 A a
1 6:30:02 B b
2 6:30:02 C c
3 6:30:03 A d
4 6:30:03 B e
5 6:30:03 C f
6 6:30:04 A g
7 6:30:04 B h
8 6:30:04 C i
0 6:30:06 A j
1 6:30:06 B k
2 6:30:06 C l
3 6:30:06 A j
4 6:30:06 B k
5 6:30:06 C l
In [5]:
df.pivot(index='date', columns='variable', values='value')
Out [5]:
ValueError: Index contains duplicate entries, cannot reshape

pivot_tableであれば、この場合も成功する。

In [6]:
df.pivot_table(values='value', index='date', columns='variable', aggfunc='first')
Out [6]:
variable A B C
date
6:30:02 a b c
6:30:03 d e f
6:30:04 g h i
6:30:06 j k l

Pandasのmelt/pivotとstack/unstackの違い

この間、Pandasを使った教材を流し読みしていると、DataFrameクラスのmeltというメソッドが出てきて、pivotの逆だと説明されていた。それを読んで、meltされたものをpivotして元に戻してみよう思って、すきま時間を集めてのべ1時間くらいがんばったが、できなかった。
ついでに、meltとstackの違いがわからなかった。

今月、1時間くらい連続してがんばったら成功して、meltとstackの違いも理解できたので、ここに控えておく。

以下、コード例はJupyter Notebookで書いたインタラクティブシェル向けの形式のものをそのまま貼り付けており、出力例はJupyter Notebookの出力を加工して作成している。

In [1]:
import pandas as pd
df = pd.DataFrame({'月': ['1月', '2月', '3月'],
                   '京都': [110, 115, 144],
                   '大阪': [263, 283, 309],
                   '奈良': [12, 13, 21],
                  })
df
Out [1]:
京都 大阪 奈良
0 1月 110 263 12
1 2月 115 283 13
2 3月 144 309 21

このようなDataFrameがあるとして、これをmeltして、pivotで元に戻してみる。

In [2]:
df_melted = df.melt(id_vars='月', var_name='地域', value_name='人数')
df_melted
Out [2]:
地域 人数
0 1月 京都 110
1 2月 京都 115
2 3月 京都 144
3 1月 大阪 263
4 2月 大阪 283
5 3月 大阪 309
6 1月 奈良 12
7 2月 奈良 13
8 3月 奈良 21
In [3]:
df_pivoted = df_melted.pivot(index='月', columns='地域', values='人数')
df_pivoted
Out [3]:
地域 京都 大阪 奈良
1月 110 263 12
2月 115 283 13
3月 144 309 21

meltしたものをpivotすると大体戻ったが、「月」がindexになっているのと、meltで加えた「地域」が残っているのが異なるので、修正する。
(ちなみに、reset_indexしただけでは「月」が「地域」に入っておかしなことになる)

In [4]:
df_pivoted = df_pivoted.reset_index()
df_pivoted.columns.name = None
df_pivoted
Out [4]:
京都 大阪 奈良
0 1月 110 263 12
1 2月 115 283 13
2 3月 144 309 21

次に、meltとstackの違いを見てみる。
上のdfをstackすると「月」と地域名が同列に処理されてしまうので、まず「月」をインデックスにしてからstackする。

In [5]:
df2 = df.set_index('月')
df2
Out [5]:
京都 大阪 奈良
1月 110 263 12
2月 115 283 13
3月 144 309 21

この場合はstackすると結果が1列なのでDataFrameでなくSeriesになるので、変数名の先頭をdf_でなくsr_にしている。

In [6]:
sr_stacked = df2.stack()
sr_stacked
Out [6]:
1月 京都 110
大阪 263
奈良 12
2月 京都 115
大阪 283
奈良 13
3月 京都 144
大阪 309
奈良 21

当然ながら、stackしたものをunstackすると元に戻る。

In [7]:
sr_stacked.unstack()
Out [7]:
京都 大阪 奈良
1月 110 263 12
2月 115 283 13
3月 144 309 21

meltでは元々column名だった列(「地域」列)がindexにならなかったが、stackではindexになっている。つまり、meltはcolumn名を新たに加えた通常の列に展開することによって表を変形し、stackはcolumn名をindexの新たな階層に展開することによって表を変形する。このことがmeltとstackの主な違いのようだ。
次のようにして、stackしたもののindexを通常の列に戻すと、meltした結果(Out [2])と同じになる。

In [8]:
df_stacked = sr_stacked.sort_index(level=1).reset_index()
df_stacked.columns = ['月', '地域', '人数']
df_stacked
Out [8]:
地域 人数
0 1月 京都 110
1 2月 京都 115
2 3月 京都 144
3 1月 大阪 263
4 2月 大阪 283
5 3月 大阪 309
6 1月 奈良 12
7 2月 奈良 13
8 3月 奈良 21

pandasでmergeせずにgroupbyで間接参照したい

この前、pandasを使っていて、次のような感じの、関連する2つのテーブル、access_logとchoice_logがある時に、結合したテーブルを作らずにchoice毎のtimestampの最小値を求めたかったのだが、どう書けば良いのかわからなかった。

import pandas as pd
import numpy as np
access_log = pd.DataFrame({'session': [100, 101, 102, 103, 104, 105, 106, 107, 108, 109],
                           'timestamp': [314, 159, 265, 358, 979, 323, 846, 264, 338, 327]})
choice_log = pd.DataFrame({'session': [100, 100, 101, 102, 102, 103, 104, 104, 105, 106, 106, 107, 108, 108, 109],
                           'choice':  ['A', 'B', 'C', 'D', 'E', 'A', 'B', 'C', 'D', 'E', 'A', 'B', 'C', 'D', 'E']})
>>> access_log
   session  timestamp
0      100        314
1      101        159
2      102        265
3      103        358
4      104        979
5      105        323
6      106        846
7      107        264
8      108        338
9      109        327
>>> choice_log
   choice  session
0       A      100
1       B      100
2       C      101
3       D      102
4       E      102
5       A      103
6       B      104
7       C      104
8       D      105
9       E      106
10      A      106
11      B      107
12      C      108
13      D      108
14      E      109
>>> 

結合テーブルを作るなら、次のように書ける。

merged = choice_log.merge(access_log, on='session', how='left')
result = merged.groupby('choice')['timestamp'].min()
>>> result
choice
A    314
B    264
C    159
D    265
E    265
Name: timestamp, dtype: int64
>>> 

実際にあったテーブルは巨大で、他にも列がたくさんあり、単純に結合テーブルを作るとRAMが足りなくなってメモリスワップが多発したので、結合テーブルを作らずにこれと同じことがしたかったのだが、その書き方がわからなかった。
結局access_log.set_index('session').to_dict()のようにして一時的なdictを作って、スワップを多発させながら処理してしまった。
それが心残りだったので、改めてpandasのドキュメントを拾い読みしながら方法を探してみた。

  1. 単純に、別のテーブルを参照する関数をSeries.mapに渡す
    def session_to_timestamp(session_series):
        return session_series.map(lambda x: access_log[access_log.session==x]['timestamp'].iat[0])
    
    result = choice_log.groupby('choice').agg(lambda x: session_to_timestamp(x).min())
    

    Seriesの先頭の要素を取り出す方法には、.iat[0]の他に.iloc[0]や.values[0]などがあり、筆者が試した所values[0]の方が速かったりしたが、pandasのドキュメントに書かれているのはilocとiatなので、ここでは添字が整数なら高速なiatを用いた。

  2. リスト内包表現(list comprehension)で別のテーブルを参照する
    def session_to_timestamp(session_series):
        return [access_log[access_log.session==x]['timestamp'].iat[0] for x in session_series]
    
    result = choice_log.groupby('choice').agg(lambda x: min(session_to_timestamp(x)))
    

    リストにはminメソッドが無いので、session_to_timestamp(x).min()とはできない。

  3. isinを使ったBoolean Indexingで別の表のサブセットを得る
    def session_to_timestamp(session_series):
        return access_log[access_log.session.isin(session_series.values)]['timestamp']
    
    result = choice_log.groupby('choice').agg(lambda x: session_to_timestamp(x).min())
    
  4. 別の表からSeriesを作ってSeries.mapに渡す
    def session_to_timestamp(session_series):
        return session_series.map(access_log.set_index('session').timestamp)
    
    result = choice_log.groupby('choice').agg(lambda x: session_to_timestamp(x).min())
    

    一見シンプルで美しそうだが、set_index()はコピーを返すので、timestampの一時的なdictを作るのと変わらない。しかも、グループ数だけ新たなテーブルを作るので、無駄である。

これらの処理時間を色々測ってみたが、2つのテーブルのサイズやグループの数によって変わり、どう比較すれば良いかわからなかったので、省略する。
大まかな傾向としては、1.と2.の処理時間はchoice_logのサイズに依存し、3.と4.の処理時間はsession_logのサイズに依存するようだった。4.はset_indexした中間テーブルを事前に作っておくと高速化するが、それでも、大抵の場合3.が一番速かった。
いずれの方法も最速になる場合があるようなので、場合毎に色々試してみるしかなさそうである。

肝心のメモリ使用量は、適当な測り方がわからなかった。
そもそも、スワップしながらの処理時間が問題だったので、単純にメモリ使用量では測れないと思う。

他にも、以下のような方向で書き方を考えてみたが、うまくできなかった。

  • groupbyでaggregateでなくtransformしてmin()
    transformする時に別のテーブルを参照することを考えたが、transformするとgroup解除されてしまうので、使えなかった。transformする時にmin()するのなら、min()した値を増殖させるだけ無駄なので、確実にaggregateの方が効率が良い。
  • pandas.DataFrame.lookupを使う
    引数としてindexしか受けられないので、使えなかった。
  • pandas.DataFrame.joinを使う
    pandas.DataFrame.mergeを使うのと変わらなかった。

そもそも、lambda式を使わずに書く方法は無いのだろうか。
teratailとかStack Overflowとかで聞いた方が早いか。

pandas.read_csv+numpy.bincountでエラー

ちょっと前に、はやりのAIのプログラミングでよく使われるPythonとNumPyとPandasを使い始めたのだが、PandasでCSVファイルを読み込んで、各ラベルの出現回数を得る為にNumPyのbincountメソッドを使うと、次のようなエラーが出て、困った。

TypeError: Cannot cast array data from dtype('int64') to dtype('int32') according to the rule 'safe'
その時使った環境は、Windows 7(32bit版)+Python 3.5.2(Anaconda 4.1.1 (32-bit))である。Mac OS Xでは出なかった。

これは、32bitのPythonを使っていると起こることらしい。例えば、32bit Pythonで次のプログラムを実行すると、同じエラーが出る。

import numpy as np
import pandas as pd
import io

data = np.random.randint(10, size=30)
buf = io.StringIO("\n".join(str(x) for x in data))
df = pd.read_csv(buf, header=None)
x = df[0].values
print(x)
print(np.bincount(x))	#Error on 32bit Python
これは、Pandasが32bit Pythonでもint64を使うのが原因のようである。

このエラーを回避するには、bincountに渡すデータの型をint32にすれば良い。

print(np.bincount(x.astype('int32')))	#also OK on 32bit Python
出力例
[1 0 6 7 1 7 7 6 9 5 6 2 8 0 7 6 7 8 9 7 8 9 8 0 2 1 2 6 0 3]
[4 3 3 1 0 1 5 6 4 3]

または、np.unique(return_counts=True)を使う方法があり、こちらの方が、大抵の場合はnp.bincountを使うよりも好ましいとされるようである。

print(np.unique(x, return_counts=True))
出力例
(array([0, 1, 2, 3, 5, 6, 7, 8, 9]), array([4, 3, 3, 1, 1, 5, 6, 4, 3]))
確かに、np.unique(return_counts=True)の方が、データに負の値があっても使えるし、大きな値が混ざってても配列が巨大にならないので、安全そうである。

なお、今動作しているPythonが32bitか64bitかを判定する方法は、いくつかあるようである。 例1

import platform
platform.architecture()
出力(上がMacOSX、下がWin32)
('64bit', '')
('32bit', 'WindowsPE')
例2
import sys
"%x" % sys.maxsize
出力(上がMacOSX、下がWin32)
'7fffffffffffffff'
'7fffffff'
例3(platform.architecture()の実装にも使われている方法)
import struct
struct.calcsize("P") * 8
出力(上がMacOSX、下がWin32)
64
32