Cream-Kuchen

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は早いという意見もあるため要検証です。

例えば、下記データフレームの欠損値をゼロ埋めする速度を比較します。

f:id:Cream-Kuchen:20210222154634p:plain

変数に再代入する方法と、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

f:id:Cream-Kuchen:20210222161419p:plain


5, PandasではなくNumpyを用いる

5-1, 新しいカラムを追加する時

Pandasデータにカラムを追加する際、pandas.locよりnumpy.whereの方が早いです。

例えば、下記データフレームで「VALUE」が100超なら「FLAG」を立てる場合の速度を比較します。

f:id:Cream-Kuchen:20210222160448p:plain

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

f:id:Cream-Kuchen:20210222161901p:plain


5-2, 欠損値を埋める時

Pandasデータの欠損値を埋める際、pandas.fillnaよりnumpy.nan_to_numの方が早いです。

例えば、下記データフレームの欠損値をゼロ埋めする場合の速度を比較します。

f:id:Cream-Kuchen:20210222162316p:plain

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

f:id:Cream-Kuchen:20210222163115p:plain


5-3, 外れ値を丸める時

Pandasデータの外れ値に対し、閾値を指定し丸める際、pandas.maskよりnumpy.whereの方が早いです。

例えば、下記データの各カラムでレコードが上下限5%を超える時、その閾値に丸める場合の速度を比較します。

f:id:Cream-Kuchen:20210222163918p:plain

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)

f:id:Cream-Kuchen:20210222164515p:plain


5-4, レコードを追加する時

Pandasデータに新しいレコードを追加する際、pandas.appendよりnumpy.appendの方が早いです。

例えば、下記1行のデータを100行複製する場合の速度を比較します。

f:id:Cream-Kuchen:20210222165128p:plain

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)

f:id:Cream-Kuchen:20210222165821p:plain


おわりに

Python, 特にPandasのデータ処理高速化やメモリ負荷削減について考えました。

ビッグデータの処理速度やメモリ容量に悩んでいる方などに、参考にしてもらえれば幸いです。