Python, Pandasデータ処理高速化・メモリ負荷削減 - サンプルコード付き
はじめに
Python, 特にPandasの重い処理をどうしたら高速化でき、メモリ負荷も減らせるのでしょう。
サンプルデータで実行速度を比較しながら見てみましょう。
また、記事中の全てのsample codeをGoogle driveで共有しているので参考にしてください。
ipynb:https://colab.research.google.com/drive/12zSrJs005v_VU8XnDbBqA7kc5Heoupl9?usp=sharing
※ 実行環境:Google Colaboratory (2021/02/22)
目次
1, メモリ使用量削減
pandasデータフレームのレコードに合わせて各カラムの型を適切なサイズまで小さくしましょう。
def reduce_mem_usage(df): numerics = ['int16', 'int32', 'int64', 'float16', 'float32', 'float64'] for col in df.columns: col_type = df[col].dtypes if col_type in numerics: c_min = df[col].min() c_max = df[col].max() if str(col_type)[:3] == 'int': if c_min > np.iinfo(np.int8).min and c_max < np.iinfo(np.int8).max: df[col] = df[col].astype(np.int8) elif c_min > np.iinfo(np.int16).min and c_max < np.iinfo(np.int16).max: df[col] = df[col].astype(np.int16) elif c_min > np.iinfo(np.int32).min and c_max < np.iinfo(np.int32).max: df[col] = df[col].astype(np.int32) elif c_min > np.iinfo(np.int64).min and c_max < np.iinfo(np.int64).max: df[col] = df[col].astype(np.int64) else: if c_min > np.finfo(np.float16).min and c_max < np.finfo(np.float16).max: df[col] = df[col].astype(np.float16) elif c_min > np.finfo(np.float32).min and c_max < np.finfo(np.float32).max: df[col] = df[col].astype(np.float32) else: df[col] = df[col].astype(np.float64) return df df = reduce_mem_usage(df) # pandasデータフレーム
2, 読み書き
2-1, 分割して読み込む (chunksize)
大きなcsvファイルを読み込むのが大変な場合、chunksizeを指定し分割して読み込みましょう。
その際、上述したメモリ使用量を削減する関数を合わせて用いると効果的です。
def split_reader(inpath, size): reader = pd.read_csv(inpath, chunksize=size) # chunksizeを指定し分割 df = pd.concat((reduce_mem_usage(r) for r in reader), ignore_index=True) return df inpath = "....csv" # 読み込みたいcsvファイル size = 100000 # 読み込む際に1つの塊とするレコード数 df = split_reader(inpath, size)
2-2, 分割して書き出す (slice & append)
大きなPandasデータを書き出すのが大変な場合、レコードを分割し追記モードで出力しましょう。
初回出力以降はheaderを付けないのがミソです。
def split_writer(df, outpath, size): length = len(df) print("Start writing "+str(length)+" records... \n") for start in range(0, length, size): end = start + size print(str(start), "~", str(end)) # 初回出力はheaderを付け、以降は外す if start==0: # 初回出力 df[:end].to_csv(outpath, mode="a", header=True, index=False) elif end<length: # 中間出力 df[start:end].to_csv(outpath, mode="a", header=False, index=False) else: # 最終出力 df[start:].to_csv(outpath, mode="a", header=False, index=False) print("\n... Finish!") print("outpath:", outpath) outpath = "....csv" # 書き出したい出力先 size = 100000 # 書き出す際に1つの塊とするレコード数 split_writer(df, outpath, size)
3, 不要な変数を削除
大きなデータを持つ変数が不要になったら適宜手動で削除しましょう。
例えば、変数「df」という大きなPandasデータフレームが不要になった場合が以下です。
import gc # garbage collection del df gc.collect()
4, 組み込み関数(update)を避ける
Pandasデータを更新する際、updateより変数に再代入する方が早いようです。
※ ただし、一部ではupdateは早いという意見もあるため要検証です。
例えば、下記データフレームの欠損値をゼロ埋めする速度を比較します。
変数に再代入する方法と、updateを用いる方法を比べると、前者の方が8.3倍速いです。
所要時間: 再代入 ... 74.5μs ⇔ update ... 617μs
def roll_mean_overwrite(df): # 再代入 df = df.fillna(0) return df def roll_mean_update(df): # update df.update(df.fillna(0)) return df
5, PandasではなくNumpyを用いる
5-1, 新しいカラムを追加する時
Pandasデータにカラムを追加する際、pandas.locよりnumpy.whereの方が早いです。
例えば、下記データフレームで「VALUE」が100超なら「FLAG」を立てる場合の速度を比較します。
pandas.locによる方法と、numpy.whereを用いる方法を比べると、後者の方が3.7倍速いです。
所要時間: pandas.loc ... 1.09ms ⇔ numpy.where ... 294μs
def make_col_pandas(df): # pandas.loc df.loc[df["VALUE"]>100, 'FLAG'] = 1 df.loc[df["VALUE"]<=100, 'FLAG'] = 0 return df def make_col_numpy(df): # numpy.where df['FLAG'] = np.where(df['VALUE']>100, 1, 0) return df
5-2, 欠損値を埋める時
Pandasデータの欠損値を埋める際、pandas.fillnaよりnumpy.nan_to_numの方が早いです。
例えば、下記データフレームの欠損値をゼロ埋めする場合の速度を比較します。
pandas.fillnaによる方法と、numpy.nan_to_numを用いる方法を比べると、後者の方が2.4倍速いです。
ただし、後者は戻値がndarrayになるので、またpandasデータに戻すとなると前者の方が早いです。
所要時間: fillna ... 75.4μs ⇔ pandas(nan_to_num) ... 101μs ⇔ nan_to_num ... 31.6μs
def fillna_pandas(df): # pandas.fillna df_fill = df.fillna(0) return df_fill def fillna_numpy_to_pandas(df): # numpy.nan_to_numからpandasに戻す df_fill = pd.DataFrame(np.nan_to_num(df), columns=df.columns) return df_fill def fillna_numpy_to_ndarray(df): # numpy.nan_to_num、ただし戻値はndarray df_fill = np.nan_to_num(df) return df_fill
5-3, 外れ値を丸める時
Pandasデータの外れ値に対し、閾値を指定し丸める際、pandas.maskよりnumpy.whereの方が早いです。
例えば、下記データの各カラムでレコードが上下限5%を超える時、その閾値に丸める場合の速度を比較します。
pandasによる方法と、numpyを用いる方法を比べると、後者の方が12倍速いです。
所要時間: pandas.mask ... 18.5ms ⇔ numpy.where ... 1.49ms
def replace_outlier_pandas(df, perXtile, upper1_lower0): # pandas.mask length = len(df) perXtile = pd.concat([perXtile]*length, ignore_index=True) # pandas.maskは比較のため同一index, columnが必要 if upper1_lower0==1: df.mask(df>perXtile, perXtile, inplace=True) elif upper1_lower0==0: df.mask(df<perXtile, perXtile, inplace=True) else: print("Error: set value ... upper 1 or lower 0") return df def fix_outlier_pandas(df, point): # get percentile point per_upper_tile = pd.DataFrame(df.quantile(1-point)).T.reset_index(drop=True) per_lower_tile = pd.DataFrame(df.quantile(0+point)).T.reset_index(drop=True) # replace outlier df = replace_outlier_pandas(df, per_upper_tile, upper1_lower0=1) df = replace_outlier_pandas(df, per_lower_tile, upper1_lower0=0) return df # -- ↑pandas -- ↓numpy -- def replace_outlier_numpy(df, perXtile, upper1_lower0): # numpy.where if upper1_lower0==1: df = np.where(df>perXtile, perXtile, df) # numpy.whereは同一index不要でbroadcastされる elif upper1_lower0==0: df = np.where(df<perXtile, perXtile, df) else: print("Error: set value ... upper 1 or lower 0") return df def fix_outlier_numpy(df, point): feat_cols = list(df.columns) # get percentile point per_upper_tile = df.quantile(1-point).to_numpy().reshape(1,-1) per_lower_tile = df.quantile(0+point).to_numpy().reshape(1,-1) # replace outlier df = replace_outlier_numpy(df, per_upper_tile, upper1_lower0=1) df = replace_outlier_numpy(df, per_lower_tile, upper1_lower0=0) # return df add columns return pd.DataFrame(df, columns=feat_cols)
5-4, レコードを追加する時
Pandasデータに新しいレコードを追加する際、pandas.appendよりnumpy.appendの方が早いです。
例えば、下記1行のデータを100行複製する場合の速度を比較します。
pandasによる方法と、numpyを用いる方法を比べると、後者の方が67倍速いです。
所要時間: pandas.append ... 33.4ms ⇔ numpy.append ... 501μs
def concat_rows_pandas(df, num): # pandas.append df_stack = None for i in range(num): df_stack = df_stack.append(df) if df_stack is not None else df return df_stack.reset_index(drop=True) def concat_rows_numpy(df, num): # numpy.append feat_cols = list(df.columns) df = df.to_numpy() df_stack = None for i in range(num): df_stack = np.append(df_stack, df, axis=0) if df_stack is not None else df return pd.DataFrame(df_stack, columns=feat_cols)
おわりに
Python, 特にPandasのデータ処理高速化やメモリ負荷削減について考えました。
ビッグデータの処理速度やメモリ容量に悩んでいる方などに、参考にしてもらえれば幸いです。