【Python】PandasのDataFrameで特定の行を爆速で更新する方法

【Python】PandasのDataFrameで特定の行を爆速で更新する方法


# numpy # pandas # python

すみません、タイトルはちょっと誇張表現含んでます。

あまりpandasに慣れていない人が書いていたと思われるコードで実行すると、 数十分かかる処理が1秒以下で終わるようになるという事はざらにあります。

pandasは便利ではあるのですが、何も考えずに書くとPythonという言語の特性やpandasのデメリットばかりを享受するようなコードになりがちです。 本来の実力をpandasに発揮してもらえるようになったらいいなあという記事になります。

環境

Python: 3.7.4(Anaconda環境)

numpy:1.16.5

pandas:0.25.1

実行時間の計測はjupyterで「%%timeit」を使用しています。

この記事で早くなる()内容

・特定のindexを指定してデータの更新を行いたい。 ・更新を行われるデータは事前に全て用意されている ・かつ全部同じデータではない。

ような場合が対象になるかと思います。 別のシステムからまとめてデータを取得した際や、外れ値を持つようなデータを補正する際などに使われるのではないでしょうか。

 

テスト用のデータを生成

置き換えが行われるデータ

1万行のデータをランダム生成してみました。

data_length = 10000

def create_data(min_num, max_num):
    return np.random.randint(low=min_num, high=max_num, size=data_length)


df = pd.DataFrame(
    columns=['apple_price', 'orange_price', 'melon_price', 'banana_price', 'mango_price'],
    index=np.arange(0, data_length))
df.apple_price = create_data(60, 160)
df.orange_price = create_data(70, 140)
df.melon_price = create_data(120, 340)
df.banana_price = create_data(20, 80)
df.mango_price = create_data(200, 280)
apple_priceorange_pricemelon_pricebanana_pricemango_price
014311925138221
1718621934209
211112918648221
39512020067234
414812417268265
999515210515056254
999612511930139267
99978013424564227
999812410716542204
999911311421525259

10000 rows × 5 columns

更新するデータ

target_index = df[np.random.choice([True, False], size=len(df))].index
target_value = np.random.randint(low=1000, high=3000, size=len(target_index))

replace_series = pd.Series(target_value, index=target_index)

# DataFrameでの置き換えも試してみているので作成。上記と同じデータ。
replace_df = replace_series.to_frame('value')

 

問題だったコード

%%timeit
transformed_df = df.copy()

for index, row in replace_df.iterrows():
    transformed_df.loc[index, 'apple_price'] = replace_df.loc[index, 'value']

# 1.21 s ± 158 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

便利だからといって、とりあえずdf.iterrowsを使うのは罪。

df.iterrowsを使うとループの度にSeriesが生成される(らしい)ので非常に低速になる可能性を孕んでいます。 いや便利なメソッドですよ。でも速度が欲しいとね…。

改善案たち

①df.itterrowsを取り除く(初期の1.78倍高速)

%%timeit
transformed_df = df.copy()

for index in replace_df.index:
    transformed_df.loc[index, 'apple_price'] = replace_df.loc[index, 'value']

# 682 ms ± 13.3 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

これだけで実行時間は半分くらいになります。iterrowsは怖いですねぇ。

でも、locを乱発することは遅いと聞くのでそれもなくしたい。

②ボツ

%%timeit
# locでのアクセスを減らすため、pd.Series.items()での取得に変更。
# ※dict.items()と同様に、index, valueを取得できる関数。

transformed_df = df.copy()
for index, value in replace_series.items():
    transformed_df.loc[index, 'apple_price'] = value
% 6.56 s ± 149 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

終わってますね…。次だ次。。。

③locやpandasループは遅いのでndarrayに変換して回す(初期の1.87倍高速)

ソースを覗いて見たらpandasのDataFrameやSeriesのループは、各ループごとに値を取り出すだけでなくデータ型の判定(+変換)が行われるので便利だが遅いようです。

%%timeit
transformed_df = df.copy()
for index, value in zip(target_index, target_value):
    transformed_df.loc[index, 'apple_price'] = value
# 645 ms ± 22.7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

若干ですが改善しました。

やはりlocを減らすことは大切なようですが、代入先を指定する際にlocを使ってるので道半ばですね…。

④ループからpandas排除(ndarrayで更新→列に代入)(初期の952倍高速)

%%timeit
transformed_df = df.copy()
transformed_apple_value = transformed_df.loc[:, 'apple_price'].values

for index, value in zip(target_index, target_value):
    transformed_apple_value[index] = value

transformed_df['apple_price'] = transformed_apple_value
# 1.27 ms ± 29.4 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

もうpandas抜きでやろうぜって例。locないと速い…。すごいぜ…。

⑤冷静になってpandasのupdate文を使う(初期の1,248倍高速)

%%timeit
transformed_df = df.copy()
transformed_df['apple_price'].update(replace_series)

# 969 µs ± 23.4 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

そもそもupdateメソッドで特定のindexの値を全部置き換えられるので、それを使う。 pandasはすごいんです。可読性も高くなりやすいし。

自分でちょっと工夫するくらいなら、組み込みの関数を使うのが無難。簡単で早い!

⑥numpyだけかつループなしで更新してみる(初期の5,084倍高速)

%%timeit
transformed_df = df.copy()
transformed_apple_value = transformed_df.loc[:, 'apple_price'].to_numpy()

transformed_apple_value[target_index] =target_value 
transformed_df['apple_price'] = transformed_apple_value

# 238 µs ± 3.64 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

今回の最終系(このくらいなら良いけど複雑な処理になると、可読性結構下がりそう)。 nd_arrayに変換した上で、ループを使わずに変換。ループなしかつnumpyだけで処理すると凄い早い。

まとめ

事前に更新データ(全データではなく一部のindexの更新)を用意する方が高速な場合は、pandasのupdate文を使ってあげましょう。

どうしても早くしたい場合は、numpyに変換してから処理を回してあげると良さそうです。 可読性は下がり記述量は増えますが、速度は良い感じです。

最後に

高速化はバグを生みやすかったり無限に時間取られたりするので、どうしても必要な時もしくは最後にやりましょう!!!