Python pandas パフォーマンス維持のための 3 つの TIPS
pandas
でそこそこ大きいデータを扱う場合、その処理速度が気になってくる。公式ドキュメントではパフォーマンス向上のために Cython
や Numba
を使う方法を記載している。
Enhancing Performance — pandas 0.16.2 documentation
が、軽く試したいだけなのに わざわざ Cython
や Numba
を使うのは手間だし、かといってあまりに遅いのも嫌だ。そんなとき、pandas
本来のパフォーマンスをできるだけ維持するためのポイントを整理したい。
pandas
に限らず、パフォーマンス改善の際にはボトルネックの箇所によってとるべき対策は異なる。pandas
では速度向上/エッジケース処理のために データの型や条件によって内部で処理を細かく分けており、常にこうすれば速くなる! という方法を出すのは難しい。以下はこの前提のうえで、内部実装からみて まあほとんどの場合あてはまるだろう、、、という内容をまとめる。
環境構築
環境は EC2 の c4.xlarge インスタンス上に作成する。パフォーマンスを重視する場合、環境構築の時点で以下を行っておく。
numpy
を各種 数値計算ライブラリBLAS
,LAPACK
,ATLAS
とリンクさせる。numexpr
,bottleneck
をインストールする。
EC2 の Amazon Linux であれば以下のコマンドでできる。
$ sudo yum -y install blas-devel lapack-devel atlas-devel $ sudo python -m pip install numpy $ sudo python -m pip install numexpr bottleneck
環境構築が正しくできたかを確認する。以降の処理は IPython Notebook
上で行う。
import numpy as np import numpy.distutils.system_info as sysinfo sysinfo.get_info('blas') # {'libraries': ['blas'], 'library_dirs': ['/usr/lib64'], 'language': 'f77'} sysinfo.get_info('lapack') # {'libraries': ['lapack'], 'library_dirs': ['/usr/lib64'], 'language': 'f77'} sysinfo.get_info('atlas') # {'define_macros': [('ATLAS_INFO', '"\\"3.8.4\\""')], # 'include_dirs': ['/usr/include'], # 'language': 'f77', # 'libraries': ['lapack', 'f77blas', 'cblas', 'atlas'], # 'library_dirs': ['/usr/lib64/atlas']} import pandas as pd pd.show_versions() # ... # pandas: 0.16.2 # ... # bottleneck: 1.0.0 # ... # numexpr: 2.4.3 # ...
サンプルデータの作成
サンプルデータ作成には pandas
がテスト用に用意しているメソッドを使う。もっとも、似たようなデータが作れれば方法はなんでもいい。
pd.util.testing.rands_array
: 指定した文字数 / 要素数のランダムな文字列のnp.ndarray
を作成。pd.util.testing.choice
:np.ndarray
からsize
個をサンプリング。
# 2 文字 / 3 要素の array を作成 pd.util.testing.rands_array(2, 3) # array(['Xn', 'ZC', 'zj'], dtype=object) # array から 5 回サンプリング pd.util.testing.choice(np.array(['a', 'b']), size=5) # array(['a', 'b', 'b', 'b', 'a'], dtype='|S1')
サンプルデータとして 100 万レコード / 3 列のデータを用意した。
np.random.seed(0) # レコード数 N = 1000000 chars1 = pd.util.testing.rands_array(5, 100) chars2 = pd.util.testing.rands_array(5, 10000) df = pd.DataFrame({'x': np.random.randn(N), 'y': pd.util.testing.choice(chars1, size=N), 'z': pd.util.testing.choice(chars2, size=N)}) df.shape # (1000000, 3) df.head()
y
列は 100 通りの値を、z
列は 10000 通りの値をとる。
chars1[:10] # array(['SV1ad', '7dNjt', 'vYKxg', 'yym6b', 'MNxUy', 'rLzni', 'juZqZ', # 'fpVas', 'JyXZD', 'ttoNG'], dtype=object) len(np.unique(chars1)) # 100 len(np.unique(chars2)) # 10000
1. 行に対するループ / DataFrame.apply
は 使わない
pandas.DataFrame
は列ごとに異なる型を持つことができる。DataFrame
は内部的に 同じ型の列をまとめて np.ndarray
として保持している。列ごとに連続したデータを持つことになるため、そもそも行に対するループには向かない。また、DataFrame.iterrows
でのループの際には 異なる型を持つ列の値を Series
として取り出すため、そのインスタンス化にも時間がかかる。
また 行ごと / 列ごとに 関数を適用するメソッドに DataFrame.apply
があるが、このメソッドでは Python
の関数を繰り返し呼び出すためのコストがかかる。apply
は利便性を重視したメソッドのため、パフォーマンスを気にする場合は避けたほうがよい。
参考 Python pandas データのイテレーションと関数適用、pipe - StatsFragments
上記ふたつの処理の組み合わせである、各行への関数適用 DataFrame.apply(axis=1)
について処理時間を %timeit
で計測する。まずは 単純に y
列と z
列の値を文字列結合する場合。
def f1(s): return s['y'] + s['z'] %timeit df.apply(f1, axis=1) # 1 loops, best of 3: 13.7 s per loop
この処理は、Series
として列を取り出し ベクトルとして行うほうが格段に速い。
%timeit df['y'] + df['z'] # 10 loops, best of 3: 74.9 ms per loop
ただ、関数中に条件分岐を含む場合など、そのままベクトル化できない場合もある。x
列の値によって 結合の順序を変える例を考える。
def f2_1(s): if s['x'] > 0: return s['y'] + s['z'] else: return s['z'] + s['y'] %timeit df.apply(f2_1, axis=1) # 1 loops, best of 3: 16 s per loop
こういった場合は np.vectorize
で関数をベクトル化し、引数として各列を Series
(もしくは np.ndarray
) として渡したほうが apply
よりは速い。
def f2_2(x, y, z): if x > 0: return y + z else: return z + y %timeit pd.Series(np.vectorize(f2_2)(df['x'], df['y'], df['z']), index=df.index) # 1 loops, best of 3: 334 ms per loop
補足 numpy
のドキュメント に記載されているとおり、np.vectorize
もパフォーマンスを最重視した方法ではない。さらに速くしたい場合は個別にベクトル化した関数を用意する。参考として、Cython
で書いた場合の処理時間は以下となる。np.vectorize
の 4-5 倍は速いようだ。
%load_ext cython
%%cython import numpy as np from numpy cimport ndarray def f2_3(ndarray[double, ndim=1] x, ndarray[object, ndim=1] y, ndarray[object, ndim=1] z): cdef: int i, length = len(x) double xval object yval, zval ndarray[object, ndim=1] result = np.empty(length, dtype=object) for i in range(length): xval = x[i] yval = y[i] zval = z[i] if xval > 0: result[i] = yval + zval else: result[i] = zval + yval return result
%timeit pd.Series(f2_3(df['x'].values, df['y'].values, df['z'].values), index=df.index) # 10 loops, best of 3: 67.5 ms per loop
補足 Cython
については最近以下の書籍が出ていた。翻訳はわからないが、原著はわかりやすく要点がまとまっていたのでおすすめ。
- 作者: Kurt W. Smith,中田秀基,長尾高弘
- 出版社/メーカー: オライリージャパン
- 発売日: 2015/06/19
- メディア: 大型本
- この商品を含むブログ (3件) を見る
2. object
型は使わない
pandas
には、グループ化 ( DataFrame.groupby
) や 結合 ( DataFrame.merge
) など、データの値をキーにして行われる処理がある。こういった操作を行う場合は object
型を避けたほうが速くなることが多い。
pandas
では文字列を含む列は object
型になることに注意する。numpy
の文字列型とならない理由は簡単にいうと欠損値 ( NaN
) の処理のため。例えば文字列で保存された商品コードをキーにしてグループ化 / 結合する場合など、そのままでは object
型として処理されてしまう。
df.dtypes # x float64 # y object # z object # dtype: object
object
型の列をキーとしてグループ化 / 集約したときの処理時間は以下。
%timeit df.groupby('y').mean() # 10 loops, best of 3: 52.3 ms per loop
処理速度をあげるためには、キーとなる値を カテゴリ型 ( pd.Categorical
) に変換しておくとよい。これは R でいう factor
にあたる型。
df['y'] = df['y'].astype('category') %timeit df.groupby('y').mean() # 100 loops, best of 3: 6.89 ms per loop
カテゴリ型では、カテゴリのラベル (pd.Categorical.categories
) と ラベルの位置 (pd.Categorical.codes
) を分けて扱う。内部処理は int
型で保存されたラベルの位置について行われるため、object
に対する処理と比較すると速くなる。
# カテゴリ型の内部データを表示 df['y'].values # [ewJ6t, JaFfE, ttoNG, F2Sy9, OopuJ, ..., dS9oG, juZqZ, 5gvFn, 9brWJ, 9fxRG] # Length: 1000000 # Categories (100, object): [0dmK0, 1DdJN, 1IZE1, 1m5Qu, ..., x7c5I, ydsVd, yym6b, zYGBj] # カテゴリのラベル df['y'].cat.categories # Index([u'0dmK0', u'1DdJN', u'1IZE1', u'1m5Qu', u'219tH', u'2aMtU', u'403al', # ... # u'yym6b', u'zYGBj'], # dtype='object') # カテゴリのラベルの位置 df['y'].cat.codes.head() # 0 66 # 1 30 # 2 90 # 3 23 # 4 38 # dtype: int8
列がとりうる値の数が増えた場合も、カテゴリ型にしたほうが速い。
# object 型 %timeit df.groupby('z').mean() # 10 loops, best of 3: 78.4 ms per loop # カテゴリ型 df['z'] = df['z'].astype('category') %timeit df.groupby('z').mean() # 100 loops, best of 3: 17.9 ms per loop
列の型 dtype
以外に処理に影響を与えうるのは、欠損値 ( NaN
) の有無 (無いほうが速い)、object
型内での型の混在 (文字列と数値が混ざっている) など。
3. ユニークでない / ソートされていない index
は使わない
最後。index
についても、上のとおり object
型は避ける。また、特に理由がない場合 値を ユニークにし、かつソートしておく。上記のサンプルデータ作成時点では、特に index
を指定していないため int
型の index
が昇順で振られている。
df.index # Int64Index([ 0, 1, 2, 3, 4, 5, 6, 7, # 8, 9, # ... # 999990, 999991, 999992, 999993, 999994, 999995, 999996, 999997, # 999998, 999999], # dtype='int64', length=1000000)
この index
で 結合 ( DataFrame.join
) した場合の処理時間は以下 (自身との結合のため意味のない処理だが)。
%timeit df.join(df, rsuffix='right_') # 10 loops, best of 3: 70.6 ms per loop
このとき、 DataFrame
に対して前処理 (スライス / サンプリングなど) を行うと、処理に応じて index
も並びかわる。例えば サンプリング ( DataFrame.sample
) を行った場合、
df2 = df.sample(n=len(df)) df2.shape # (1000000, 3) df2.index # Int64Index([372149, 955220, 320961, 244572, 254656, 74192, 143279, 246307, # 764579, 96091, # ... # 827030, 681861, 492455, 894210, 758153, 327280, 245717, 952466, # 26440, 532620], # dtype='int64', length=1000000)
このような DataFrame
で結合を行うと、上と比較して処理時間が長くなっていることがわかる。
%timeit df2.join(df, rsuffix='right_') # 1 loops, best of 3: 185 ms per loop
index
の値がユニークかどうか / ソートされているかは 以下のプロパティで確認できる。このプロパティによって一部の内部処理が分かれる。
# ユニークかどうか df.index.is_unique # True # 昇順でソートされているか df.index.is_monotonic_increasing # True df2.index.is_unique # True df2.index.is_monotonic_increasing # False
まとめ
pandas
にその本来のパフォーマンスを発揮させるためには、以下 3 点の処理を避けるとよい。
- 行に対するループ /
DataFrame.apply
は 使わない object
型は使わない- ユニークでない / ソートされていない
index
は使わない
上記を行った上で処理速度に不満がある場合は Cython
や Numba
で高速化する。もしくは、Dask
を使って並列化を考える。
9/24 追記 Dask
についてはこちらを。
Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理
- 作者: Wes McKinney,小林儀匡,鈴木宏尚,瀬戸山雅人,滝口開資,野上大介
- 出版社/メーカー: オライリージャパン
- 発売日: 2013/12/26
- メディア: 大型本
- この商品を含むブログ (10件) を見る