洋食の日記

「だ・である」調ではなく「です・ます」調で書きはじめれば良かったなと後悔してる人のブログです

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が作られた。