AWS Lambdaによるサーバーレスな機械学習APIの作り方

この記事は、Python Advent Calendar 2016の14日目の記事です。

## 概要

[NewsDigest](https://newsdigest.jp)では、配信するニュース記事のカテゴリを機械学習アルゴリズムによって分類しています。具体的には、１日約1000件の記事を「エンタメ」「政治」「スポーツ」といった10種類のカテゴリに分類しています。

[NewsDigest](https://newsdigest.jp)では、そのようなカテゴリ分類をサーバーモジュールに密結合で行うのではなく、社内に分類のための**汎用API**を設けています。

この汎用 API を実現するにあたって、よりスケーラブルにするために、サーバーレス(AWS Lambda)な機械学習 API を検討したので、その紹介というか、サーバーレス API を作るためのチュートリアルになります。

実際に動く API は https://3lxb3g0cx5.execute-api.us-east-1.amazonaws.com/prod/classify
 で、リポジトリは https://github.com/yamitzky/serverless-machine-learning です。

## 前提

今回実装する API は、次の前提のもとで作ります。

- 教師あり学習による分類 API。つまり、**学習ステージと分類(予測)ステージが存在**する
- **ビッグデータを対象としない**。したがって、Spark　などは使わず、 scikit-learn のみで実装する
- 先述したように、サーバーレスな API を作る

**機械学習に関しての説明や、形態素解析の方法、scikit learnの使い方などは省きます**

このチュートリアルでは、次のようなステップで進めます

- まずは、API関係なくミニマムな機械学習アルゴリズムのためのコードを書く
- bottle を使って、サーバーレスでない API 化する
- AWS Lambda にデプロイして、サーバーレス化する

## 1. 機械学習による分類を行うミニマム実装

まずは、API　化のことを考えずに、ミニマム実装を作ってみましょう。前提として、次のようなコーパスを用意してみます([Reutersコーパス](http://disi.unitn.it/moschitti/corpora.htm)からコーパスを作り、生成してみました)。

```
カテゴリ\t形態素解析 済み の 文章
money-fx\tu.k. money market given 120 mln stg late help london, march 17 - the bank of england said it provided the money market with late assistance of around 120 mln stg. this brings the bank's total help today to some 136 mln stg and compares with its forecast of a 400 mln stg shortage in the system.
grain\tu.s. export inspections, in thous bushels soybeans 20,349 wheat 14,070 corn 21,989 blah blah blah. &#3;
earn\tsanford corp <sanf> 1st qtr feb 28 net bellwood, ill., march 23 - shr 28 cts vs 13 cts net 1,898,000 vs 892,000 sales 16.8 mln vs 15.3 mln
...
```

Naive Bayesによるカテゴリ分類のミニマム実装は次のような感じでしょうか。

```py3
from gensim.corpora.dictionary import Dictionary
from gensim.matutils import corpus2csc
from sklearn.naive_bayes import MultinomialNB


def load_corpus(path):
    """コーパスをファイルから取得"""
    categories = []
    docs = []
    with open(path) as f:
        for line in f:
            category, line = line.split('\t')
            doc = line.strip().split(' ')
            categories.append(category)
            docs.append(doc)
    return categories, docs


def train_model(documents, categories):
    """モデルを学習する"""
    dictionary = Dictionary(documents)
    X = corpus2csc([dictionary.doc2bow(doc) for doc in documents]).T
    return MultinomialNB().fit(X, categories), dictionary


def predict(classifier, dictionary, document):
    """学習したモデルから、未知の文章のカテゴリを推定する"""
    X = corpus2csc([dictionary.doc2bow(document)], num_terms=len(dictionary)).T
    return classifier.predict(X)[0]


# モデルを学習する
categories, documents = load_corpus('corpus.txt')
classifier, dictionary = train_model(documents, categories)

# 学習したモデルでカテゴリ分類する
predict_sentence = 'a dollar of 115 yen or more at the market price of the trump market 4% growth after the latter half of next year'.split()  # NOQA
predict(classifier, dictionary, predict_sentence)  # money-fx
```

このミニマム実装は、

- コーパスからデータを読みとり、モデルを学習する
- 学習済みのモデルから、未知の文章をカテゴリ分類する

という点で、教師あり学習の最低限の機能を備えています。では、これを API 化してみましょう。

## 2. bottle を使ってシンプルな API を実装する

サーバーレスにする前に、シンプルな Web フレームワークである bottle を使って、カテゴリ分類を単純に API 化してみます。

```py
from bottle import route, run, request

def load_corpus(path):
    """コーパスをファイルから取得"""
def train_model(documents, categories):
    """学習用API"""
def predict(classifier, dictionary, document):
    """分類用API"""

@route('/classify')
def classify():
    categories, documents = load_corpus('corpus.txt')
    classifier, dictionary = train_model(documents, categories)
    sentence = request.params.sentence.split()
    return predict(classifier, dictionary, sentence)

run(host='localhost', port=8080)
```

この状態でcurlコマンドを叩くと、API の結果が返ってくると思います。

```
curl "http://localhost:8080/classify?sentence=a%20dollar%20of%20115%20yen%20or%20more%20at%20the%20market%20price%20of%20the%20trump%20market%204%%20growth%20after%20the%20latter%20half%20of%20next%20year"
# money-fx
```

しかし当然、**この実装には大きな問題があります**。分類エンドポイント(`/classify`)を叩いたときに学習と分類を同時に行うので、遅いということです。

一般的に、機械学習において、学習に時間がかかり、分類は短時間で終わります。そこで、学習のエンドポイントを切り出して、学習済みモデルを永続化してみます。

## 3. 学習用エンドポイントを作り、モデルを永続化する

今度は `/train` と `/classify`　の２つの API を用意してみました。モデルの永続化は[scikit learnの3.4. Model persistence](http://scikit-learn.org/stable/modules/model_persistence.html)の説明に従って、joblib で保存してみました。joblibを使うのがミソで、joblib を使ってモデルを圧縮すると、200MB ぐらいのものが 2MB とかに収まります(Lambda 化するにあたって、ファイル容量が制約になるためです)。

```py
from sklearn.externals import joblib
import os.path

from bottle import route, run, request

def load_corpus(path):
    """コーパスをファイルから取得"""
def train_model(documents, categories):
    """学習用API"""
def predict(classifier, dictionary, document):
    """分類用API"""

@route('/train')
def train():
    categories, documents = load_corpus('corpus.txt')
    classifier, dictionary = train_model(documents, categories)
    joblib.dump((classifier, dictionary), 'model.pkl', compress=9)
    return "trained"

@route('/classify')
def classify():
    if os.path.exists('model.pkl'):
        classifier, dictionary = joblib.load('model.pkl')
        sentence = request.params.sentence.split()
        return predict(classifier, dictionary, sentence)
    else:
        # 当該ファイルがなければ、学習済みでない
        return "model not trained. call `/train` endpoint"

run(host='localhost', port=8080)

```

この API　では、モデルを学習すると、学習済みモデルを `model.pkl` として永続化します。最初の段階だとモデルが学習されていないので、「model not trained」 と表示されます。

```
curl "http://localhost:8080/?sentence=a%20dollar%20of%20115%20yen%20or%20more%20at%20the%20market%20price%20of%20the%20trump%20market%204%%20growth%20after%20the%20latter%20half%20of%20next%20year"
# model not trained
```

学習をして、再度分類を行うと、APIが正常に分類してくれることが確認できると思います。

```
curl http://localhost:8080/train
# trained
curl "http://localhost:8080/classify?sentence=a%20dollar%20of%20115%20yen%20or%20more%20at%20the%20market%20price%20of%20the%20trump%20market%204%%20growth%20after%20the%20latter%20half%20of%20next%20year"
# money-fx
```

## 4. サーバーレス化する

ここからが本題です。bottle で作った API を、AWS Lambda にデプロイしていきます。

機械学習 API をサーバーレス化するにあたって、学習フェーズと分類フェーズを、次のように定義します。

- 学習フェーズ
    - Docker を使って、データセットのダウンロード、データセットの作成、学習、学習済みモデルファイルの保存を行う
    - コード＋学習済みモデルを zip 化し、AWS Lambda にデプロイする
- 分類フェーズ
    - API Gateway + AWS Lambda の組み合わせで、分類リクエストが来たら、学習済みモデルをロードし、分類する

## 4-1. 学習フェーズ： Dockerを使ってモデルのビルドをする

少々天下り的ですが、次のような Dockerfile を用意します。

```docker
# 機械学習関連のビルドが簡単な、anaconda(miniconda)をベースイメージに使う
FROM continuumio/miniconda

RUN mkdir -p /usr/src/app

WORKDIR /usr/src/app

# データセットのダウンロード
COPY download_corpus.sh /usr/src/app/
RUN sh download_corpus.sh

# 機械学習関連のライブラリのインストール
COPY conda-requirements.txt /usr/src/app/

RUN conda create -y -n deploy --file conda-requirements.txt
# 関連ライブラリは、/opt/conda/envs/deploy/lib/python2.7/site-packages に吐き出される

COPY . /usr/src/app/

# 学習し、モデルの吐き出しを行う
RUN python gen_corpus.py \
      && /bin/bash -c "source activate deploy && python train.py"

# デプロイするための成果物を用意する
# コードと、学習済みモデルと、実行に必要な so ファイルなどを詰め込む
RUN mkdir -p build/lib \
      && cp main.py model.pkl build/ \
      && cp -r /opt/conda/envs/deploy/lib/python2.7/site-packages/* build/ \
      && cp /opt/conda/envs/deploy/lib/libopenblas* /opt/conda/envs/deploy/lib/libgfortran* build/lib/
```

この Dockerfile をビルドすると、コードと、学習済みモデルと、実行に必要な so ファイルが詰め込まれた、Docker イメージができあがります。つまり、「モデルをビルドした」ということです。

Docker イメージから成果物を取り出し、Lambda にアップロードするための成果物を作るために、次のようなコマンドを実行します。

```bash
docker build -t serverless-ml .
# Docker イメージからの情報の取り出し
id=$(docker create serverless-ml)
docker cp $id:/usr/src/app/build ./build
docker rm -v $id
# サイズの削減rm build/**/*.pyc
rm -rf build/**/test
rm -rf build/**/tests
# 成果物のzip 化
cd build/ && zip -q -r -9 ../build.zip ./
```

これで、コードとモデルが詰め込まれた、zip ファイルが完成しました。

## 4-2. Lambda のデプロイ

これに関しては AWS Lambda の使い方なので、省略します。

[ステップ 2.3: Lambda 関数を作成し、手動でテストする](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/with-s3-example-upload-deployment-pkg.html#walkthrough-s3-events-adminuser-create-test-function-upload-zip-test-upload)が参考になるかと思います。

## 4-3. 分類 API を作る

サーバーレスな "API"　を作るために、Amazon API Gateway を使います。

これに関しても、API Gateway　の使い方なので、省略します。

[Lambda 関数を公開するための API を作成する](http://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/getting-started.html) が参考になるかと思います。

## 5. 完成！

Working example として、次のような API を用意してみました。

```
https://3lxb3g0cx5.execute-api.us-east-1.amazonaws.com/prod/classify
```

実際に curl で API を叩いてみます。

```
curl -X POST https://3lxb3g0cx5.execute-api.us-east-1.amazonaws.com/prod/classify -H "Content-type: application/json" -d '{"sentence": "a dollar of 115 yen or more at the market price of the trump market 4% growth after the latter half of next year"}'
```

無事、`money-fx` という分類結果が得られます。

## サーバーレス機械学習 API は使えるか？

結論から言うと、**なし**です。

理由としては、API が結果を返す時間が遅すぎるということです。先程の例で、5秒ぐらい時間がかかっています。5秒返す API っていうのは、まあ**なし**ですよね(苦笑)

レスポンスが遅い理由は明確で、ディスクに保存されている pkl ファイルをメモリにロードするのが時間がかかるためです。したがって、**モデルファイルが巨大な場合、サーバーレス機械学習 API はレスポンスが遅すぎる**　ということが言えると思います。

逆に、例えばモデルファイルのない、単純に `numpy` を使ったような API だとか、モデルファイルがとても軽量な場合などは、比較的使えるんじゃないかなと思います。