Python
python3
チューニング
パフォーマンス

Pythonで辞書のキーにtupleが指定できた件(+パフォーマンスの話)

強化学習の本を読んでいて、とても今更なのですがPythonで辞書のキーにtupleが指定できることを知りました。

普段はPandasでデータフレームで対応することが多いので、そこまで頻繁に使うというものではありませんが、稀に必要になった時のためにメモしておきます。(マイナーなトピックなので、需要は皆無な気がしないでもないですが・・)

できること

sample_dict = {}
sample_dict[(100, 3, 1300)] = 500
print(sample_dict[(100, 3, 1300)])
500

という書き方ができます。defaultdictでもできます。

from collections import defaultdict
sample_defaultdict = defaultdict(int)
sample_defaultdict[(100, 3, 1300)] = 500
print(sample_defaultdict[(100, 3, 1300)])
500

tupleが使えると何が嬉しいの?

前述の3つの値をキーにするときのように、複数の値でキーを設定したい場合、色々方法があると思いますが、中でも記述が短くシンプルになる、という点がまず挙げられそうです。

たとえば、ゲームのログを想定して、ユーザーID・アイテム種別・アイテムIDみたいな組み合わせのキーを持つ必要があると考えて、文字列でアンダースコアで繋げてキーにする場合を考えてみます。

user_id = 100
item_type = 3
item_id = 1300

sample_dict = {}
key = str(user_id) + '_' + str(item_type) + '_' + str(item_id)
sample_dict[key] = 500

数値などが絡む場合は上記のように文字列のキャストを挟んだりする必要が出てきます。若干記述が煩雑になります。

また、キャストを挟む都合、ある程度速度に差異が出てきます。

%%timeit
key = str(user_id) + '_' + str(item_type) + '_' + str(item_id)
sample_dict[key] = 500
839 ns ± 7.36 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
%%timeit
sample_dict[(user_id, item_type, item_id)] = 500
134 ns ± 0.353 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

両方とも処理時間がナノセカンドの単位なので、十二分に早い感じではありますが、6倍程度は差が出ています。
かなりでかいデータで、且つ辞書を扱わないといけないケースで、さくっとPythonだけで対応する際などには地味に影響することが出てくるかもしれません。

※もともと文字列の値などであれば、文字列連結時にはそこまで速度には影響しません。(tupleで扱うのと比べるとやはり少し遅くはなりますが)

user_id = '100'
item_type = '3'
item_id = '1300'
%%timeit
key = user_id + '_' + item_type + '_' + item_id
sample_dict[key] = 500
306 ns ± 1.3 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

また、文字列で連結したキーの場合、再度キーを参照したい場合に、一度splitなどで分割したりする必要が出てくるため、その点でも記述の煩雑さやsplitし、さらに元のデータの型(intなど)へキャストする点を考えると、速度的にもある程度差異が生まれてくると思います。
tupleで指定した際には、ループを回すとそのままキーの値がtupleで設定され、型もそのままなので直接インデックスにアクセスするだけで対応できます。

for key, value in sample_defaultdict.items():
    print('key :', key, ', type :', type(key))
    print(key[0], key[1], key[2])
key : (100, 3, 1300) , type : <class 'tuple'>
100 3 1300

文字列を連結せずに、sample_dict[100][3][1300]といった具合に扱う方法を考慮してみます。

この場合、普通の辞書だと、各階層で毎回キーの有無を確認して、なければ辞書を追加・・とやっていると、コードが煩雑になるので、defaultdict(デフォルト値が設定され、キーの有無で怒られない辞書)を使うようなケースが多いと思います。
多次元にする場合はlambdaを挟んでdefaultdictを初期化します。

sample_defaultdict = defaultdict(lambda: defaultdict(lambda: defaultdict(int)))
sample_defaultdict[100][3][1300] = 500

こちらも、初期化時でlambdaを重ねる点が少し分かりづらい点を除けば、初期化した後であればキャストも無しにシンプルな書き方で扱えます。

また、速度も大分早いです。

%%timeit
sample_defaultdict[100][3][1300] = 500
94.7 ns ± 0.852 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

普通にdefaultdictでtupleを使う場合と比較してみても、ほぼ遜色ありません。

sample_defaultdict = defaultdict(int)
%%timeit
sample_defaultdict[(100, 3, 1300)] = 500
86.1 ns ± 0.347 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

(余談ですが、普段特に気にしていませんでしたが、普通の辞書よりdefaultdictの方がぼちぼち早いのでしょうか・・?)

普通の辞書で文字列をキャストしてキーとする場合と、defaultdictでtupleを使う場合とだと10倍くらい差が出るようです。(連結・キャスト数に依存するとは思いますが・・)