遅くないpandasの書き方
これは何?
- この記事は Kaggle Advent Calendar 2021 の7日目の記事です。
- pandasはデータ分析ライブラリとして非常に便利ですが、書き方を間違えると簡単に処理が遅くなってしまうという欠点があります。そこで、この記事では遅くならない書き方をするために気をつけたいポイントをいくつかご紹介したいと思います。
- この Colab Notebookの実行結果をエクスポートした上で、不要な部分を一部削って記事にしています。colab notebook をコピーして実行してもらえれば再現することが可能なはずです。(colabにコメント等をいただいても返すことはできないと思います、すみません。)
前提条件
- この記事ではあくまで「遅くない(なりづらい)書き方を紹介する」ことに努めます。よって、以下のような改善点はあるが一旦考慮の外におくものとして話を進めます。
- 並列化ライブラリ
- numbaでのコンパイル
- (cudfなどでの)GPU活用
- BigQuery利用
- 他言語利用(C++とか)
余談
- pandas高速化でググると並列化ライブラリの紹介が結構出てきます
- 基本的にはこの辺りはあんまり調べる必要はないと思っています
- どのライブラリも微妙にpandasとは互換性がないので、どうせ微妙に互換性がないものを学ぶならcudf一択かなと思います。
- 今はcolabに標準で組み込まれていませんが、そのうち組み込まれるはず… (僕の願望も含む)
目次
データ準備
- まずは例に使うデータの準備を行います
- 別に何のデータでもよかったのですが、こちらの記事で使われているデータを適当に加工して使います。
- データ量が少なかったのでカラム数を20倍、行数を100倍に膨らませています。
import gc import string import random import numpy as np import pandas as pd from tqdm.notebook import tqdm tqdm.pandas() def make_dummy_location_name(num=10): chars = string.digits + string.ascii_lowercase + string.ascii_uppercase return ''.join([random.choice(chars) for i in range(num)]) def make_dummy_data(df, location_name): for i in range(20): df[f'energy_kwh_{i}'] = df[f'energy_kwh'] * random.random() df['location'] = make_dummy_location_name() return df !wget https://raw.githubusercontent.com/realpython/materials/master/pandas-fast-flexible-intuitive/tutorial/demand_profile.csv df_tmp = pd.read_csv('demand_profile.csv') df_dummy = pd.concat([ make_dummy_data(df_tmp.copy(), x) for x in range(100) ]).reset_index(drop=True) df_dummy = df_dummy.sample(frac=1).reset_index(drop=True) df_dummy.to_csv('data.csv') display(df_dummy.info()) display(df_dummy[['date_time', 'location', 'energy_kwh_0', 'energy_kwh_1']].head(3))
date_time | location | energy_kwh_0 | energy_kwh_1 | |
---|---|---|---|---|
0 | 14/7/13 3:00 | DOymwZfkoV | 0.696740 | 0.419453 |
1 | 24/7/13 21:00 | smOT74HjRq | 0.213311 | 0.317483 |
2 | 4/6/13 9:00 | nKYmHeR2ov | 0.322995 | 0.413001 |
- join / merge の実演に使う適当なデータも作ります
- location_idのリストとしました
locations = df_dummy['location'].drop_duplicates().values df_locations = pd.DataFrame({ 'location': locations, 'location_id': range(len(locations)) }) df_locations.head(3)
location | location_id | |
---|---|---|
0 | DOymwZfkoV | 0 |
1 | smOT74HjRq | 1 |
2 | nKYmHeR2ov | 2 |
データ読み込み
さて、ここから実際に話を進めていこうと思います。 まずはデータを読み込むときに気をつけるポイントです
usecols の利用
- データが大きい、かつ、捨てるカラムが多い時は必ず
usecols
を指定しましょう - 読み込み速度が如実に変わります
usecols = ['date_time', 'energy_kwh_0', 'energy_kwh_1', 'energy_kwh_2', 'location']
%%time # usecolsがないとき df = pd.read_csv('data.csv')
CPU times: user 4.84 s, sys: 126 ms, total: 4.97 s
Wall time: 4.96 s
%%time # usecolsがあるとき df = pd.read_csv('data.csv', usecols=usecols)
CPU times: user 2.86 s, sys: 97.1 ms, total: 2.95 s
Wall time: 2.93 s
型指定
- 余裕がないとき以外は型を指定しましょう
- 集計のキーにするカラムは category 型にしておくと集計が早くなるメリットがあります (後述)
- 読み込み速度には影響しませんが、メモリ使用量に大きく貢献するのでメモリ不足で落ちた、等の不要なエラーを防ぐことでトライアンドエラーの効率も上がると思います。
%%time # 型指定しておくとお行儀が良い # 自分で型を考えるのが面倒な時は次節の reduce_mem_usage を使うのでも良い df = pd.read_csv( 'data.csv', usecols=usecols, dtype={ 'date_time': str, 'energy_kwh_0': float, 'energy_kwh_1': float, 'energy_kwh_2': float, 'location': 'category' } )
CPU times: user 2.79 s, sys: 73.1 ms, total: 2.87 s
Wall time: 2.86 s
cudf
- 数億行単位のファイルならcudfを使うのも良いと思います
- Kaggle Riiidコンペデータ(大体1億行ぐらい)ではpandas読み込みでは1分以上かかっているものがcudf読み込みだと3秒で終わるとの投稿もありました
- 読み込んだ後の処理も一部変わるので注意は必要ですが、莫大なデータを扱うときの選択肢として学習コストに見合うパフォーマンスだと思います。
# import cudf # cdf = cudf.read_csv('data.csv')
iterrows は絶対に使わない (applyも)
数多くの記事(1)(2)で取り上げられていて、ご存知の方にとっては「何をいまさら」と思われることかもしれません。
が、この記事のタイトルからしてこのトピックを取り上げないわけにはいかないので紹介させていただきます。
- 遅くない書き方は色々ありますが…
- とりあえず考えるのが面倒なら「numpy行列にしてからループを回せば及第点」だと覚えておけば良い
- 「
np.where
やnp.vectorize
を使ってループ処理を排除すると速い」というのも併せて覚えておくと、困ったときの手段として使えます。
- ref
iterrowsの遅さを体感しよう
基本的に、iterrows
を使わない書き方をするだけで9割の破滅的な遅さを回避できます(断言)
業界の有識者の方もこのようなツイートをされています
pandasで速いコードを書きたかったらiterrowsとapplyとtransformを使うなおじさん「pandasで速いコードを書きたかったらiterrowsとapplyとtransformを使うな」 https://t.co/DLmWsBvAUG
— まますたん (@mamas16k) 2021年11月2日
まずはiterrowsの遅さを体感してみましょう。
(実行する処理は何でもいいので適当に書きました。特に意味はありません。)
# 破滅的に遅い patterns = [] for idx, row in tqdm(df.iterrows(), total=len(df)): if row['energy_kwh_0'] > row['energy_kwh_1']: pattern = 'a' elif row['energy_kwh_0'] > row['energy_kwh_2']: pattern = 'b' else: pattern = 'c' patterns.append(pattern) df['pattern_iterrows'] = patterns
CPU times: user 1min 23s, sys: 661 ms, total: 1min 23s
Wall time: 1min 24s
apply
にすると少しはマシなように見えるのですが、後述する「遅くない書き方」と比べると比較にならないくらい遅いです。
%%time def func_1(energy_kwh_0, energy_kwh_1, energy_kwh_2): if energy_kwh_0 > energy_kwh_1: return 'a' elif energy_kwh_0 > energy_kwh_2: return 'b' else: return 'c' df['pattern_iterrows'] = df.progress_apply( lambda x: func_1(x['energy_kwh_0'], x['energy_kwh_1'], x['energy_kwh_2']), axis=1 )
CPU times: user 18 s, sys: 494 ms, total: 18.4 s
Wall time: 18.5 s
脳死で書ける書き方 (numpy配列にする)
- とりあえずこう書いておけば死にはしない、という結論を置いておきます
- このデータ例では
iterrows
を回すより100倍前後、apply
するより20倍前後早くなっています iterrows
で for ループ回す書き方と非常に似ているので、覚えるのも簡単かと思います
%%time # とりあえず numpy 行列にしてから回せば早い patterns = [] for idx, (energy_kwh_0, energy_kwh_1, energy_kwh_2) in tqdm(enumerate( zip( df["energy_kwh_0"].values, df["energy_kwh_1"].values, df["energy_kwh_2"].values ) ), total=len(df)): # 後は iterrows のコードをそのまま書けば良い if energy_kwh_0 > energy_kwh_1: pattern = 'a' elif energy_kwh_0 > energy_kwh_2: pattern = 'b' else: pattern = 'c' patterns.append(pattern) df['pattern_by_np_array'] = patterns assert np.array_equal(df['pattern_iterrows'], df['pattern_by_np_array']) # 一応確認 (10msぐらいかかってる)
CPU times: user 1.12 s, sys: 18.9 ms, total: 1.14 s
Wall time: 1.17 s
その他色々な書き方
- 上記の書き方はループを回しているからか、最良のパフォーマンスと比べるとやや劣ってしまいます。
- そこで以下でループを回避する方法が2つ紹介しておきます
- numpyの処理と組み合わせると速いので紹介します
np.where の活用
%%time # 簡単な処理なら np.where などで処理することも考える df['pattern_np_where'] = 'c' df['pattern_np_where'] = np.where(df['energy_kwh_0'] > df['energy_kwh_2'], 'b', df['pattern_np_where']) df['pattern_np_where'] = np.where(df['energy_kwh_0'] > df['energy_kwh_1'], 'a', df['pattern_np_where']) assert np.array_equal(df['pattern_np_where'], df['pattern_by_np_array']) # 一応確認 (10msぐらいかかってる)
CPU times: user 68.2 ms, sys: 0 ns, total: 68.2 ms
Wall time: 66.6 ms
np.vectorize の活用
- np.whereで書くような処理は速いのですが、複雑な処理になると、処理を実現する同等の操作を考えるのにまぁまぁ頭を使う必要があります。
- そこで、考えるのが面倒なら
np.vectorize
というnumpyの関数を使う方法があるのでご紹介します。 - 推論する時間のオーバーヘッドがあるためかやや遅いのですが、それで上記の numpy行列にしてから for ループを回すよりは全然速いです。
%%time def func_1(energy_kwh_0, energy_kwh_1, energy_kwh_2): if energy_kwh_0 > energy_kwh_1: return 'a' elif energy_kwh_0 > energy_kwh_2: return 'b' else: return 'c' df['pattern_np_vectorize'] = np.vectorize(func_1)( df["energy_kwh_0"], df["energy_kwh_1"], df["energy_kwh_2"] ) assert np.array_equal(df['pattern_np_vectorize'], df['pattern_by_np_array']) # 一応確認 (10msぐらいかかってる)
CPU times: user 296 ms, sys: 35 ms, total: 331 ms
Wall time: 333 ms
型指定あれこれ
「iterrows
使うな」でこの記事で言いたいことの90%ぐらい終わっているのですが、他にも細々とした点が少しあるので以下少し描いておきます。まずは型指定の話です。
- groupbyするときにはカテゴリ型をなるべく使う
groupby
するときのキーが(object型ではなく) category型だと早い- 1回しか集計しないならカテゴリ型に変換する時間が無駄なので変換不要だが、大抵の場合は何度も集計処理をするのでcategory型にしておくと良い
- その他のカラムも必要な精度に応じてカテゴリ変換しておくとメモリ使用量も削減できて良い
- 自分で型を考えるのが面倒な時はKaggleコード遺産の reduce_mem_usage を使う手もあり
- 高速化されたものを紹介されている 記事
groupby の集計キーは category 型が良い
# データ読み込みの dtype 指定で category 型にしてしまっているので効果を確認するために一度object型に戻す df['location'] = df['location'].astype('object')
%%time hoge = df.groupby('location')['energy_kwh_0'].mean()
CPU times: user 59.8 ms, sys: 11 µs, total: 59.8 ms
Wall time: 59.1 ms
%%time # category 型への変換は多少時間がかかる df['location'] = df['location'].astype('category')
CPU times: user 59.4 ms, sys: 1.01 ms, total: 60.4 ms
Wall time: 64.2 ms
%%time # ただし一度変換しておくとその後の集計は早い hoge = df.groupby('location')['energy_kwh_0'].mean()
CPU times: user 11.6 ms, sys: 996 µs, total: 12.6 ms
Wall time: 16.7 ms
pd.to_datetimeはフォーマット指示するのが吉
%%time # この例は極端な例かもだが… pd.to_datetime(df['date_time'])
CPU times: user 1min 21s, sys: 223 ms, total: 1min 21s
Wall time: 1min 21s
%%time # 指定すると推論が入らないからか速い pd.to_datetime(df['date_time'], format='%d/%m/%y %H:%M')
CPU times: user 2.37 s, sys: 8.13 ms, total: 2.37 s
Wall time: 2.36 s
集計時のカラム指示
# %%time # これ終わらないので注意 # df.mean()
%%time
df.mean(numeric_only=True)
CPU times: user 8.44 ms, sys: 37 µs, total: 8.48 ms
Wall time: 9.53 ms
numpy 処理の活用
- (言うまでもないが) numpy に実装されている処理はそれで書いたほうが速い
- 上記の例のように何十倍も早くなるというわけでもないが、10-30%程度は速いのでなるべく気を使った方が良い
%%timeit df[['energy_kwh_0', 'energy_kwh_1', 'energy_kwh_2']].sum(axis=1)
10 loops, best of 5: 43.1 ms per loop
%%timeit df[['energy_kwh_0', 'energy_kwh_1', 'energy_kwh_2']].values.sum(axis=1)
100 loops, best of 5: 11 ms per loop
高速join
最後に、やや高度な高速化の話をします。
- pandas DataFrame をmergeしたい時、一定の条件を満たしていると「reindexを用いた上でconcatする」と速いというテクニックがあります。
- 一定の条件とは、「join したいDataFrameの join に利用するキーがユニークである」という内容です
- 書き方もややこしいのでこの制約を確認しつつ高速joinを行う関数を持っておくといいかもしれない
- この記事で使ってる例ぐらいのデータ量だとそんなに差が出ていないが、この方法の初出(?)の KaggleRiiidコンペのNotebook では350倍以上速くなっている(!)
%%time df_merge = pd.merge(df, df_locations, how='inner', on='location')
CPU times: user 909 ms, sys: 9.94 ms, total: 918 ms
Wall time: 920 ms
%%time df_concat = pd.concat([ df, df_locations.set_index('location') .reindex(df['location'].values) .reset_index(drop=True) ], axis=1)
CPU times: user 148 ms, sys: 3.77 ms, total: 151 ms
Wall time: 150 ms
その他 飛び道具系
- 並列化ライブラリ
- 使うならpandarallelが手軽でおすすめ
- mecabでの分かち書きみたいなどうしようなない処理を parallel_apply して使ったりしてます
- その他色々ある奴はよく知らないが、それを勉強するくらいならcudfの使い方を学んだ方が中期的に学習効率良いと思います。
- 使うならpandarallelが手軽でおすすめ
- cudf
- 多少使えない関数はあるが基本爆速
- cuml と組み合わせたりすると良い
- colab標準で入れてくれてGPU気軽に使えればいいんだけどなぁ
- colabへのインストールがむずい
- numba
- コンパイルできるように書けば速い
- ただ numba で頑張るぐらいなら cudf とか BQ でええんちゃうか
参考資料
- この記事を執筆するにあたって参考にした記事を列挙しておきます
- How to make your Pandas operation 100x faster
- Do You Use Apply in Pandas? There is a 600x Faster Way
- Fast, Flexible, Easy and Intuitive: How to Speed Up Your Pandas Projects
- Dunder Data Challenge #2 — Explain the 1,000x Speed Difference when taking the Mean
- How to simply make an operation on pandas DataFrame faster
- Make Pandas Run Blazingly Fast
- 超爆速なcuDFとPandasを比較した
- pandasで使う処理はだいたい自分のブログ記事にまとまってる
宣伝
最後に宣伝させてください。(イベント終わった後で消す)
- 僕の所属しているチームで勉強会やるのでよかったらぜひ
- 僕は現職の仕事ではメルカリアプリのホーム画面に出すレコメンデーションパーツの裏側のロジックを組んでいます。
- この記事で紹介した方法なども使いつつ膨大なログを解析してレコメンデーションロジックを組むのはなかなか面白いです
- 尋常じゃないアクセス数があるのでアプリに組み込むときの方法なども考慮してロジックを組むのは正直なかなか骨が折れる仕事ですが笑、飽き性の僕でもなかなか飽きなくて素晴らしいと思います。
- 受付が12/14までとなっているので、興味ある方はこのまま↓から申し込んで見てください