洋食の日記

洋食のことではなく、技術メモを書きます。たまにどうでも良いことも書きます。

PyCallを使えばRubyでもKerasでDeep Learningができる

mrknさんが開発しているPyCallを使うと、RubyからPythonオブジェクトを操作できる。 Rubyから、Python機械学習・統計分析のツールを利用することを目的としており、 ネット上にもnumpyやscikit-learnを実行する例があがっている。

Rubyist Magazine - PyCall があれば Ruby で機械学習ができる

このPyCallで、Kerasを叩くことができれば、RubyでもDeep Learningできると思い試してみた。 まずはインストールから。

$ gem install --pre pycall

ちなみに、実行環境をまとめると、Ruby 2.4.0、PyCall 0.1.0.alpha.20170317、Python 3.6.0、Theano 0.9.0、Keras 2.0.2である。 試したのは、MNISTの手書き数字画像を、畳み込みニューラルネットで認識するサンプルコードである。

keras/mnist_cnn.py at master · fchollet/keras · GitHub

これを、細かいところは適宜はぶきながら、Ruby+PyCallで移植すると次のようになる。

require 'pycall/import'
include PyCall::Import

# Kerasの必要なものをimportする.
pyimport 'keras'
pyfrom 'keras.datasets', import: 'mnist'
pyfrom 'keras.models', import: 'Sequential'
pyfrom 'keras.layers', import: ['Dense', 'Dropout', 'Flatten']
pyfrom 'keras.layers', import: ['Conv2D', 'MaxPooling2D']

# MNISTは28x28の大きさの手書き数字画像で、各画像が10個のクラスにわけられている.
nb_classes = 10
img_rows = 28
img_cols = 28

# MNISTデータセットを読み込む.初回実行時はダウンロードするところから始まる.
(x_train, y_train), (x_test, y_test) = mnist.load_data.()

# データのreshapeは、元のコードでは...
#   pyfrom 'keras', import: 'backend'
#   backend.image_data_format.()
# の結果で処理を分けている.
# 試してみたところ "channels_last" だったので、
# そちらの処理を移植した.
x_train = x_train.reshape.(x_train.shape[0], img_rows, img_cols, 1)
x_test = x_test.reshape.(x_test.shape[0], img_rows, img_cols, 1)
# 型をfloat32にして、要素を[0.0,1.0]にする.
x_train = x_train.astype.('float32')
x_test = x_test.astype.('float32')
x_train /= 255
x_test /= 255

# ラベル情報をクラスベクトル形式にする.
y_train = keras.utils.to_categorical.(y_train, nb_classes)
y_test = keras.utils.to_categorical.(y_test, nb_classes)

# ネットワークを定義する.
model = Sequential.()
model.add.(Conv2D.(32, kernel_size: [3, 3], activation: 'relu', 
                   input_shape: [img_rows, img_cols, 1]))
model.add.(Conv2D.(64, kernel_size: [3, 3], activation: 'relu'))
model.add.(MaxPooling2D.(pool_size: [2, 2]))
model.add.(Dropout.(0.25))
model.add.(Flatten.())
model.add.(Dense.(128, activation: 'relu'))
model.add.(Dropout.(0.5))
model.add.(Dense.(nb_classes, activation: 'softmax'))

# ネットワークをコンパイルする.初回実行時はそれなりに時間がかかる.
model.compile.(loss: keras.losses.categorical_crossentropy,
              optimizer: keras.optimizers.Adadelta.(),
              metrics: ['accuracy'])

# ネットワークを学習する.
model.fit.(x_train, y_train,
          batch_size: 128,
          epochs: 10,
          verbose: 1,
          validation_data: [x_test, y_test])

# 分類性能を評価する.
score = model.evaluate.(x_test, y_test, verbose: 0)
print(sprintf("Test loss: %.6f\n", score[0]))
print(sprintf("Test accuracy: %.6f\n", score[1]))

これを実行すると、問題なくネットワークの学習が動き始める!

$ ruby keras_test.rb
Using Theano backend.
Using cuDNN version 5005 on context None
Mapped name None to device cuda: GeForce GTX 1080 (0000:06:00.0)
Train on 60000 samples, validate on 10000 samples
Epoch 1/10
60000/60000 [==============================] - 27s - loss: 0.3227 - acc: 0.9030 - val_loss: 0.0730 - val_acc: 0.9760
...
Epoch 10/10
60000/60000 [==============================] - 26s - loss: 0.0410 - acc: 0.9881 - val_loss: 0.0298 - val_acc: 0.9890
Test loss: 0.029782
Test accuracy: 0.989000

Kerasは、ブロックをつなげる感じで、簡単にネットワーク構造を定義できる。 Pythonに詳しくないRubyエンジニアが、Deep Learningを試してみるには、PyCall+Kerasが最高な気がする。 また、Pythonで学習したネットワークを、Ruby+PyCallで読み込んで使うという形にすれば、 容易にDeep Learningな機能をRailsアプリに組み込める、という感じで夢が広がる。

scikit-learnで近似最近傍探索したいときはLSHForestがある

scikit-learnでは、ver. 0.16から近似最近傍探索手法のLSHForestが実装されている。LSHForestは、ハッシングによる近似最近傍探索の代表的な手法であるLocality Sensitive Hashing(LSH)をベースにした、木構造の近似最近傍探索手法である。LSHは特徴ベクトルをRandom Projectionと閾値処理で0と1の短いバイナリベクトルに変換し、これをキーとしてハッシュテーブルを作る。LSHForestでは、LSHによって得られれたバイナリベクトルから木構造(あるビットが0か1で二分木が作れる、これをLSHTreeと呼ぶ)を複数個つくる。検索クエリが与えられると、それぞれのLSHTreeから候補を割り出して、それら候補を距離で並べることで検索結果を得る。ちなみに、コサイン距離(1-コサイン類似度)による検索しか行えない。この点では、FLANNの方が柔軟である。 それでは、検索インデックスを作成するスクリプトは、次のようになる。これをgen_idx.pyとする。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from sklearn.datasets import load_svmlight_file
from sklearn.neighbors import LSHForest
from sklearn.externals import joblib

def main():
  # MNIST(手書き数字画像)データセットを読み込む。
  # ※検索対象の例として使う。
  targets, _ = load_svmlight_file('mnist.scale')

  # LSHはアルゴリズム中でRandom Projectionを用いるので、
  # 再現性を確保したい場合は、random_stateに何か与えると良い。
  search_idx = LSHForest(random_state=1984)
  
  # 検索インデックスを作成する。
  search_idx.fit(targets)

  # 検索インデックスを保存する。
  joblib.dump(search_idx, 'lshtree.pkl.cmp', compress=True)

if __name__ == '__main__':
  main()

検索インデックスを読み込み、検索するスクリプトは次のようになる。これをsearch.pyとする。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from sklearn.datasets import load_svmlight_file
from sklearn.neighbors import LSHForest
from sklearn.externals import joblib

def main():
  # 検索クエリとして、MNISTのテストデータを読み込む。
  queries, q_labels = load_svmlight_file('mnist.scale.t')

  # 検索対象データのラベルを読み込む。
  # ※あとで検索結果を確認したいためで、検索には必要ない。
  _, t_labels = load_svmlight_file('mnist.scale')
  
  # 検索インデックスを読み込む。
  search_idx = joblib.load('lshtree.pkl.cmp')

  # 各クエリの5-近傍を検索する。
  dists, ids = search_idx.kneighbors(queries, n_neighbors=5)

  # 検索クエリの一番目のデータで、検索結果を確認する。
  print(q_labels[0])
  print(t_labels[ids[0,:]])
  print(ids[0,:])
  print(dists[0,:])

if __name__ == '__main__':
  main()

実行してみると、検索クエリと同じ「7」のMNISTデータが検索できているのがわかる。

$ ./gen_idx.py
$ ./search.py
7.0
[ 7.  7.  7.  7.  7.]
[15260 16186 14563  9724 31073]
[ 0.08516171  0.09843745  0.10159849  0.10445509  0.10882645]

LSHForestにも、LSHTreeの数などのパラメータがあるが、論文中で書かれている値であったりと、デフォルトで問題なさそう。 内部のLSHとしては、32ビットのバイナリベクトルに変換する様子。 FLANNと違って、検索時に、元の検索対象データを必要としないのが良い。 ちなみに、検索インデックスに新たにデータを加えるときは、partial_fitメソッドを使う。

Pythonで近似最近傍探索を試したいときはpyflannがちょうど良い

近似最近傍探索とは近似的に近いものを検索してくる技術で、普通に距離を計算して並べて近くにあるものを探すより速い。代表的なライブラリにFLANN(Fast Library for Approximate Nearest Neighbors)があり、これのPythonバインディングがpyflannになる。FLANNの開発は2013年から止まっているのに(もともとブリティッシュコロンビア大の研究がベースなので研究プロジェクトが一段落したんだと思われる)、pyflannは今でも開発されているのがおもしろい。FLANN自体は、Debian GNU/Linuxとかでもパッケージになってて、pyflannもpipにあるのでインストールは楽ちん。枯れ具合がちょうど良い。

$ sudo apt-get insatll libflann-dev
$ sudo pip install pyflann

では、まず、インデックスを作成する。これをgen_idx.pyとする。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pyflann
from sklearn.datasets import load_svmlight_file

def main():
  # とりあえず検索対象のデータとしてMNISTのデータを使う。
  # MNISTのデータはLIBSVM Dataのページからダウンロードできる。
  targets, _ = load_svmlight_file('mnist.scale')
  targets = targets.toarray()

  # 距離を設定する。一般的なユークリッド距離(euclidean)の他に、
  # マンハッタン距離(manhattan)とか
  # ヒストグラムインターセクションカーネル(hik)とかがある。
  pyflann.set_distance_type('euclidean')

  # 検索インデックスを作成する。
  # algorithmはHierarchical K-Means Clustering Treeを選択した。
  # 他にもRandomized KD-Treeなどがある。
  # centers_initはK-Meansの初期値の設定方法を指定する(デフォルトはランダム)。
  # 再現性を確保したい場合にはrandom_seedを指定すると良い。
  search_idx = pyflann.FLANN()
  params = search_idx.build_index(targets, algorithm='kmeans', 
    centers_init='kmeanspp', random_seed=1984)
  
  # 検索インデックスのパラメータを見てみる。
  print(params)

  # 作成した検索インデックスを保存する。
  search_idx.save_index('mnist.idx')

if __name__ == '__main__':
  main()

これを実行すると、検索インデックスが作成される。

$ ./gen_idx.py
{'branching': 32, 'cb_index': 0.5, 'centers_init': 'default', 'log_level': 'warning', 'algorithm': 'kmeans', 
...
'target_precision': 0.8999999761581421, 'sample_fraction': 0.10000000149011612, 'iterations': 5, 'random_seed': 1984, 
'checks': 32}

作成した検索インデックスを読み込んで検索を行う。これをsearch.pyとする。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import pyflann
from sklearn.datasets import load_svmlight_file

def main():
  # 検索対象となるMNISTデータを読み込む.
  targets, t_labels = load_svmlight_file('mnist.scale')
  targets = targets.toarray()

  # 検索クエリとなるMNISTデータを読み込む.
  queries, q_labels = load_svmlight_file('mnist.scale.t')
  queries = queries.toarray()

  # 距離を設定する。
  pyflann.set_distance_type('euclidean')

  # 作成した検索インデックスを読み込む。
  search_idx = pyflann.FLANN()
  search_idx.load_index('mnist.idx', targets)

  # 近似最近傍探索を行う。ここでは5-近傍を探索している。
  result, dists = search_idx.nn_index(queries, num_neighbors=5)

  # 結果を表示する。1番目の検索クエリのラベルと、その5-近傍のラベルと距離を見てみる。
  print(q_labels[0])
  print(t_labels[result[0,:]])
  print(result[0,:])
  print(dists[0,:])

if __name__ == '__main__':
  main()

実行してみると、検索クエリと同じ「7」のMNISTデータが検索できているのがわかる。

$ ./search.py
7.0
[ 7.  7.  7.  7.  7.]
[53843 38620 16186 27059 30502]
[  7.0398414    9.69496007  11.4449976   11.49352929  14.02158225]

他にも細かくパラメータを設定できる。 詳細は、FLANNの公式ページのユーザマニュアルを見ると良い。 MATLABバインディングの説明が参考になる。 FLANNの検索では、(当然だけど)検索インデックスの他に検索対象データも必要で、 検索対象データが高次元かつ大規模であると、検索対象データ自体がメモリに乗るかという問題が発生する。 対策は計算機環境によって色々考えられるだろうけど、どうしても検索対象データ自体を小さくしたい場合は、 Locality Sensitive Hashingに代表されるような、ハッシングによる近似最近傍探索を検討すると良い。

scikit-learnで学習した分類器をjoblib.dumpで保存するときはcompressをTrueにするとファイルが一つにまとまって便利

scikit-learnで学習した分類器を保存する場合、joblib.dumpを使用するが、これだと、大量のnpyファイルが作られる。この場合、joblib.dumpのcompressを使うとよい。まず、例えば以下のような、train.pyがあるとする。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from sklearn.datasets import load_svmlight_file
from sklearn.multiclass import OneVsRestClassifier
from sklearn.svm import LinearSVC
from sklearn.externals import joblib

def main():
  # MNISTの訓練データセットを読み込む。
  tr_samples, tr_labels = load_svmlight_file('mnist.scale')

  # one-versus-restで線形SVM分類器を学習する。
  # n_jobsを-1にするとパラレルで実行してくれる。
  classifier = OneVsRestClassifier(LinearSVC(), n_jobs=-1)
  classifier.fit(tr_samples, tr_labels)

  # 学習した分類器を保存する。
  joblib.dump(classifier, 'svc.pkl')

if __name__ == '__main__':
  main()

これを実行すると、大量のnpyファイルが作られる。まあ美しくない。

$ ./train.py
$ ls -1
svc.pkl_01.npy
svc.pkl_02.npy
svc.pkl_03.npy
...
svc.pkl_51.npy
svc.pkl
train.py
mnist.scale
mnist.scale.t

学習した分類器を一つのファイルに保存したい場合は、joblib.dumpの部分でcompress引数にTrueを指定する。compressは0から9の整数をとり、値が大きくなるほど圧縮率が高まる。 Trueを指定するとcompress=3と同じになる。拡張子はなんでも良いけど、「圧縮しましたよ」ということで「.cmp」をつけてみた。

joblib.dump(classifier, 'svc.pkl.cmp', compress=True)

これを実行すると、学習した分類器が、たった一つのファイルにまとめられる。

$ ./train.py
$ ls -1
svc.pkl.cmp
train.py
mnist.scale
mnist.scale.t

読み込みは、圧縮しない場合と同様で、joblib.loadで良い。例えば以下のような、test.pyを用意する。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from sklearn.datasets import load_svmlight_file
from sklearn.externals import joblib
from sklearn.metrics import accuracy_score

def main():
  # MNISTのテストデータセットを読み込む。
  te_samples, te_labels = load_svmlight_file('mnist.scale.t')

  # 学習した分類器を読み込む。
  classifier = joblib.load('svc.pkl.cmp')

  # パラメータを表示してみる。
  print classifier

  # ラベルを推定する。
  pr_labels = classifier.predict(te_samples)

  # Accuracyを計算してみる。
  print accuracy_score(te_labels, pr_labels)

if __name__ == '__main__':
  main()

これを実行すると、one-versus-restな線形SVM分類器がちゃんと読み込まれているのがわかる。

$ ./test.py
OneVsRestClassifier(estimator=LinearSVC(C=1.0, class_weight=None, dual=True, fit_intercept=True,
     intercept_scaling=1, loss=l2, multi_class=ovr, penalty=l2,
     random_state=None, tol=0.0001, verbose=0),
          estimator__C=1.0, estimator__class_weight=None,
          estimator__dual=True, estimator__fit_intercept=True,
          estimator__intercept_scaling=1, estimator__loss=l2,
          estimator__multi_class=ovr, estimator__penalty=l2,
          estimator__random_state=None, estimator__tol=0.0001,
          estimator__verbose=0, n_jobs=-1)
0.918

圧縮されているので、ファイルサイズもそれなりに小さい。 libsvmやliblinearをコマンドで使うと、重みとかパラメータが書かれたテキストファイルが作られる。 MNISTぐらいだと大したことないけど、特徴ベクトルの次元数が大きいと、ファイルサイズも大きくなってツラい。 そういう意味でも良い。

$ liblinear-train -q -s 2 mnist.scale mnist.scale.model
$ ls -l mnist.scale.model | awk '{print $4"\t"$8}'
147K    mnist.scale.model
$ ls -l svc.pkl.cmp | awk '{print $4"\t"$8}'
55K     svc.pkl.cmp

NginxでUserDir的なことするのは設定ファイルにlocationを書くだけで良い

Apache2からNginxへの移行を進めていて、UserDir的なことをしようと思ったら、mod_userdirを有効にする感じではなかった。Debian GNU/Linuxであれば、/etc/nginx/sites-available/defaultを編集するだけでよい。PHPも動くようにしたいので、ついでにそれも設定する。

$ sudo vim /etc/nginx/sites-available/default
...省略

  # 最初からコメントで「PHP使うならindex.phpを追加しなさいよ」と書いてある。
  # Add index.php to the list if you are using PHP
  index index.php index.nginx-debian.html index.html index.htm;

...省略

  # for UserDir
  location ~ ^/~([^/]+)/(.+\.php)$ {
    alias /home/$1/public_html/$2;
    fastcgi_pass unix:/var/run/php5-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $request_filename;
  }
  location ~ ^/~(.+?)(/.*)?$ {
    alias /home/$1/public_html$2;
  }

...省略

基本的には、正規表現で拾ったものを、aliasに渡しているだけ。これで再起動して設定を反映すればおk。

$ sudo systemctl restart nginx

とっても簡単。本日も先人に感謝。

DebianでNginx上でPHPを動かすのはaptでPHP-FPMを入れて設定ファイルをちょっと編集するだけで良い

Nginxの設定ファイルの接しやすさに感動して、Apache2からNginxに完全移行することにした。 WebツールやデモシステムをPHPで作っていたので、とりあえず、PHPが動くようにする。 作業自体は大したことなくて、PHPFastCGI実装のPHP-FPM(FastCGI Process Manager)をインストールすれば良い。

$ sudo apt-get install php5-fpm

設定ファイルとかインストールした時点で良い感じになっている。/etc/php5/fpm/pool.d/www.confを見ると、

$ vim /etc/php5/fpm/pool.d/www.conf
...
listen = /var/run/php5-fpm.sock
...

socketとして/var/run/php5-fpm.sockがあるのがわかる。phpのリクエストがきたら、このsocketに渡せば良い。 これもnginxの設定ファイルにすでにコメントの形で書いてあって、その部分をコメントアウトすれば良い。

$ sudo vim /etc/nginx/sites-available/default
...
location ~ \.php$ {
  include snippets/fastcgi-php.conf;

  # With php-fpm (or other unix sockets):
  #fastcgi_pass unix:/var/run/php/php7.0-fpm.sock;
  fastcgi_pass unix:/var/run/php5-fpm.sock;
  # With php-cgi (or other tcp sockets):
  #fastcgi_pass 127.0.0.1:9000;
}

再起動して設定を反映させる。※PHP-FPMの設定ファイルを変更してないけど勢いで再起動しちゃった。

$ sudo systemctl restart php5-fpm.service
$ sudo systemctl restart nginx

以下の内容で、/var/www/html/hoge.phpとか置いてアクセスしてみると、Server APIがFPM/FastCGIになってたりして上手く動いているのがわかる。

<?php
phpinfo();

超簡単でまったくつまづくことがなかった。先人にマジ感謝。

TheanoなKerasをデプロイするときはNginx+uWSGI+Flaskが良さそう

Kerasで作った画像認識プログラムを、Webサービスの形にしてみようと思い色々ためした。 画像認識処理をAPIの形で立ち上げ、フロントから叩くことにした。 複雑で大規模な構造のAPIにはならないので、フレームワークにはFlaskを選択した。 はじめ、Apache2+mod_wsgiを考えたが、設定ファイルがなんだか面倒なのと、 APIを叩くたびに「Using Theano backend.」とかimportから始まるのでツラい(これも設定ファイルでなんとかなるのかもだけど掘り下げてない)。 KerasなのにTensorFlowじゃないのは、Kerasが出たての頃から使ってて~/.keras/keras.jsonがそんな設定になってるからで、こだわりではない。 そんなわけで、まずは、TheanoなKerasのFlaskアプリを用意する。ちなみに、OSはDebian GNU/Linuxです。

# -*- coding: utf-8 -*-

# 学習するわけではないのでCPUモードで起動する。
# 環境変数をプログラム中で変えることでKerasとTheanoを設定する。
import os
os.environ['KERAS_BACKEND'] = 'theano'
os.environ['THEANO_FLAGS'] = 'mode=FAST_RUN,device=cpu,floatX=float32'
# Kerasがらみ
from keras.applications.vgg16 import VGG16
from keras.preprocessing import image
from keras.applications.vgg16 import preprocess_input
from keras.models import Model
from keras.models import model_from_json
# Flaskがらみ
from flask import Flask

# 省略...(学習したモデルを読み込んだりとか)

app = Flask(__name__)
@app.route('/')
def index():

# 省略...(認識した結果をJSONで返したりとか)

if __name__ == '__main__':
  app.run()

Nginxの設定ファイルを書く。sites-available以下に書いて、sites-enabledからシンボリックリンクを張るのが、DebianなNginxのマナー。 5000番ポートをlistenしてるのは、Flask単体でrunしたとき、localhost:5000でサーバーが動くので、その名残り。意味はない。location内のほうが大事で、適当なsocket(/hoge/uwsgi.sock)を用意して、これを介してuwsgiにつなぐ。設定の楽さに感動。

$ sudo vim /etc/nginx/sites-available/uwsgi
server {
  listen 5000;
  listen [::]:5000;

  location / {
    include uwsgi_params;
    uwsgi_pass unix:/hoge/uwsgi.sock;
  }
}
$ cd /etc/nginx/sites-enabled
$ sudo ln -sn /etc/nginx/sites-available/uwsgi

そして、uwsgiでFlaskアプリを動かす。uwsgiの設定ファイルを用意すると、オプションの指定が楽になるんだろうけど、とりあえず試したいだけなので、コマンドで実行する。

# Flaskアプリのファイルはhoge.pyだとして...
$ /usr/local/bin/uwsgi -s /hoge/uwsgi.sock --wsgi hoge:app --chmod-socket=666
*** Starting uWSGI 2.0.14 (64bit) on [Sun Mar 12 15:20:11 2017] ***

...

*** Operational MODE: single process ***
Using Theano backend.
WSGI app 0 (mountpoint='') ready in 7 seconds on interpreter 0x1a2e1e0 pid: 10892 (default app)
*** uWSGI is running in multiple interpreter mode ***
spawned uWSGI worker 1 (and the only) (pid: 10892, cores: 1)

この状態で、APIをいくら叩いても「Using Theano backend.」とかならなかったので、とても良い。 Kerasでは~/.keras以下に設定ファイルや学習済みCNNモデルなどを置くので、デプロイするためのユーザーを用意したほうが良いかもしれない。 ちなみに、Apache2+mod_wsgiAPIを立ち上げたときは、/var/www/.kerasや/var/www/.theanoが作られた。