開発合宿でGCPのautoML(ベータ版)を触ってみた話

この記事は [Opt Technologies Advent Calendar 2019](https://qiita.com/advent-calendar/2019/opt-technologies) 2日目の記事です。公開は諸事情により7日目です。

弊社では毎年開発合宿（[去年の様子](https://tech-magazine.opt.ne.jp/entry/2018/12/10/170000)）でエンジニアがYATTEIKIを発揮します。最近はビジネスサイド参加者が増えていて活発っぽい。時代はAIバブルですが Google の [Cloud autoML](https://cloud.google.com/automl/?hl=ja)を触ってみたら楽にAIな開発ができたので、機械学習なんもわからんマンでもできる実装手順を紹介します。

# はじめに
〜ランチ中〜
弊社データサイエンティスト「[kaggle](https://www.kaggle.com/) のachievement機能みたいに社員を格付けしたい」
私「どうして」
弊社データサイエンティスト「人類は評価されると嬉しいから」
私「天才」

# アイデア
コンペ資料など社内知見共有をするwebアプリケーション(Quarry[^1])を利用し、アップロードした資料につく「いいね」「お気に入り」「ダウンロード数」「カテゴリ」「タグ」情報から以下の機能を考えました。

- `AutoML`による投稿時のカテゴリ（投稿種別）の自動付与
- 投稿のレコメンド
- ユーザーのランク付けによる投稿動機付け

![quarry_image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/eac3df15-9b16-9e4f-5118-50ec6974036f.png)

# ↑をもとに作った↓

※ステージング環境のデータベースとはいえ(コンプライアンス的に)完全アウトなのでモザイク
![quarry_of_philosophy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/0a135496-a0fc-a9d6-d70c-780cc0f1fcd5.png)

主なポイントは

1. 採石場という意味のQuarryにちなんでユーザランクは `石ころ` <<<<< `サファイヤ` まで
1. **recommends**カラムにアイテム(資料)を3つサジェスト表示  



の2点です。アプリケーションの構成技術スタックは flask + Vue.js で実装しました。
![スクリーンショット 2019-12-05 22.00.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/cd4f865a-d6c5-cc2b-31d7-83ae6e59d6f4.png)

今回は2番目のサジェスト機能で利用した **AutoML Text Classification** にフォーカスします。


# AutoMLって
冒頭でも紹介しましたが、現在(2019/12)[Google Cloud Platform](https://cloud.google.com/docs/overview/?hl=ja) がβ版で公開している機械学習APIです。
今回利用した Cloud Natural Language API だけでなく、画像や翻訳など各分野のAPIが展開されています。
![gcp_list.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/988dc6ec-424d-4511-136d-9bf3c6cad705.png)

今回は、資料のファイル名とそれに紐づくカテゴリデータ(タグもある)をもとにレコメンドモデルを生成したいので、Natural Language をクリックします。
すると自然言語系のプロダクト一覧がダッシュボード画面に表示されます。
カテゴリ分類を行う機械学習モデルを作成する AutoML Text Classification を利用します。

![dashboard.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/83f39021-c60e-1e61-91aa-2724c5110851.png)

##　前処理
機械学習といえば前処理。前処理を制する者は人類を制す。それは言いすぎかも。
普通に jupyter notebook (今後Jupyter labに移行予定らしい[^2])でQuarryのDBから対象データを抽出していきます。

必要なライブラリ等諸々。

```python

import csv
import os
import pandas as pd
import mysql.connector
from flask import Blueprint, request, jsonify
```

ステージングのDBと接続します。

```python

# DB connection func.
def get_connection():
    conn = mysql.connector.connect(
        host='quarry-staging.hoge.ap-northeast-1.rds.amazonaws.com',
        port=3306,
        user='hogest',
        password='sayhoge',
        database='db_name',
    )
    return conn
```

mysql-connector-python(sqlight3でも良い)を使って欲しいテーブルやカラムをSQLで `execute` していきます(一例)。

```python

db_name = Blueprint('db_name', __name__, url_prefix='/api/db_name')

# メッセージリスト
def list_message_query():
    """
      "id",
      "user_id",
      "message_div",
      "scope_div",
      "title",
      "message",
      "url_flg",
      "link_url",
      "link_url_kind",
      "thumbnail_url",
      "update_user_id",
      "status",
      "created",
      "updated",
    """
    conn = get_connection()
    cur = conn.cursor()
    cur.execute(f"""
    select id, user_id, title, message, updated, created
    from messages
    where status = 0
     """)
    return cur.fetchall()
```

あとはリストを作って `map` 関数とlambda式で要素を変更しながら各テーブルごと同様の加工をします。

```python

massage_list = list(map(lambda x: {
    "id": x[0], "title": x[2], "message": x[3]
}, list_message_query()))
```

ラベルに使う category_id(カテゴリ番号) を抽出します。

```python

df_raw_cid = pd.DataFrame(category_list, columns=['id', 'category_id'])
```

はじめはtitle(資料名)とmessage(資料のコメント)をくっつけて学習しようとした日もありました。

```python

body_str = add_categoryId['title']+add_categoryId['message']
# cid を Series -> df に変換
df_body_str = pd.DataFrame(body_str, columns = ['title_message'])
type(df_body_str)
```

コメントの文字列に改行や特殊文字など扱いにくい文字列がたくさんあるとわかったので、やっぱり message カラムいらない、、、みたいな紆余曲折。

```python

drop_col_message = ['message']
df = add_categoryId.drop(drop_col_message, axis=1)
```

結局必要なカラムだけをとった df_title と df_cid を `concat` してCSVに吐きました。Dataframeで作成したので勝手についてしまうindexは `index=False` することを忘れないでください、AutoMLのデータセットに不要なようです。

```python

dataset_tc = pd.concat([df_title, df_cid], axis=1)
dataset_tc.dropna(how='any')
dataset.to_csv('data/dataset_tc.csv', index=False)
```


## データセットのインポート


1. 「新しいデータセット」からデータセット名とモデルを選択。  
今回のデータセットは資料名とカテゴリ番号なので単一ラベル分類を選択しました。

![スクリーンショット 2019-12-05 22.42.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/f3fa12db-5cbe-1d6d-687d-3b1a694b9123.png)

1. 該当するファイルのアップロード（今回は jupyter notebook でデータ整形をしたので「パソコンからCSVファイルをアップロード」した）。  
![スクリーンショット 2019-12-05 22.44.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/c33dab1e-580c-b464-f7ba-4dc1c05ae5d4.png)

...なんとこれだけで学習モデルの作成がスタートします。

# 考察

## トレーニング結果

以下のようなデータセットが作成されました。
![スクリーンショット 2019-12-05 22.28.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/fe3b495c-e299-6810-ef41-eb07c60bc6b0.png)

対象データのアイテム数とカテゴリラベルなど詳細は以下です。
ちなみに学習が終わるまで2時間くらいかかりました。モデル生成に時間はかかりますが、あとは学習済みのモデルをAPIで使うだけなので、毎回この時間がかかるわけではありません。

<img width="774" alt="スクリーンショット 2019-12-08 1.11.42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/2e135268-52a0-c951-4aba-fd00818a050e.png">

|  |アイテム数  |
|---|--:|
|全てのアイテム  |9416  |
|ラベル付き  |8151  |
|ラベルなし  |1267  |
|トレーニング  |6522  |
|検証  |816  |
|テスト  |813  |


## 評価

トレーニングが終了したら生成モデルの評価を行います。分類精度として適合率(Precision)と再現率(Recall)の２つを指標とします。お互いにトレードオフの関係であるため、ケースによってどちらを優先するべきか判定する材料となるわけです。
アイテムの嗜好に適合させたい今回のケースはレコメンドエンジンとして評価したいので、適合率を優先します。反対に、よく例に出てくる癌予測のケースでは再現率を優先して病気の見逃しをできるだけ減らすことに注視します。

(TP=真陽性,FP=偽陽性,TN=真陰性,FN=偽陰性)

$$ Precision = \frac{TP}{FP+TP} $$ 

$$ Recall = \frac{TP}{FN+TP} $$


陽性(True)と予測した結果によって、実際に陽性だった結果を真陽性(True-Positive)、違った結果を偽陽性(False-Positive)と定義しそれぞれの指標をします。陰性(Negative)の場合も同様に真陰性(True-Negative)と偽陰性(False-Negative)と表します。


<img width="392" alt="スクリーンショット 2019-12-08 2.42.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/56f075d8-0558-76f0-0bf9-0cdc32a24396.png">



また、正解ラベルと実際の予測結果を行列で表現する混同行列(Confusion matrix)も評価指標となります。
各行(列)で高い予測割合が右斜め下にクロスしていればよいモデルと判断できます。

### 適合率,再現率(Precision, Recall)の結果

<img width="1031" alt="precision-recall.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/d949ce58-a0f8-f2a7-b15b-01858321eadb.png">
閾値(Confidence thresholdの調整バーで設定)の時点で適合率が高く性能としては良さそうです。


### 混同行列(Confusion matrix)の結果

<img width="676" alt="cof-matrix.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/0c5276fd-d4e0-d2cc-f3ca-58f2aa95b3dd.png">
ラベル５,10はもともとアイテム数が少なく割合が低くなってしまいました。アイテム数が0のラベル6はしっかりはじかれていることがわかります。


### カテゴリラベルごとの結果

|ラベル |アイテム数  |適合率(%)|再現率(%)|
|---|--:|--:|--:|
|1.媒体資料 |660  |68.57|36.36|
|2.提案資料  |2701  |89.95|66.3|
|3.施策・事例共有  |3058  |84.36|75.82|
|4.機能・テクニック  |363  |100|19.44|
|5.業務効率化・改善  |214  |100|0|
|6.タスク・スケジューリング管理  |0  |null|null|
|7.業界・市場・競合調査  |554  |84.09|67.27|
|8.セミナー・勉強会  |473  |58.62|36.17|
|9.連絡  |92  |100|44.44|
|10.教本・基礎 |36  |100|0|

AutoML Text Classification は、設定したラベルごとのF値(しかもTP,FP,TN,FNごとに)の結果を出してくれています、すごい(KONAMI)。

![result-label2-b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/122aded5-38e5-506f-304c-4510e6e402f0.png)

最もスコアが高いラベル２の中でも、正しく予測できなかったアイテムが一覧で確認できるので何が原因だったのか考察することができます。  
例えば、提案資料カテゴリの偽陰性一覧にあがっているアイテムで資料名に「提案資料」と入っているものが複数観測できました。これは「提案資料」の前後に別カテゴリに分類されそうな「施策」や他カテゴリに多い社名(固有名詞)が入っており、文脈による単語間の類似度計算かなにかがされていたりして(要出典)、「提案」とあっても分類されないケースもあるようです。


## テストと使用
ダッシュボード上でGCPから新たなデータをこのモデルに判定させることもできます。
<img width="1048" alt="スクリーンショット 2019-12-08 1.10.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66523/ab17a702-2266-0d92-eefd-f3871ecaae8d.png">

Cloud Natural Language は API なので当然アプリケーションコードに組み込むことで推薦機能を実装できます。


まずは、

```
$ curl -X POST \
  -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
  -H "Content-Type: application/json" \
  https://automl.googleapis.com/v1beta1/projects/391983214514/locations/us-central1/models/TCN1234567790hogehoge:predict \
  -d @request.json
```

と curl を叩くと、


```json

{
  "payload": {
    "textSnippet": {
      "content": "YOUR_SOURCE_CONTENT",
      "mime_type": "text/plain"
    }
  }
}
```
`request.json` が返ってきます。


GDC fileなら以下。

```json

{
  "payload": {
    "document": {
      "input_config": {
        "gcs_source": {
          "input_uris": "YOUR_GCS_FILE_URI"
        }
      }
    }
  }
}
```

Pythonの場合、 `predict.py` として以下のように書くことができます。

```python



import sys
​
from google.api_core.client_options import ClientOptions
from google.cloud import automl_v1beta1
from google.cloud.automl_v1beta1.proto import service_pb2
​
def inline_text_payload(file_path):
  with open(file_path, 'rb') as ff:
    content = ff.read()
  return {'text_snippet': {'content': content, 'mime_type': 'text/plain'} }
​
def pdf_payload(file_path):
  return {'document': {'input_config': {'gcs_source': {'input_uris': [file_path] } } } }
​
def get_prediction(file_path, model_name):
  options = ClientOptions(api_endpoint='automl.googleapis.com')
  prediction_client = automl_v1beta1.PredictionServiceClient(client_options=options)
​
  payload = inline_text_payload(file_path)
  # Uncomment the following line (and comment the above line) if want to predict on PDFs.
  # payload = pdf_payload(file_path)
​
  params = {}
  request = prediction_client.predict(model_name, payload, params)
  return request  # waits until request is returned
​
if __name__ == '__main__':
  file_path = sys.argv[1]
  model_name = sys.argv[2]
​
  print get_prediction(content, model_name)
```

あとは

```
$ python predict.py 'YOUR_SOURCE_FILE' projects/391983214514/locations/us-central1/models/TCN1234566789hogehoge
```

と実行して確かめてみましょう。カンターン。


# まとめ

院生時代、マシンを１週間並列処理(物理)したり論文に実験結果を載せようとscikit-learnでゴリゴリコード書いて混同行列とか作ったり、毎回偽陰性の定義を忘れては確認し(頑張れ)を繰り返していた日々を返して欲しい気もするし返して欲しくない気もしました。



[^1]: 広告運用効率化のための[社内プロダクト](https://opt-technologies.jp/)が複数ある
[^2]: `JupyterLab will eventually replace the classic Jupyter Notebook.`
 https://github.com/jupyterlab/jupyterlab