StatsFragments

Python, R, Rust, 統計, 機械学習とか

Python pandas パフォーマンス維持のための 3 つの TIPS

pandas でそこそこ大きいデータを扱う場合、その処理速度が気になってくる。公式ドキュメントではパフォーマンス向上のために CythonNumba を使う方法を記載している。

Enhancing Performance — pandas 0.16.2 documentation

が、軽く試したいだけなのに わざわざ CythonNumba を使うのは手間だし、かといってあまりに遅いのも嫌だ。そんなとき、pandas 本来のパフォーマンスをできるだけ維持するためのポイントを整理したい。

pandas に限らず、パフォーマンス改善の際にはボトルネックの箇所によってとるべき対策は異なる。pandas では速度向上/エッジケース処理のために データの型や条件によって内部で処理を細かく分けており、常にこうすれば速くなる! という方法を出すのは難しい。以下はこの前提のうえで、内部実装からみて まあほとんどの場合あてはまるだろう、、、という内容をまとめる。

環境構築

環境は EC2 の c4.xlarge インスタンス上に作成する。パフォーマンスを重視する場合、環境構築の時点で以下を行っておく。

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()

f:id:sinhrks:20150711222824p:plain

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 については最近以下の書籍が出ていた。翻訳はわからないが、原著はわかりやすく要点がまとまっていたのでおすすめ。

Cython ―Cとの融合によるPythonの高速化

Cython ―Cとの融合によるPythonの高速化

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 点の処理を避けるとよい。

  1. 行に対するループ / DataFrame.apply は 使わない
  2. object 型は使わない
  3. ユニークでない / ソートされていない index は使わない

上記を行った上で処理速度に不満がある場合は CythonNumba で高速化する。もしくは、Dask を使って並列化を考える。

9/24 追記 Dask についてはこちらを。

sinhrks.hatenablog.com

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理