Jubatusを使った感情分析を行うLINE Botの作成

[Jubatusハッカソン with 読売新聞 #2 - connpass](http://jubatus.connpass.com/event/41692/)でJubatusを使ったLINE Botを作成しました。

概要を[記事](http://www.yomiuri.co.jp/science/feature/CO018532/20161114-OYT8T50091.html?from=tw)から引用します。

```
「小町の溜息」は、発言小町に投稿された文章を六つの感情パターンに分類し、
悩みを投稿するとそのパターンに沿って、平安時代の歌人、小野小町の和歌の美しいフレーズで回答してくれる仕組み。
```

概要やイベントの様子は記事や公式サイトに譲るものとして、ここでは、取り組んだ内容の詳細についてご紹介します。

ハッカソンの成果物 : おののこまち Line Bot
================================================================================

今回のハッカソンでは次のものを作りました。

![IMG_3955.PNG](https://qiita-image-store.s3.amazonaws.com/0/41266/772d4ee3-7a99-c93c-1660-30f15ffdf85c.png "小野小町がニュース・相談に答える")

ニュース・相談を入力すると、小野小町が内容を表す和歌を返歌するLine Botです。

なお、アイコンは[あきたこまち](http://www.ja-ugo.jp/onlineshop/akitakomachi.html)よりお借りしました。

I. システム構成
----------------------------------------------------------------

今回作成したサービスのシステム構成は次のとおりです。
システム構成には[LINE Bot API の HTTPS Callback は Amazon API Gateway を使うと簡単便利安価で最高 - おともだちティータイム](http://shunirr.hatenablog.jp/entry/2016/04/13/164902)を参考にさせていただきました。

![archtecture.png](https://qiita-image-store.s3.amazonaws.com/0/41266/85068628-12b4-527f-aacb-97024362bdcb.png "archtecture.png")

それぞれのコンポーネントの役割は次の通りです。

* APIGateway : LINE Messaging APIとの通信(HTTPS必須)を提供するために利用
* Flask : Messenger APIからのコールバックの実装に利用
* Jubatus : 記事の感情分析を実施。今回のコア機能を提供。
* Python : 感情分析結果からの会話文生成や学習用のスクリプトを提供


II. API Gateway
----------------------------------------------------------------

LINE Messaging APIからのメッセージの受診にはHTTPSでWebhookを実装する必要があります。

一方、サーバー単体でHTTPSでの通信を実現するためにはHTTPサーバーの設定や証明書の発行などの作業が必要です。

今回はそれらの作業を回避するため、API Gatewayを利用してHTTPS/HTTPの変換を行なっています。

設定上で注意すべきは、HTTP Request Headers で Messaging API から通知されるX-LINE-ChannelSignatureを渡すように設定が必要です。

設定の詳細は[LINE Bot API の HTTPS Callback は Amazon API Gateway を使うと簡単便利安価で最高 - おともだちティータイム](http://shunirr.hatenablog.jp/entry/2016/04/13/164902)をご参照ください。


III. Amazon ec2
----------------------------------------------------------------

Jubatusの構築について、Amazon ec2用のイメージの配布が行われていましたのでそちらを利用しました。

カスタマイズとしては、Python3系とpip3のインストールを行なっています。これはチームメンバーがPython3系に慣れていたのと、Python 2系の文字列の問題を回避するためです。

IV. Flask
----------------------------------------------------------------

軽量なREST APIサーバーの構築のために[Flask](http://flask.pocoo.org/)を利用しました。

当初はLINE Messaging API のためのAPIを[GCPUG Nagoya #2](https://mursts.github.io/gcpug-nagoya-02/)にならってGAEを利用して作成しようと考えていました。

しかし、実行してみたところ[Jubatus Client](http://jubat.us/ja/api/index.html)がGAEで動作しないことが発覚したため、webapi2からFlaskに実装しなおし、ec2に移し替えました。

V. Jubatus
----------------------------------------------------------------

Jubatusでは機械学習を行い、入力文から感情解析をしてどの和歌を返答すべきか判定しています。

### 問題設定

機械学習としての問題設定は次の通りです。

1. 入力 : 日本語の文章、特に恋愛相談
2. 出力 : 返答すべき和歌を表すラベル (7クラス)
3. 教師データ : ラベル付けされた発言小町の相談文

ここで重要なのは、返答すべき和歌は**相談者の感情を反映したものにする**点です。この実現のために機械学習アルゴリズムを用いています。

### 教師データの作成

調査の結果、小野小町の和歌はよく知られたもので45首程度、代表的なものでは17首程度が知られているとわかりました。

現代語での解説を読むと (例えば[小野小町の和歌 17首 【現代語訳】付き | ジャパノート－日本の文化と伝統を伝えるブログ](http://idea1616.com/komachi-waka/) )「逢いたい」「飽きられる」「恋しい」といった特徴的な単語が現れていることもわかります。

また、それぞれの句では似た内容が述べられていたため、人手で返答すべき句を6首剪定しました。ここでどれにも該当しないデータとしては松岡修造の返答をすると決定し、6+1の7クラスへの分類問題となりました。

これらのことから、提供された発言小町のデータ(11カテゴリー、各カテゴリーは900件程度)から、教師データを次の手順で作成しました。

1. 恋愛相談カテゴリーから、それぞれの和歌を表す特徴的な単語(=辞書)で検索 (grepしてます)
2. 検索結果に目を通し、人手でラベル付け
3. 各クラス(7クラス)についてそれぞれ10件の相談を集める

今回作成した辞書はこのようになっています

```
秋風,
悲しい,
むなしい,
秋,
夜,
逢う,
あわれ,
世の中,
ほだし,
絆,
結びつき,
仲間,
世間,
海人,
里,
恨み,
恋,
時雨,
雨,
うつろい,
 :
 :
どうにでもなれ,
自暴自棄,
(計77単語)
```

最終的に教師データは次の形のJSONにまとめました。
実際には配列に10個の要素が並びます。

``` traindata.json

[{
    "label": "komachi_furueru",
    "message": "3年前から同棲している2つ年上（38歳）の彼がいます。(中略)",
  }]
```

### Jubatusでの学習

#### 前処理

上記データをもとに学習させます。文章の特徴量として、Bag of Wordsを用います。
前処理には[Jubatusのデータ変換機能](http://jubat.us/ja/fv_convert.html)を用いました。形態素解析にはmecabを用いました。

``` config.json
{
  "method": "NHERD",
  "parameter": {
    "regularization_weight": 0.001
  },
  "converter": {
    "num_filter_types": {
    },
    "num_filter_rules": [
    ],
    "string_filter_types": {
    },
    "string_filter_rules": [
    ],
    "num_types": {
    },
    "num_rules": [
    ],
    "string_types": {
        "bigram":  { "method": "ngram", "char_num": "2" },
        "mecab": {
          "method": "dynamic",
          "path": "libmecab_splitter.so",
          "function": "create"
        }
    },
    "string_rules": [
        { "key": "*", "type": "mecab", "sample_weight": "bin", "global_weight": "idf" }
    ]
  }
}
```

#### Jubatusの起動

上記設定ファイルをec2のサーバーに設置し、Jubatusを次のコマンドで起動します

```
$ jubaclassifier --configpath shogun.json
```

#### 学習

学習自体はPythonスクリプトを書いて実行しました。次のようにファイルを配置しておきました。

``` フォルダ構成
komachi_ai.py
config.json
/TrainingData
  - (学習用のJSONファイル)
```

次のスクリプトで学習させます。Python3で書きました。コード規約に沿ってないところは多々あると思います。

``` komachi_ai.py
import sys,json
import re
import glob

from jubatus.classifier.client import Classifier
from jubatus.classifier.types import LabeledDatum
from jubatus.common import Datum

def get_datummsg(message):
    """質問文1件を受け取り、datum形式にして返す    
    """
    #　改行とカンマをスペースに置換
    text = re.sub(r'[\n,]', ' ', message)
    return Datum({"message" : text})


def training():
    """ Training Jubatus Classifier Model
    """
    files = glob.glob("TrainingData/*.json")
    for file in files:
        print(file)
        with open(file) as f:
            print(file + "is opened.")
            try:
                for data in f:
                    j = json.load(data)
                    label = j["label"]
                    message = j["message"]
                    classifier.train([LabeledDatum(label, get_datummsg(message) )])
                    print(label + "*" + message)
            except:
                pass


if __name__ == '__main__':
    SERVER_IP = '127.0.0.1'
    SERVER_PORT = 9199
    NAME = ''

    # Create a client instance.
    classifier = Classifier(SERVER_IP, SERVER_PORT, NAME, 10)

    # Show configuration.
    print("--- Configuration ----------")
    print(classifier.get_config())
    print()

    # Show the status of classifier before training.
    print("--- Status ----------")
    print(classifier.get_status())
    print()

    training()

    classifier.save("komachi")
    print("--- Trained! ----------")
```

幾つか特徴的な部分があるので解説します。

* NAMEはJubatusの分散構成を取った場合にZookeeperで利用されます。今回はシングルなので用いていません。
* JubatusはRPCで通信を行うため`classifier = Classifier(SERVER_IP, SERVER_PORT, NAME, 10)`を実行した時点でJubatusとの接続が可能(RESTではない)
* 実際に学習させている命令は`classifier.train([LabeledDatum(label, get_datummsg(message) )])`
* mecabで形態素解析を行うコードは書かなくて良い(config.jsonで指定している)
* 学習結果を識別時に使うため、`classifier.save("komachi")`で保存

#### 識別・和歌生成

LINE Messaging APIとJubatusを接続し、入力文から和歌を生成します。
少し複雑なので分解すると、3つのことをやっています。

* Flaskを使ってAPIサーバーを立てる
* Messaging APIからの入力から判別を行う
* 和歌生成

このあたりの実装は[Python + FlaskでLINE botを作った - 『入る学科間違えた高専生』の日記]( http://nnsnodnb.hatenablog.jp/entry/line-bot-made-flask)と[GCPUG Nagoya #2 GAE Handson](https://mursts.github.io/gcpug-nagoya-02/)を参考にしています。

###### Flaskを使ってAPIサーバーを立てる

次のようにしてWebhookを実装します。

```
app = Flask(__name__)

@app.route("/callback", methods=['POST'])
def callback():
    events = request.json
    line_signature = request.headers.get('X-Line-Signature')

    for event in events['events']:
        if event['message']['type'] == 'text':
            # テキストのメッセージが送られてきた場合
            text_reply(event['replyToken'], event['message']['text'])

if __name__ == "__main__":
    app.run(host = '0.0.0.0', port = 8001, threaded = True, debug = True)
```

今回は省略してしまいましたが、本当は署名の検証が必要です。


##### Messaging APIからの入力から判別を行う

判別は次のように行いました。

```
def get_datummsg(message):
    """質問文1件を受け取り、datum形式にして返す
    """
    #　改行とカンマをスペースに置換
    text = re.sub(r'[\n,]', ' ', message)
    return Datum({"message" : text})


def classifier(message):
    """
    発言を7クラスに分類する
    """
    SERVER_IP = '127.0.0.1'
    SERVER_PORT = 9199
    NAME = ''
    classifier = Classifier(SERVER_IP, SERVER_PORT, NAME, 10)
    classifier.load("komachi")

    datum = get_datummsg(message)
    answers = classifier.classify([datum])
    # answers[0] からうまいこと最大値を選ぶ
    estimations = answers[0]

    score , label = estimations[0].score, estimations[0].label
    for estimation in estimations:
        if estimation.score > score:
            score, label = estimation.score, estimation.label

    return label
```

引数の`message`は入力文です。返り値のlabelはクラスを表す文字列です。特徴的な箇所をまたいくつか解説します。

* NAMEはJubatusの分散構成を取った場合にZookeeperで利用されます。今回はシングルなので用いていません。
* 学習結果を用いるため、`classifier.load("komachi")`で学習結果を呼び出しています。
* answersに識別結果が帰ってきますが、すべてのクラスについての識別結果が返されるため、スコアが最大値をとるものを選択しています

##### 和歌生成

和歌を生成し、Messaging APIにメッセージを送り返します。

```
def wakanize(message):

    wakationary = {
        "komachi_ikeike" : "みるめなき　我が身をうらと知らねばや　かれなで海士の足たゆく来る\n\n",
        "komachi_furueru" : "花の色は　移りにけりないたづらに　わが身世にふるながめせしまに\n\n",
        "komachi_bannen" : "わびぬれば　身を浮草の根をたえて　さそふ水あらばいなむとぞ思ふ\n\n",
        "komachi_mayou" : "あはれてふ　ことこそうたて世の中を　思ひはなれぬほだしなりけれ\n\n",
        "komachi_wakarerarenai" : "うつつには　さもこそあらめ夢にさへ　人めをもると見るがわびしさ\n\n",
        "komachi_syanairennai" : "いとせめて　恋しき時はむばたまの　夜の衣をかへしてぞ着る\n\n",
        "shuzo" : "気にすんな！くよくよすんなよ！大丈夫！\n\n"
    }

    wakaclass = classifier(message)
    return wakationary[wakaclass]

def text_reply(reply_token, message):
    """textのメッセージを返信します"""

    message = wakanize(message)

    payload = {'replyToken': reply_token,
               'messages': [{'type': 'text',
                             'text': message}]}

    headers = {'Content-Type': 'application/json; charset=UTF-8',
               'Authorization': 'Bearer {}'.format(settings.CHANNEL_ACCESS_TOKEN)}

    r = requests.post(LINE_ENDPOINT, headers = headers, data = json.dumps(payload))
```

全体のコードはそのうちGithubで公開しようと思います。


対応できなかった/しなかった事項
================================================================================

いくつかの事項が課題や次の挑戦として残されています。

機械学習
--------------------------------------------------------------------------------

今回の学習の手順をまとめると次のようになります。

1. それぞれのクラスを表す、軽量な辞書を作成
2. 軽量な辞書に現れる単語を含む文章群を抜き出し
3. 文章群を人手でラベル付けし、教師データを10件作成
4. 教師データから学習し、識別器を作成
5. 入力文に対して、識別器でクラスを出力

出力結果を何度か見ていると、全く違うニュースの文章に対して同じ和歌を出力してしまうなど、学習が十分ではないと思われる挙動がありました。
これは開発中にも議論になりました。
教師データの用意については、次のような手法が考えられました。

1. それぞれのクラスを表す、軽量な辞書を作成
2. 軽量な辞書に現れる単語を含む文章群を抜き出し
3. 文章群を人手でラベル付けし、教師データを10件作成
4. 教師データからJubatusで学習し、識別器1を作成
5. 識別器1を用いて、全てのデータにラベル付けし、識別器2を作成
6. 入力文に対して、識別器2を用いてクラスを出力

これが有効に機能する可能性があるのは、実際に発言小町から入手できたデータが次のような形式をしていたためです。

```
{
    "message": "夫の些細な一言、あるいは会話の返しにいつもイライラしてしまいます。(省略)",
    "topic_id": "784475",
    "user_id": "1373687724",
    "votes": [{
        "category": "1",
        "categoryname": "面白い",
        "count": 2
    }, {
        "category": "2",
        "categoryname": "びっくり",
        "count": 45
    }, {
        "category": "3",
        "categoryname": "涙ぽろり",
        "count": 0
    }, {
        "category": "4",
        "categoryname": "エール",
        "count": 6
    }, {
        "category": "5",
        "categoryname": "なるほど",
        "count": 2
    }],
    "group": "恋愛・結婚・離婚",
    "user_name": "悩む妻",
    "responses": [],
    "face": "kao040",
    "title": "夫の返しにいちいちイライラする",
    "url": "http://komachi.yomiuri.co.jp/t/2016/1111/784475.htm?g=04",
    "n_favorite": "0",
    "n_response": "0",
    "date": "2016年11月11日 16:36"
}
```

今回の手法では、ユーザーによる投票やレスポンスの文章、グループ、といった情報を全て捨ててしまっています。
これらの情報を用いると、小規模な教師データでもより有効な識別機 (識別器1) の作成が出来た可能性があります。

識別器1を用いてラベル付けしたデータを用いて、再度学習を行い識別器を作成 (識別器2) すれば、より精度を上げられた可能性があります。
また、認識させるクラスを増やした場合、このような手段をとらないとデータが不足すると思われます。

サービス化する場合の考慮事項
--------------------------------------------------------------------------------

スケールさせるためにシステム構成を各所で検討できます。

* APIサーバーと返答用サーバーの分割
  - 今回のシステム構成では大量のメッセージに対応できないことが知られています。
* Jubatusの分散構成
  - 識別処理を分散させられます。
* 和歌をソースコードから切り出す
  - 最低でも外部ファイルに出すべきでした。

Jubatus側で対応してくれると嬉しいこと
--------------------------------------------------------------------------------

実装している中で次の事項が若干課題になったので、対応を検討いただけるとうれしいです

* ec2用のイメージでPython3系とpip3を追加
* JubatusクライアントのGAE, Heroku, Bluemix辺りへの対応 (難しいと思いますし必須でもないと思います)


最後に
================================================================================

今回のハッカソンでは運営の方々のサポートに助けられました。

開発中、深夜にもかかわらず相談に対応して頂いたJubatus開発チームの方々に感謝申し上げます。
また、早朝にも関わらずAPI Gatewayのことを教えて頂いた運営の方(おそらくドワンゴの方?)にも感謝申し上げます。

そして、無茶言ったにも関わらず徹夜して(中には2徹して)取り組んでくれたチームのメンバーにも、ありがとうございました。



Reference
================================================================================

イベントサイト
--------------------------------------------------------------------------------

* [Jubatusハッカソン with 読売新聞 #2 - connpass](http://jubatus.connpass.com/event/41692/)

* [新聞記事・発言小町活用ハッカソンに８チーム : 科学 : 読売新聞（YOMIURI ONLINE）](http://www.yomiuri.co.jp/science/feature/CO018532/20161114-OYT8T50091.html?from=tw)


Jubatus
--------------------------------------------------------------------------------
* [Jubatusでテキストに含まれる特徴語の傾向を学習し、入力テキストをカテゴライズする - Qiita](http://qiita.com/tmtysk/items/4d177ba27c8d2484b206)

* [Classifier チュートリアル (Python) — Jubatus](http://jubat.us/ja/tutorial/classifier_python.html)

* [Jubatus](https://github.com/jubatus)

* [jubatus/hackathon](https://github.com/jubatus/hackathon)


LINE Messaging API
--------------------------------------------------------------------------------
* [LINE Developers](https://developers.line.me/)

* [API Reference](https://devdocs.line.me/ja/#webhook)


AWS
--------------------------------------------------------------------------------
* [LINE Bot API の HTTPS Callback は Amazon API Gateway を使うと簡単便利安価で最高 - おともだちティータイム](http://shunirr.hatenablog.jp/entry/2016/04/13/164902)

* [Amazon API Gateway を使って AWS 以外のサービスの API をラップする ｜ Developers.IO](http://dev.classmethod.jp/cloud/aws/api-gateway-wrap-api/)

* [5分でわかるAmazon API Gateway | ナレコムAWSレシピ](http://recipe.kc-cloud.jp/archives/7944)

Python & Flask
--------------------------------------------------------------------------------

* [Python + FlaskでLINE botを作った - 『入る学科間違えた高専生』の日記](http://nnsnodnb.hatenablog.jp/entry/line-bot-made-flask)

* [nnsnodnb/LINE_bot-MorphologicalAnalysis](https://github.com/nnsnodnb/LINE_bot-MorphologicalAnalysis)

* [python - How to get data recieved in Flask request - Stack Overflow](http://stackoverflow.com/questions/10434599/how-to-get-data-recieved-in-flask-request)

* [How to serve HTTPS *directly* from Flask (no nginx, no apache, no gunicorn) | Flask (A Python Microframework)](http://flask.pocoo.org/snippets/111/)

* [Pythonのimportについてまとめる - Qiita](http://qiita.com/suzuki-hoge/items/f951d56290617df4279e)

* [ソース — GCPUG Nagoya #2 GAE/Py Handson 1.0 ドキュメント](https://mursts.github.io/gcpug-nagoya-02/hello-line-bot/source.html)