Cythonの導入とPandasでapplyする方法の調査

概要

CythonはPythonに型指定を書き足すことでコンパイルできるようにして、実行速度を何倍にも速くできるような言語。Pythonユーザーであればそれほど困ることなく使え、且つ実行速度の問題を解決できる、大変ありがたい言語である。

個人的にPandas使っている時に、ちょっとPythonのコードをapplyで噛ませると処理速度が急に遅くなってしまう問題を、Cythonで関数を記述することで解決したいというモチベーションの元に手を出した。

今回は導入からapplyに渡す方法の検討までを行う。

@CretedDate 2016/03/19
@Versions python 3.4, pandas0.17.1, cython0.23.4

インストール

pipでいける。

$ sudo pip install cython

簡易サンプル

実行手順は下記に載っている。

http://docs.cython.org/src/tutorial/cython_tutorial.html

引数n〜mまでの間にある素数を出力する適当なプログラムを書いたとする。

def print_prime_number(int n, int m):
  for i in range(n, m):
    for j in range(2, i):
      if i % j == 0:
        break
      if j == i - 1:
        print( i )

たいへん効率が悪い処理だけど答えはちゃんと出る(1も解に出てきてしまったりするけど)。尚、先ほどのリンク先にはエラトステネスのふるい的なちゃんとしたコードが掲載されている。

上記ソースコードを foo.pyx という名前で保存してcythonizeすると、コンパイルされたバイナリコードが生成される。

cythonizeする際には、下記のようなコードを実行する。名前は仮にsetup.pyとする。

from distutils.core import setup
from Cython.Build import cythonize

setup(
  ext_modules = cythonize("foo.pyx")
)

保存したら下記のように引数を付けてファイルを実行する。

$ python setup.py build_ext --inplace

実行すると下記のようなファイルが生成される。Python3.4で実行しているので34mが付いている。

foo.cpython-34m.so

バイナリファイルの中身を見てみる。

$ od -c foo.cpython-34m.so | head -c60
0000000 177   E   L   F 002 001 001  \0  \0  \0  \0  \0  \0

$ readelf -h foo.cpython-34m.so 
ELF ヘッダ:
  マジック:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  クラス:                            ELF64
  (以下略)

普通のELF。

じゃ、これをPythonから実行してみる。

import pyximport; pyximport.install()
import foo

# 10〜29までの間にある素数を表示する
foo.print_prime_number(10, 30)
  #=> 11
  #=> 13
  #=> 17
  #=> 19
  #=> 23
  #=> 29

ちゃんと出力できた。

実行速度の比較

Cythonを使うのは何故か。もちろんスピードが欲しいからだ。

ということでこの処理がPureなCPythonとCythonとで、どの程度差が出るか確認してみる。処理時間が十分に長くなるように、1千万〜1千万+1000までの間にある素数を出力する。

%time foo.print_prime_number(10000000, 10001000)
Python : 1m10.579s
Cython : 0m50.127s

あれ、意外と差がない。あー、そうか。cdefしてなかった。型が明記されてないとコンパイラの恩恵が得られない。

def print_prime_number(int n, int m):
  # iとjをint型に指定
  cdef int i, j
  for i in range(n, m):
    for j in range(2, m):
      if i % j == 0:
        break
      if j == i - 1:
        print( i )

これで実行してみる。

Python : 1m10.579s
Cython(cdefなし) : 0m50.127s
Cython(cdefあり) : 0m1.933s

桁違いに速くなった。およそ36.5倍。この速度が出るのがCythonの強み。

pandasのapplyで使う

pandasのapplyで速度改善が期待できるか確認する。

pandasでの利用は下記を参考にする。

http://pandas.pydata.org/pandas-docs/stable/enhancingperf.html

まずは普通にintを渡して素数なら1を、違ったら0を返すCythonのコードを書く。

cpdef int is_prime(int n):
  cdef int i
  for i in range(2, n):
    if n % i == 0:
      return 0
  return 1

で、2〜9までの数字に対して実行してみる

df = pd.DataFrame( pd.Series(range(2, 10)), columns=['n'] )
df['prime'] = df.n.apply(foo.is_prime)
print( df )

  #=>    n  prime
  #=> 0  2      1
  #=> 1  3      1
  #=> 2  4      0
  #=> 3  5      1
  #=> 4  6      0
  #=> 5  7      1
  #=> 6  8      0
  #=> 7  9      0

こういう1つ1つの処理が重い例では、applyしてもそこそこ速度改善が期待できる。

けど、軽い処理を大量に行う場合には、それ以外の部分でかかる処理時間が全体の多くを占めるようになる為、上記のような記述ではそれほど速度は上がらない。

pandasのapplyとはどういう挙動か

処理を考える前に、applyでどういったオーバーヘッドが考えられるか確認しておく。

applyはnp.ufunc(Universal functions)が渡された場合は、こんな風に扱っている。

if isinstance(f, np.ufunc):
    return f(self)

ufuncはnp.sum(ndarray)のような、NumPyのndarrayに対して使える関数。ufuncを作ってapplyすると速度が出そうだ。

次にufunc以外が渡された場合は、Cythonで書かれたループが呼ばれている。

# lib.map_inferというループ用の関数がある
mapped = lib.map_infer(values, f, convert=convert_dtype)
# lib.map_inferの中で、ループが行われている
# (もちろん下記で使われている変数は型指定がされている)
result = np.empty(n, dtype=object)
for i in range(n):
    val = f(util.get_value_at(arr, i))
    result[i] = val

Cythonのループで実行されているので、Pythonでループを回した場合よりは速くなる。

ただ、型変換とか関数呼び出しとかオーバーヘッドになる箇所は多々あるので、ufuncよりはだいぶ遅くなるはず。

ufunc(Universal Function)の作成

Cythonでnp.ufuncを自作してみる。下記のページを参考にする。

http://www.tp.umu.se/~nylen/pylect/advanced/advanced_numpy/index.html

注意点として、下記のコードは正直よくわからないままに書いている。参考にはならないかもしれない。とりあえず動くことは動く。

下記コードの簡単な説明。

やっていることは渡されたnumpyのintのarrayの各要素に対して1を足すというだけの処理。

序盤は必要なものをimportしている。ヘッダファイルを指定して、そこから必要な関数や型をctypedefなりcdefなりして呼び出せるようにしているらしい。

あとは入力される型と出力される型、ループ用の関数、applyする関数などを指定してPyUFunc_FromFuncAndDataを生成すれば良いらしい。

cdef extern from "numpy/arrayobject.h":
    void import_array()
    ctypedef int npy_intp
    cdef enum NPY_TYPES:
        NPY_INTP

cdef extern from "numpy/ufuncobject.h":
    void import_ufunc()
    ctypedef void (*PyUFuncGenericFunction)(char**, npy_intp*, npy_intp*, void*)
    object PyUFunc_FromFuncAndData(PyUFuncGenericFunction* func, void** data,
        char* types, int ntypes, int nin, int nout,
        int identity, char* name, char* doc, int c)
    void PyUFunc_D_D(char**, npy_intp*, npy_intp*, void*)

# numpyのarrayのimport
import_array()
# ufuncのimport
import_ufunc()

# ループ用の関数
cdef PyUFuncGenericFunction loop_func[1]
loop_func[0] = PyUFunc_D_D

# 入力値と出力値の型
cdef char input_output_types[2]
input_output_types[0] = NPY_INTP
input_output_types[1] = NPY_INTP

# applyする関数
cdef void *apply_func[1]
cdef void plus1_func(int *input1, int *output1):
    output1[0] = input1[0] + 1
apply_func[0] = plus1_func

# ufuncの作成(名前はplus1としておく)
plus1 = PyUFunc_FromFuncAndData(
    loop_func,  # ループ関数
    apply_func, # applyする関数
    input_output_types, # 入力値と出力値の型
    1,          # 入力値の型の数
    1,          # 入力値の数
    1,          # 出力値の数
    0,          # よくわからん
    "plus1",    # 関数名
    "plus1(x) -> computes x + 1", # ドキュメント用の文字列
    0           # よくわからん
)

あとはこのファイルを今まで通りcythonizeして、Pythoのコードから呼び出す。

import pyximport; pyximport.install()
import foo
import pandas as pd, numpy as np

# 0〜4までの値を持つカラムnを持つDataFrame
df = pd.DataFrame( pd.Series(range(0, 5)), columns=['n'] )

# 自作のufunc、foo.plus1を適用する
print( df.n.apply(foo.plus1) )
  #=> 0    1
  #=> 1    2
  #=> 2    3
  #=> 3    4
  #=> 4    5

出力の左側はSeriesのIndex、右側が値。ちゃんと1足された値が返っていることがわかる。

データ量を100万件にして処理時間を比較してみる。

比較対象として、cythonで+1する関数も用意しておく。

cpdef int plus1_2(int n):
  return n + 1
import pyximport; pyximport.install()
import foo
import pandas as pd, numpy as np

# 100万件入れてみる
df = pd.DataFrame( pd.Series(range(0, 1000000)), columns=['n'] )

# df.n + 1
%timeit df.n + 1
  #=> 100 loops, best of 3: 2.14 ms per loop

# 自作のufunc
%timeit df.n.apply(foo.plus1)
  #=> 100 loops, best of 3: 2.24 ms per loop

# 自作のcythonの関数
%timeit df.n.apply(foo.plus1_2)
  #=> 1 loop, best of 3: 214 ms per loop

# lambda
%timeit df.n.apply(lambda x: x + 1)
  #=> 1 loop, best of 3: 363 ms per loop

自作のufuncは df.n + 1 に対して0.1msec程度の遅れはあるものの、かなり近い数字を出せている。

それに対してapplyでcythonのコードを渡した場合は100倍近い時間が、lambdaでPythonのコードを渡した場合は150倍近い時間がかかっている。

ufuncが自作すればかなりの速度改善が期待できることがわかる。

ndarrayを引数に取って返す

ufuncは使う際は便利だが記述は若干面倒さがある。

ここはPandasの公式に書いてある流儀で、普通にndarrayを引数にとって返す処理を書いてみる。

http://pandas.pydata.org/pandas-docs/stable/enhancingperf.html

cimport numpy as np
import numpy as np
cpdef np.ndarray[long] plus1_3(np.ndarray[long] arr):
    cdef int i
    cdef np.ndarray[long] res = np.empty(n)
    for i in range(len(arr)):
        res[i] = arr[i] + 1
    return res

これで実行してみる。

%timeit foo.plus1_3( df.n.values )
  #=> 1000 loops, best of 3: 1.94 ms per loop

1.94msec。単純に df.n + 1 するよりも良い速度(ほぼ変わらない)が出ている。コード量も短いしわかりやすいので、これでも良いような気がしてきた。

文字列を引数に渡す

PandasではDataFrameに文字列を格納することも多い。そのあたりの処理もできるか確認してみる。

例として文字列の長さを返す処理と、upper caseを返す処理を書く。

cimport numpy as np
import numpy as np

# 文字列の長さを返す
cpdef np.ndarray[np.int] str_len(np.ndarray[unicode] arr):
    cdef int i
    cdef int n = len(arr)
    cdef np.ndarray[long] res = np.empty(n, dtype=long)
    for i in range(n):
        res[i] = len( arr[i] )
    return res

# 文字列のupper caseを返す
cpdef np.ndarray[object] upper(np.ndarray[unicode] arr):
    cdef int i
    cdef int n = len(arr)
    cdef np.ndarray[object] res = np.empty(n, dtype=object)
    for i in range(n):
        res[i] = arr[i].upper()
    return res

注意点として、Cythonではnp.strやnp.unicodeは使えないらしい。扱いたい時はuint8のarrayみたいなnativeで扱える型に直す必要がある。

Some data types are not yet supported, like boolean arrays and string arrays.(訳 : booleanとかstringの配列はサポートしてないよ)

http://cython.readthedocs.org/en/latest/src/tutorial/numpy.html

Cythonのコードの中でnp.ndarray[unicode]を生成したり代入したりすることはできないけど、今回の例のようにupperして中に入れるだけの場合は、np.ndarray[object]として箱を用意しておいてそこに詰めることでなんとかできるようだ。

unicodeとして中に入れようとすると下記のようなエラーが出たりする。

ValueError: Does not understand character buffer dtype format string ('w')

書いた処理を呼び出してみる。

import pyximport; pyximport.install()
import foo
import pandas as pd, numpy as np

# 文字列の入ったDataFrameを作る
df = pd.DataFrame( [['foo'], ['barbar'], ['buzbuzbuz'], ['日本語']], columns=['s'] )

# 文字列の長さ設定
df['length'] = foo.str_len( df.s.values )
df['s_upper'] = foo.upper( df.s.values )
print(df)
  #=>            s  length    s_upper
  #=> 0        foo       3        FOO
  #=> 1     barbar       6     BARBAR
  #=> 2  buzbuzbuz       9  BUZBUZBUZ
  #=> 3        日本語       3        日本語

ちゃんと動いているようだ。

速度がどの程度出るかも確認してみる。

10万行のランダムな文字列が入ったレコードを生成し、lambdaの場合と実行時間を比較してみる。

# 適当な文字列を生成してDataFrameに入れる
alpha = list('abcdefghijklmnopqrstuvwxyz')
s = [''.join(np.random.choice(alpha, int(np.random.random() * 10 + 1))) for i in range(100000)]
df = pd.DataFrame(np.array(s).T, columns=['s'])

# Cython版文字列の長さ取得
%time foo.str_len( df.s.values )
  #=> Wall time: 1.27 ms

# lambda版文字列の長さ取得
%time df.s.apply(lambda s: len(s))
  #=> Wall time: 28.1 ms

%time foo.upper( df.s.values )
  #=> Wall time: 6.48 ms

%time df.s.apply(lambda s: s.upper())
  #=> Wall time: 21.3 ms

文字列の長さは28.1ms → 1.27ms、upper caseは21.3ms → 6.48msと両者ともCythonにすることで改善している。ただupper()の呼び出しなどはあまりCythonが得意とするところではないので、速度改善の割合はだいぶ鈍くなっている。