この前、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のドキュメントを拾い読みしながら方法を探してみた。
- 単純に、別のテーブルを参照する関数を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を用いた。
- リスト内包表現(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()とはできない。
- 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())
- 別の表から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は便利そうなので、pandasの勉強を兼ねてこの問題に取り組んでみたが、いくら勉強しても、やっぱり難しい。サンプルコードと同じように書いてるつもりでも、Boolean Indexingなどの内部の動作を理解していないとエラーになってしまう。色々な書き方ができるが、エラーにならずに動く書き方は限られているという感じである。
コメント