Ruby on Railsで作ったWEBアプリとPythonで作った機械学習モデルを連携するには？

Ruby勉強してるけど、やっぱ流行りのAI導入したいので、その辺調べました。

## 背景
**Ruby on Rails**でWEBアプリの作り方をいろんな方から教わった。
**Python**で機械学習もいろんな方から教わった。
そうなると当然、**機械学習を利用したWEBアプリ**を作りたくなる。

でも言語が異なる2つのシステムをどうやって連携するのか？
素人の私が思いついた方法は、

**Pythonで作った機械学習エンジンをAPI化させ、Ruby on RailsのWEBアプリからJSON経由で呼び出す**

という結論に至った。
（多分他にいろいろ方法はあるんだろうが、私は現状これしか実現できない。。）

その学びの過程をこの記事に書いていこうと思う。

## 全体の流れ

少々長くなりそうなので、全体の流れを先に紹介です。

1. 開発環境と事前準備
2. Flaskを使ってみる
3. FlaskでAPIを作ってみる
4. APIなので認証機能をつけてみる
5. 簡単な学習モデルを作る
6. 学習モデルをAPIに乗せてみる
7. HerokuでAPIを公開
8. Ruby on Railsのシステムから呼び出す

ながいなぁ。

## 0. 私の状態

私の状態としては、

- 一応、**Ruby on Rails**で簡単なWEBアプリは公開できる
- **Python**をつかって、機械学習の簡単なチュートリアルくらいならなんとかなる
- **API**とか認証とかサーバとかそういうのは、敬遠しがち

です。
なので、私と似ている方は参考になるかもしれません。

## 1. 開発環境と事前準備

事前準備としては、

- Herokuの登録 https://jp.heroku.com/
- VirtualBoxのインストール http://www.oracle.com/technetwork/server-storage/virtualbox/downloads/index.html?ssSourceSiteId=otnjp
- Vagrantのインストール https://www.vagrantup.com/
- Windowsな方はGitBashのインストール https://gitforwindows.org/
- **Python**と**Ruby on Rails**の開発環境

かなと思います。
一応開発環境を一致させるために、**Vagranfile**を載せておきます。

(`Python開発環境のVagrantfile`)

```rb:Vagrantfile
# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/xenial64"
  config.vm.network :"forwarded_port", guest: 5000, host: 5000
  config.vm.synced_folder "~/python_vagrant/workspace", "/home/ubuntu/workspace", :create => true, mount_options: ['dmode=777','fmode=755']
  config.vm.provision "shell", privileged: false, inline: <<-SHELL
    sudo apt-get -y upgrade
    sudo apt-get -y update

    # install essentials
    sudo apt-get install -y make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev libncursesw5-dev libpng-dev
    
    # get pyenv and set path
    git clone https://github.com/yyuu/pyenv.git ~/.pyenv
    echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.profile
    echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.profile
    echo 'eval "$(pyenv init -)"' >> ~/.profile
    . /home/ubuntu/.profile

    # install anaconda
    pyenv install anaconda3-5.0.1
    pyenv rehash
    pyenv global anaconda3-5.0.1

    # install Heroku CLI
    curl https://cli-assets.heroku.com/install-ubuntu.sh | sh
    
    # pip install 
    pip install --upgrade pip
    pip install flask-httpauth
    pip install gunicorn
  SHELL
end
```

(`Ruby開発環境のVagrantfile`)

```rb:Vagrantfile
# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/xenial64"
  config.vm.network :"forwarded_port", guest: 3000, host: 3000
  config.vm.synced_folder "~/workspace", "/home/ubuntu/workspace", :create => true, mount_options: ['dmode=777','fmode=755']
  config.vm.provision "shell", privileged: false, inline: <<-SHELL

    sudo apt-get -y upgrade
    sudo apt-get -y update

    # install essentials
    sudo apt-get install git curl g++ make vim nodejs libreadline-dev libssl-dev zlib1g-dev imagemagick libmagickcore-dev libmagickwand-dev -y
    sudo apt-get remove ruby -y
    
    # get rbenv and set path
    git clone git://github.com/rbenv/rbenv.git /home/ubuntu/.rbenv
    echo 'export PATH="/home/ubuntu/.rbenv/bin:$PATH"' >> ~/.profile
    echo 'eval "$(rbenv init -)"' >> ~/.profile
    . /home/ubuntu/.profile

    # install ruby-build
    mkdir -p ~/.rbenv/plugins
    git clone git://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build

    # install ruby
    rbenv install 2.3.0
    rbenv global 2.3.0
    rbenv rehash
    sudo apt-get install ruby-railties -y

    # install bundle
    gem install bundler --no-document

    # install postgresql
    sudo apt-get install postgresql postgresql-contrib python-psycopg2 libpq-dev -y
  SHELL
end
```

## 2. Flaskを使ってみる

いろいろ調べているとどうやら**Flask**という**Python**のフレームワークで簡単に**API**を作ることができるらしい。

なのでPython開発環境で[ウェブアプリケーションフレームワーク Flask を使ってみる](https://qiita.com/ynakayama/items/2cc0b1d3cf1a2da612e4)を参考にFlaskで遊んでみた。

とても分かりやすい記事で、スムーズに進めることができた。

**Ruby on Rails**を知っている人が**Flask**で抑えるべき点は以下のような部分です。

- `@app.route()`でルーティングとHTTPメソッドを指定
- `@app.route()`直下にRailsのコントローラ的なものが続く
- `return render_template()`と`redirect()`で画面遷移をコントロール

これくらい知っておけば十分かなと思います。

## 3. FlaskでAPIを作ってみる

では目的である**API**を**Flask**で作っていきます。
以下の動画を参考にしました。

https://www.youtube.com/watch?v=CjYKrbq8BCw
https://www.youtube.com/watch?v=qH--M56OsUg
https://www.youtube.com/watch?v=2gunLuqHvc8
https://www.youtube.com/watch?v=I64c1L_Zl2Y

この動画の通りやれば、すんなり**API**が構築できますが、1点のみ注意が必要です。
動画内では最下部が以下のようになっていますが、

```py
# 省略
if __name__ == '__main__':
    app.run(debug = True, port = 8080) 
```

以下のように修正してください。
どのIPからもcurlでリクエストを送れるようにしておくためです。

```py
# 省略
if __name__ == '__main__':
    app.debug = True
    app.run(host = '0.0.0.0')
```


最終的に完成したコードを下記に載せます。

```crud.py
from flask import Flask, jsonify, request

app = Flask(__name__)

languages = [{'name' : 'java'}, {'name' : 'php'}, {'name' : 'ruby'}]

@app.route("/", methods = ['GET'])
def test():
    return jsonify({'message' : 'it is works.'})

@app.route("/lang", methods = ['GET'])
def returnAll():
    return jsonify({'langages' : languages})

@app.route("/lang/<string:name>", methods = ['GET'])
def returnOne(name):
    langs = [language for language in languages if language['name'] == name]
    return jsonify({'langages' : langs[0]})

@app.route("/lang", methods = ['POST'])
def addOne():
    language = {'name' : request.json['name']}
    languages.append(language)
    return jsonify({'langages' : languages})

@app.route("/lang/<string:name>", methods = ['PUT'])
def editOne(name):
    langs = [language for language in languages if language['name'] == name]
    langs[0]['name'] = request.json['name']
    return jsonify({'langages' : langs[0]})

@app.route("/lang/<string:name>", methods = ['DELETE'])
def removeOne(name):
    langs = [language for language in languages if language['name'] == name]
    languages.remove(langs[0])
    return jsonify({'langages' : languages})

if __name__ == '__main__':
    app.debug = True
    app.run(host = '0.0.0.0')
```

## 4. APIなので認証機能をつけてみる

**API**のサービスというと**認証**や**Token**で制限されているのがほとんどです。
なので、認証機能をつけたくなります。

一番簡単そうなBasic認証をつけてみます。

[Flask-HTTPAuth](https://flask-httpauth.readthedocs.io/en/latest/)を参考にしました。

最終的には以下のようなコードになります。

```crud.py
from flask import Flask, jsonify, request
from flask_httpauth import HTTPBasicAuth # ←追記

app = Flask(__name__)
auth = HTTPBasicAuth() # ←追記

users = { # ←追記
    "john": "hello", # ←追記
    "susan": "bye" # ←追記
} # ←追記

@auth.get_password # ←追記
def get_pw(username): # ←追記
    if username in users: # ←追記
        return users.get(username) # ←追記
    return None # ←追記

languages = [{'name' : 'java'}, {'name' : 'php'}, {'name' : 'ruby'}]

@app.route("/", methods = ['GET'])
@auth.login_required # ←追記
def test():
    return jsonify({'message' : "Hello, %s!" % auth.username()})

@app.route("/lang", methods = ['GET'])
@auth.login_required # ←追記
def returnAll():
    return jsonify({'langages' : languages})

@app.route("/lang/<string:name>", methods = ['GET'])
@auth.login_required # ←追記
def returnOne(name):
    langs = [language for language in languages if language['name'] == name]
    return jsonify({'langages' : langs[0]})

@app.route("/lang", methods = ['POST'])
@auth.login_required # ←追記
def addOne():
    language = {'name' : request.json['name']}
    languages.append(language)
    return jsonify({'langages' : languages})

@app.route("/lang/<string:name>", methods = ['PUT'])
@auth.login_required # ←追記
def editOne(name):
    langs = [language for language in languages if language['name'] == name]
    langs[0]['name'] = request.json['name']
    return jsonify({'langages' : langs[0]})

@app.route("/lang/<string:name>", methods = ['DELETE'])
@auth.login_required # ←追記
def removeOne(name):
    langs = [language for language in languages if language['name'] == name]
    languages.remove(langs[0])
    return jsonify({'langages' : languages})

if __name__ == '__main__':
    app.debug = True
    app.run(host = '0.0.0.0')
```

すると、以下のcurlコマンドではレスポンスが返りますが、

```console
$ curl --basic -u john:hello -X GET http://localhost:5000/
```

以下のcurlコマンドではレスポンスがエラーが返ります。

```console
$ curl -X GET http://localhost:5000/
```

これで**API**への**Basic認証**がつきました。

## 5. 簡単な学習モデルを作る

では次に、機械学習のモデルを作成しましょう。
[機械学習で作成したモデルをREST APIとしてdeployする[python]](http://datanerd.hateblo.jp/entry/2017/09/01/212021)を参考にしました。

```learning.py
from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
import pandas as pd

iris = datasets.load_iris()
X = pd.DataFrame(iris.data, columns=["sepal_length", "sepal_width", "petal_length", "petal_width"])
y = iris.target

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.20, random_state=0)

clf = LogisticRegression()
clf.fit(X_train, y_train)

from sklearn.externals import joblib
joblib.dump(clf, 'iris_logreg.pkl')
joblib.dump(["sepal_length", "sepal_width", "petal_length", "petal_width"], 'iris_logreg_cols.pkl')

send_data = [{'petal_length': 4.5,
  'petal_width': 1.5,
  'sepal_length': 6.0,
  'sepal_width': 2.8999999999999999}]

print(clf.predict(send_data))
```

`python learning.py`を実行した時の出力は以下になります。

```
array([1])
```

`learning.py`の最終行の`joblib.dump(["sepal_length", "sepal_width", "petal_length", "petal_width"], 'iris_logreg_cols.pkl')`はカラムを固定するための措置です。

**API**を通じて**JSON**や**python**や**Ruby**に変換しながらでデータをやり取りする際、順番が入れ替わらないように、カラム順を指定しておくのです。

また`python learning.py`を実行した時に`iris_logreg.pkl`と`iris_logreg_cols.pkl`が生成されます。
`iris_logreg.pkl`がモデルです。
`iris_logreg_cols.pkl`がカラム順指定ファイルです。

## 6. 学習モデルをAPIに乗せてみる

つぎは作成したモデルを**API**に乗せます！
引き続き[機械学習で作成したモデルをREST APIとしてdeployする[python]](http://datanerd.hateblo.jp/entry/2017/09/01/212021)を参考にします。
同じディレクトリ内に以下のファイルを作成します。

```main.py
from sklearn.externals import joblib
from flask import Flask, jsonify, request
import pandas as pd
from sklearn import datasets
from flask_httpauth import HTTPBasicAuth # ←追記

app = Flask(__name__)
auth = HTTPBasicAuth()

users = {
    "john": "hello",
    "susan": "bye"
}

@auth.get_password
def get_pw(username):
    if username in users:
        return users.get(username)
    return None

@app.route('/predict/<string:clf_file>', methods=['POST'])
@auth.login_required
def predict(clf_file):
    clf = joblib.load("{}.pkl".format(clf_file))
    data = request.json
    query = pd.DataFrame(data)
    cols = joblib.load("{}_cols.pkl".format(clf_file))
    query = query[cols]
    prediction = clf.predict(query)
    return jsonify({'prediction':prediction.tolist()})


if __name__ == '__main__':
    app.debug = True
    app.run(host = '0.0.0.0')
```

`main.py`の以下の部分は、`iris_logreg_cols.pkl`で固定したカラム順にデータを成形しなおす処理に当たります。

```main.py
# 省略
    query = pd.DataFrame(data)
    cols = joblib.load("{}_cols.pkl".format(clf_file))
    query = query[cols]
# 省略
```

`python main.py`を実行したのち、以下のコマンドを実行します。

```console
$ curl --basic -u john:hello -X POST -H 'Content-Type:application/json' -d "{'petal_length': 4.5, 'petal_width': 1.5, 'sepal_length': 6.0, 'sepal_width': 2.8999999999999999}" http://localhost:5000/predict/iris_logreg
```

すると、以下のレスポンスが返ります。

```js
{
  "prediction": [
    1
  ]
}
```

`learning.py`の出力だった`array([1])`と一致していますね。

これが学習モデルの**API化**です。

## 7. HerokuでAPIを公開

ローカルでばっかりやってても面白くないですね。
では先ほどの**学習API**をHerokuで公開します。

さらに以下のように`Procfile`と`requirements.txt`を作成します。

```Procfile
web: gunicorn main:app --log-file -
```

`Procfile`は`main.py`をHeroku上で起動させる命令を行っています。

```requirements.txt
Flask==0.12.2
gunicorn==19.8.1
Flask-HTTPAuth==3.2.3
scikit-learn==0.18.1
pandas==0.19.2
```

`requirements.txt`はHeroku上に追加でインストールさせるライブラリを指定しています。

これで最終的に同じディレクトリに`learning.py`と`iris_logreg.pkl`と`iris_logreg_cols.pkl`と`Procfile`と`requirements.txt`がある状態になっているかと思います。

これらをまとめてHerokuにpushします。
すると以下のcurlコマンドで、

```console
$ curl --basic -u john:hello -X POST -H 'Content-Type:application/json' -d "{'petal_length': 4.5, 'petal_width': 1.5, 'sepal_length': 6.0, 'sepal_width': 2.8999999999999999}" https://xxx.herokuapp.com/predict/iris_logreg
```

以下のレスポンスが返ってきます。

```js
{
  "prediction": [
    1
  ]
}
```

これで**学習API**の公開が完了です。

## 8. Ruby on Railsのシステムから呼び出す

最後は別に独立した**Ruby on Rails**のシステムから公開されている**学習API**にリクエストを送り、レスポンスを受け取るようにします。

```console
$ curl --basic -u john:hello -X POST -H 'Content-Type:application/json' -d "{'petal_length': 4.5, 'petal_width': 1.5, 'sepal_length': 6.0, 'sepal_width': 2.8999999999999999}" https://xxx.herokuapp.com/predict/iris_logreg
```

上記のcurlコマンドは**Ruby**でどのように表現するのでしょうか。
そんな時に役立つのが[curl-to-ruby](https://jhawthorn.github.io/curl-to-ruby/)です。

このサイトはcurlコマンドを**Ruby**コードに変換してくれます。
では早速上記curlコマンドを**Ruby**に変換してみましょう。

```rb
require 'net/http'
require 'uri'

uri = URI.parse("https://xxx.herokuapp.com/predict/iris_logreg")
request = Net::HTTP::Post.new(uri)
request.basic_auth("john", "hello")
request.content_type = "application/json"
request.body = "{'petal_length': 4.5, 'petal_width': 1.5, 'sepal_length': 6.0, 'sepal_width': 2.8999999999999999}"

req_options = {
  use_ssl: uri.scheme == "https",
}

response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
  http.request(request)
end

# response.code
# response.body
```

この`response.body`で得られる値が、`{"prediction": 1}`となります。
上記コードを既存の**Ruby on Rails**ソースに落とし込めば、**Ruby on Rails**と**Python製学習API**の連携が完了となります。

## 最後に
**API**最強やん！って思いました。
あと、**API**の認証は本当は**Token**で行いたかったのですが、難しくて**Basic認証**にしました。
ひょっとして、機械学習より認証系や決済系の方が難しいかも！