ML_BearのKaggleな日常

元WEBマーケターのMLエンジニアがKaggleに挑戦する日々を綴ります

遅くない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)で取り上げられていて、ご存知の方にとっては「何をいまさら」と思われることかもしれません。

が、この記事のタイトルからしてこのトピックを取り上げないわけにはいかないので紹介させていただきます。

iterrowsの遅さを体感しよう

基本的に、iterrows を使わない書き方をするだけで9割の破滅的な遅さを回避できます(断言)

業界の有識者の方もこのようなツイートをされています

まずは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の使い方を学んだ方が中期的に学習効率良いと思います。
  • cudf
    • 多少使えない関数はあるが基本爆速
    • cuml と組み合わせたりすると良い
    • colab標準で入れてくれてGPU気軽に使えればいいんだけどなぁ
      • colabへのインストールがむずい
  • numba
    • コンパイルできるように書けば速い
    • ただ numba で頑張るぐらいなら cudf とか BQ でええんちゃうか

参考資料

宣伝

最後に宣伝させてください。(イベント終わった後で消す)

  • 僕の所属しているチームで勉強会やるのでよかったらぜひ
    • 僕は現職の仕事ではメルカリアプリのホーム画面に出すレコメンデーションパーツの裏側のロジックを組んでいます。
    • この記事で紹介した方法なども使いつつ膨大なログを解析してレコメンデーションロジックを組むのはなかなか面白いです
    • 尋常じゃないアクセス数があるのでアプリに組み込むときの方法なども考慮してロジックを組むのは正直なかなか骨が折れる仕事ですが笑、飽き性の僕でもなかなか飽きなくて素晴らしいと思います。
  • 受付が12/14までとなっているので、興味ある方はこのまま↓から申し込んで見てください

mercari.connpass.com