ABEJA Platform + LINE Botで機械学習アプリをつくる

この記事は [ABEJA Advent Calendar 2019](https://qiita.com/advent-calendar/2019/abeja) の 2 日目の記事です。

昨年の [ABEJA Platform Advent Calendar](https://qiita.com/advent-calendar/2018/abejaplatform) では「[ABEJA Platform の認証についてまとめる](https://qiita.com/ishikawa@github/items/150a0705d9581c1000c6)」と題して、ABEJA Platform における API 呼び出しの認証について紹介しましたが、今年も ABEJA Platform ネタで書いてみました。

なお、まことに遺憾ながら ABEJA Platform は [Elixir](https://elixir-lang.org/) に対応していないため、コードはすべて Python で書いてあります。

## 概要

2019 年 7 月に投稿された、[@yushin_n](https://qiita.com/yushin_n) さんによる「[ABEJA Platform + Cloud Functions + LINE Botで機械学習アプリをつくる](https://qiita.com/yushin_n/items/0d115efa705579a53cfe)」では、

- ABEJA Platform
- Google Cloud Functions
- LINE Bot

を組み合わせて、サーバーレスな機械学習アプリをつくるのがテーマでした。

今回は、この機械学習アプリを改良して、**Google Cloud Functions を使わずに ABEJA Platform のみで LINE Bot を開発する**手順について紹介します。

そのために、この記事では ABEJA Platform の以下の 3 つの機能を使います。

### 新しいコンテナイメージ

ABEJA Platform では大きく分けて [18.10](https://developers.abeja.io/function/model/model-handler/) と [19.x 系](https://developers.abeja.io/function/model/model-handler-19.04/)の二種類のイメージを提供しています。19.x 系では、より新しいライブラリやフレームワークがインストールされているだけでなく、機械学習 API の実装方法が刷新され、より柔軟な処理を実装できるようになっています。

### 推論テンプレート

ABEJA Platform ではいくつかの機械学習タスクについて、学習と推論のテンプレートを提供しています。このテンプレートを使うことで、一行もコードを記述することなく機械学習モデルの学習と推論ができるだけでなく、コードを改変することで実装したいビジネスドメイン（今回は画像分類の LINE bot）に適したものに改良することができます。

### API エンドポイントの認証方式

デプロイされた API の認証方式を選択可能です。組み込みのユーザー認証と API キーによる認証を選択可能なだけでなく、認証自体をオフにして独自の処理を実装することができます。LINE bot の実装では署名の検証による認証を実装するために、この機能を使います。



## システム構成

- LINE Bot に画像を送信する
- LINE Messaging API からの HTTP リクエスト（webhook）を、ABEJA Platform の HTTP サービスで受け取る
- リクエストから画像データを取得し、推論を実行結果から画像のクラスの予測結果を取得する
- 予測結果を LINE に返す

![ABEJA_Platform_LINEbot_Arch.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/e23e0d79-4157-ca53-352c-b1f3dd251dee.png)

## 推論コードの実装

「[ABEJA Platformのテンプレートを使用して、ノンプログラミングで機械学習モデルを学習する](https://qiita.com/yushin_n/items/6852ad042913617d8892)」で、すでにネットワークの学習は完了し、結果のパラメータが ABEJA Platform の「モデル」として保存されているものとします。

<img width="1306" alt="スクリーンショット 2019-12-01 18.18.38.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/419408a2-6611-5ca9-5e15-41f88e6f8049.png">

### 最初のバージョンをテンプレートから作成

推論のコードをゼロから自分で書きたくはないので、ABEJA Platform の推論テンプレートのコードを改変することにします。推論のテンプレートでは、

- 画像分類の推論
- 推論結果を JSON で返す

処理が実装されているので、ここに LINE bot 特有の処理（詳細は後述）を追加すればいいはずです。

推論テンプレートのコードを生成するためには、コード（とデプロイされたサービス）を管理する容れ物となる「デプロイメント」を作成する必要があります。[デプロイメント一覧画面](https://console.abeja.io/deployments)の「デプロイメント作成」ボタンから新規デプロイメントを作成します。

<img width="756" alt="スクリーンショット 2019-12-01 18.28.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/a13bc151-0c1b-ea24-4a65-b903fed026fc.png">

新しく作成されたデプロイメントは、一覧では「0 モデルバージョン」となっているはずです。このリンクからコードの管理画面に移動します。

<img width="1117" alt="スクリーンショット 2019-12-01 18.33.00.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/f5df4cd0-dd00-98bb-2791-69530ca76b4d.png">

コードの管理画面では、このデプロイメントに属するコードをバージョン管理できます。早速、右上の「バージョン作成」から新規コードバージョンを作成します。

<img width="1373" alt="スクリーンショット 2019-12-01 18.33.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/da47c729-25b1-26a1-f9e5-0170641cfbcf.png">

今回はテンプレートのコードを改変したいので、タブから「テンプレート」を選択し、「Image classification (CPU)」を選びます。

<img width="745" alt="スクリーンショット 2019-12-01 18.33.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/40aed93b-5a16-9ba3-316d-b147de71a156.png">

新しく作成されたコードバージョン「0.0.1」です。リンクをクリックして個別画面に移動します。

<img width="1137" alt="スクリーンショット 2019-12-01 18.34.10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/9ae9a809-a1eb-c0d4-e821-6f21c1d2b331.png">

コードバージョンの個別画面では「ダウンロード」リンクからソースコードの zip をダウンロードできます。

<img width="1126" alt="スクリーンショット 2019-12-01 18.34.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/8ed1e767-fa2e-cfaf-b8ca-57a37007b1eb.png">

### テンプレートのコードを改良する

ダウンロードした zip ファイルを解凍すると、以下のようなディレクトリ構造になっているはずです。

```bash
$ ls -l 
total 96
-rw-r--r--@ 1 user  staff   1068 10 30 01:31 LICENSE
-rw-r--r--@ 1 user  staff   4452 10 30 01:31 README.md
-rw-r--r--@ 1 user  staff   1909 10 30 01:31 predict.py
-rw-r--r--@ 1 user  staff  12823 10 30 01:31 preprocessor.py
-rw-r--r--@ 1 user  staff     82 10 30 01:31 requirements-local.txt
-rw-r--r--@ 1 user  staff     25 10 30 01:31 requirements.txt
-rw-r--r--@ 1 user  staff   4406 10 30 01:31 train.py
drwxr-xr-x@ 6 user  staff    192 10 30 01:31 utils
```

[元記事](https://qiita.com/yushin_n/items/0d115efa705579a53cfe)を参考に、必要なライブラリを `requirements.txt` に追加します。

```txt
line-bot-sdk
googletrans
...
```

そして、`predict.py` が推論処理を実装したファイルですが、ここに LINE bot に必要な処理である、

1. ヘッダーで送られてくる署名の検証
2. LINE メッセージから画像データの取得
3. 結果を LINE で返信

を追加実装してやる必要があります。

まずは、これらの実装が完了した `predict.py` を載せます。リクエストのエントリーポイントとなる `handler` 関数を修正しています。

```python
import os
import io
import linebot
import linebot.exceptions
import linebot.models
import googletrans

from keras.models import load_model
import numpy as np
from PIL import Image

from preprocessor import preprocessor
from utils import set_categories, IMG_ROWS, IMG_COLS


# Initialize model
model = load_model(os.path.join(os.environ.get(
    'ABEJA_TRAINING_RESULT_DIR', '.'), 'model.h5'))
_, index2label = set_categories(os.environ.get(
    'TRAINING_JOB_DATASET_IDS', '').split())

# (1) Get channel_secret and channel_access_token from your environment variable
channel_secret = os.environ['LINE_CHANNEL_SECRET']
channel_access_token = os.environ['LINE_CHANNEL_ACCESS_TOKEN']

line_bot_api = linebot.LineBotApi(channel_access_token)
parser = linebot.WebhookParser(channel_secret)


def decode_predictions(result):
    result_with_labels = [{"label": index2label[i],
                           "probability": score} for i, score in enumerate(result)]
    return sorted(result_with_labels, key=lambda x: x['probability'], reverse=True)


def handler(request, context):
    headers = request['headers']
    body = request.read().decode('utf-8')

    # (2) get X-Line-Signature header value
    signature = next(h['values'][0]
                     for h in headers if h['key'] == 'x-line-signature')

    try:
        # parse webhook body
        events = parser.parse(body, signature)
        for event in events:
            # initialize reply message
            text = ''

            # if message is TextMessage, then ask for image
            if event.message.type == 'text':
                text = '画像を送ってください！'

            # (3) if message is ImageMessage, then predict
            if event.message.type == 'image':
                message_id = event.message.id
                message_content = line_bot_api.get_message_content(message_id)

                img_io = io.BytesIO(message_content.content)
                img = Image.open(img_io)
                img = img.resize((IMG_ROWS, IMG_COLS))

                x = preprocessor(img)
                x = np.expand_dims(x, axis=0)

                result = model.predict(x)[0]
                sorted_result = decode_predictions(result.tolist())

                # translate english label to japanese
                label_en = sorted_result[0]['label']
                translator = googletrans.Translator()
                label_ja = translator.translate(label_en.lower(), dest='ja')

                prob = sorted_result[0]['probability']

                # set reply message
                text = f'{int(prob*100)}%の確率で、{label_ja.text}です！'

            line_bot_api.reply_message(
                event.reply_token,
                linebot.models.TextSendMessage(text=text))

    except linebot.exceptions.InvalidSignatureError:
        raise context.exceptions.ModelError('Invalid signature')

    return {
        'status_code': 200,
        'content_type': 'text/plain; charset=utf8',
        'content': 'OK'
    }
```

## コードの解説

LINE bot 実装に関連した部分にコメントで番号を振ってあります。順を追って見ていきましょう。ここで解説している以外のコードは推論テンプレートおよび元記事そのままです。

#### 1. LINE bot SDK の初期化

```python
# (1) Get channel_secret and channel_access_token from your environment variable
channel_secret = os.environ['LINE_CHANNEL_SECRET']
channel_access_token = os.environ['LINE_CHANNEL_ACCESS_TOKEN']

line_bot_api = linebot.LineBotApi(channel_access_token)
parser = linebot.WebhookParser(channel_secret)
```

ここでは、LINE bot SDK を使って、API クライアントとメッセージの Parser を初期化しています。初期化に必要なパラメータ（秘密鍵とアクセストークン）は、環境変数で渡される想定です。

#### 2. 署名の検証

```python
# (2) get X-Line-Signature header value
signature = next(h['values'][0]
                 for h in headers if h['key'] == 'x-line-signature')

try:
  # parse webhook body
  events = parser.parse(body, signature)
```

HTTP のリクエスト・ヘッダーで渡される署名 `X-Line-Signature` を SDK で検証します。HTTP のリクエスト・ヘッダーは handler 関数に渡される `request` dict に格納されています。

#### 3. メッセージで送られてきた画像を取得

```python
# (3) if message is ImageMessage, then predict
if event.message.type == 'image':
  message_id = event.message.id
  message_content = line_bot_api.get_message_content(message_id)

  img_io = io.BytesIO(message_content.content)
  img = Image.open(img_io)
  img = img.resize((IMG_ROWS, IMG_COLS))
```

メッセージの内容を取得し、PIL の Image オブジェクトに変換します。

## 機械学習モデルのデプロイ

では、出来上がったソースコードを zip に圧縮して、新しくコードバージョンを作りましょう。

<img width="356" alt="スクリーンショット 2019-12-01 19.10.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/eb6d4258-696c-8186-af47-d3d45a56e002.png">

### 新しいコードバージョン

さきほどのコード管理画面から新しくコードバージョン作成画面を表示し、zip をアップロードします。このとき、ランタイム（コンテナイメージ）を「**abeja-inc/all-cpu:19.10**」とし、必要な環境変数も設定します。

<img width="751" alt="スクリーンショット_2019-12-01_17_44_11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/639bdbbd-c1cb-0d00-e5c9-a18648e042fb.png">

### API のデプロイ

API のデプロイは「[ABEJA Platformのテンプレートを使用して、ノンプログラミングで機械学習モデルをデプロイする](https://qiita.com/yushin_n/items/fb338ca9bd3c685ad691)」で解説されている通りなので繰り返しません。

ただ、最初に説明したとおり、LINE bot からのリクエストを「認証なし」で通すために、新しいエンドポイントを作成し、

- プライマリのエンドポイントにする
  - こうすることで、あとから HTTP サービスを切り替えても、API の URL を変更せずにすみます
- アクセス制御で「認証なし」を選択

<img width="859" alt="スクリーンショット 2019-12-01 17.49.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/4448968d-0205-b68b-544f-4e918bcdf311.png">

新しく作成されたエンドポイントの URL は、サービス一覧の瞳のアイコンから確認できます。

<img width="255" alt="スクリーンショット 2019-12-01 19.20.30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/fd59f837-11c4-293d-8934-ac7278aef0d7.png">

`https://{ORGANIZATION_NAME}.api.abeja.io/deployments/{DEPLOYMENT_ID}` という形式になっているはずです。これを LINE bot の webhook として登録します。

<img width="955" alt="スクリーンショット_2019-12-01_17_49_17.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/eb90698d-49bf-d900-bfb1-18ca3c0dd2ab.png">

## LINE bot の動作確認

動作確認のために、今回作成した LINE bot にいくつか写真を投稿してみました。[^1]
![IMG_1528.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18990/fc5d530f-b79a-3ec1-f754-3115ab09dda1.png)

結果の真偽はともかく（？）LINE bot として動いているようです。

[^1]: この投稿に使用させてもらった写真は次の通りです。 [sunflower by Aiko, Thomas & Juliette+Isaac](https://www.flickr.com/photos/aikos/4755861023), [rose by Waldemar Jan](https://www.flickr.com/photos/128905059@N02/22138314909/), [cauliflower by liz west](https://www.flickr.com/photos/calliope/8130880534)