luggage baggage

Machine learning, data analysis, web technologies and things around me.

Python 機械学習コードを Cython でラップして C/C++ から使う

お久しぶりです。吉田弁二郎です。

Cython という便利なトランスパイラ(言語と言語の中間にある言語のようなもの)があります。Python ライクな文法で書ける Cython スクリプトは C/C++ コードに変換・コンパイルされた後に Python から呼び出し可能で、C/C++ ライブラリを Cython スクリプトから呼び出すことも難しくないので、Python ライブラリの高速化作業を効率良くおこなうためによく使われています。

著名なプロジェクトで言うと scikit-learn や pandas だったり、最近利用が広がり始めた cupy などで Cython が使われています。scikit-learn の例で言うと、例えばツリー系のアルゴリズムを実装したこのスクリプトは Cython によるものですね。

また逆の方向として、Python で書いたライブラリを C/C++ コードから手軽に呼び出したい、という時にも Cython を使うことができます。双方向のラッパーとして機能するのですね。今回の記事では、この方向で Cython を使うための簡単な例を紹介していきます。Cython の文法については詳しくは書かないので、ドキュメントもしくはデモコードなどを見ていただけるとよいと思います。

動作確認環境

  • Amazon Linux (Linux version 4.14.26-46.32.amzn1.x86_64 (mockbuild@gobi-build-60009) (gcc version 7.2.1 20170915 (Red Hat 7.2.1-2) (GCC)) #1 SMP Fri Mar 30 22:29:54 UTC 2018)
  • gcc (GCC) 4.8.5 20150623 (Red Hat 4.8.5-28)
  • Python 2.7.14
  • Cython 0.28.2
  • numpy 1.14.3
  • scikit-learn 0.19.1

Python 関数の Cython スクリプトからの呼び出し

以下のような簡単な機械学習スクリプト src.py があるとしましょう。2次元正規分布に従う2つのデータ群を生成し、ランダムフォレストモデルの学習、および予測値を使った混合行列の計算をするものです。

# src.py

from __future__ import print_function
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix


def gen_data(n_samples, test_size=.3):
    print("[src.py] gen_data")
    mean0 = [0., 0.]
    cov0 = [[1., .5],
            [.5, 1.]]
    data0 = np.random.multivariate_normal(mean0, cov0, n_samples)
    class0 = [0] * n_samples
    mean1 = [2., 2.]
    cov1 = [[1., .5],
            [.5, 1.]]
    data1 = np.random.multivariate_normal(mean1, cov1, n_samples)
    class1 = [1] * n_samples
    data = np.vstack([data0, data1])
    labels = np.hstack([class0, class1])
    return train_test_split(data, labels, test_size=test_size)


def train(x_train, y_train):
    print("[src.py] train")
    clf = RandomForestClassifier()
    clf.fit(x_train, y_train)
    return clf


def evaluate(clf, x_test, y_test):
    print("[src.py] evaluate")
    pred = clf.predict(x_test)
    confmat = confusion_matrix(y_test, pred)
    return confmat

そして、C の側からは int 型の n_samples を受け取り、上の Python コードを経由して混合行列を計算した後に、返り値として double 型の正解率を出すような Cython スクリプト cybridge.pyxを作成します。

# cybridge.pyx

from __future__ import print_function
from src import gen_data, train, evaluate


# generate the header file with public keyword
cdef public double calc_accuracy(int n_samples):
    print("[cybridge.pyx] calc_accuracy")
    x_train, x_test, y_train, y_test = gen_data(n_samples)
    clf = train(x_train, y_train)
    confmat = evaluate(clf, x_test, y_test)
    tn, fp, fn, tp = confmat.ravel()
    return float(tn + tp) / confmat.ravel().sum()

ぱっと見た感じではほとんど Python コードと変わらないですよね。冒頭では src モジュールから各関数を Python の意味で import しています。Cython では Python のオブジェクトをいつも通りに import することができます。

また、C コードからこの関数を呼び出せるようにするため、cdef public キーワードをつけています。public をつけるとこの Cython スクリプトに対応するヘッダーファイルが生成されるようになるため、忘れずに追加しましょう。基本的なコードの準備はここまでになります。

共有ライブラリのビルド

さて、Cython スクリプトをビルドして、共有ライブラリを作ります。そのためには setup.py を書くと拡張性が担保でき良いです(書かずにビルドする方針もあります)。

# setup.py

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


extensions = [
    Extension("libcybridge", ["cybridge.pyx"])
]

setup(
    ext_modules=cythonize(extensions)
)

Extension で拡張モジュールの属性を指定しているのですが、後の都合上、名前を libcybridge としました。python setup.py build_ext -i とすれば、ヘッダーファイル cybridge.h と共有ライブラリ libcybridge.so が生成されます。次は、これをリンクする C コードを書いていきましょう。

C コードの用意

libcybridge.so を呼んで Python 関数を動かすためには、C コードの中で Python インタープリタを初期化したりすることが必要です。

// main.c

#include "Python.h"
#include "cybridge.h"
#include <stdio.h>


int main() {
    printf("[main.c] start\n");

    int n_samples = 200;
    double accuracy;

    printf("[main.c] initialize python interpreter\n");
    Py_Initialize();

    printf("[main.c] initialize libcybridge\n");
    PyRun_SimpleString("import sys\nsys.path.insert(0, '')");
    initlibcybridge();

    accuracy = calc_accuracy(n_samples);
    Py_Finalize();

    printf("[main.c] accuracy: %f\n", accuracy);
    return 0;
}

ポイントがいくつかあります。

まず、ヘッダーファイルを読む順番。先に Python.h を入れないと cybridge.h を読む際にエラーが出てしまうと思います。

また、今回は sklearn など Python オブジェクトの操作を必要とするライブラリを動かすため、Py_Initialize() してインタープリタを使える状態にしておかないといけません。さらに、Python ライブラリのサーチパスにカレントディレクトリを追加しないと src.py が見つからず実行時にエラーが出るため、PyRun_SimpleString で明示的に追加する作業をしています。

C コードのコンパイルと実行

gcc $(python-config --cflags) $(python-config --ldflags) -L. -lcybridge -Wl,-rpath,. main.c

と実行すると a.out が生成され、一連の処理が実行可能になります。python-config を使うと Python ヘッダーの場所が自動的にわかったりするので便利ですね。ほかのオプションはライブラリのサーチパスを調整するためのものです。

さて、これを実行して次のような表示が出てきたら成功です!

[main.c] start
[main.c] initialize python interpreter
[main.c] initialize libcybridge
[cybridge.pyx] calc_accuracy
[src.py] gen_data
[src.py] train
[src.py] evaluate
[main.c] accuracy: 0.841667

この時、同時に src.pyc も作られているはずです。

この記事の背景

以前、Python で書いてある ML コードに対して C++ で書いた関数の出力を与えたい、みたいな状況に業務上なったことがありました。Python コードを別言語で書き直すのはあまりにも面倒だし、さしあたり実行速度にも不満はないといった状況だったので何か楽ができないかと思っていたのですね。で、Cython を使って C/C++ コードをラップして Python で使う話は結構豊富に見つかるのですが、その逆をやる方法については意外とまとまった情報が出てこなかったので本記事を書いてみました。今回の作業によって機械学習の実行自体が特に速くなるわけではない(というか特に機械学習に限った手法ではない)です。

実用上は、例えば Cython 関数に numpy.array を渡すとか、Cython 側で書いたクラスを C/C++ 側から見たいとかいったこともあると思うので、これについても後日記事追加してみます。