10
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Twitterのリプライから機械学習でLINEBotを作る

Posted at

こちらはプロ生ちゃん Advent Calendar 2018 17日目の記事になります。昨日はsaya3633さんの「今年の私とプロ生ちゃん振り返り」でした。私もいつかコスプレしてみたいです。

最近、少しずつでも機械学習をさわってみたいな~と思い、マスコットアプリ文化祭2018 に向けてLINEBotを作りました。今回はその作り方の話です。下記QRコードから友達追加ができますので、もしよかったら遊んでみてください。

作業した環境

  • CentOS 7.5(CPU環境)
  • Python 3.6.5

ざっくり手順

  1. Twitterからリプライ収集
  2. 機械学習でモデル作成
  3. LINEBotを用意

Twitterからリプライ収集

「リプライ」の定義

メッセージ 返信
ただいま。 おかえり。
ごちそうさまでした。 おそまつさまでした。
君がッ!泣くまで!!殴るのをやめないッ!!! このきたならしい阿呆がァーーーーーーッ!!

Twitter API

リプライ収集はTwitter APIを利用します。以前だと登録申請は簡単だったのですが、今はしっかりとした審査(英語)があるみたいですね。とても丁寧に解説なさっている記事がありますので、もし未登録の場合はこちらを参考にしてなんとか突破を。申請方法が変わっても、個人かつ無料で登録可能です。

リプライ取得

こちらの記事を参考にさせていただきました。ありがとうございます。

twitter.py
import sys, datetime, time, json
from requests_oauthlib import OAuth1Session

USER_TIMELINE_URL = '/statuses/user_timeline'
SHOW_URL = '/statuses/show/:id'
 
class TweetCollecter(object):
    def __init__(self, CK, CS, AT, AS):
        self.__session = OAuth1Session(CK, CS, AT, AS)
 
    def __specifyUrlAndParams(self, screen_name):
        """
        リクエストURLとパラメータを返す
        """
        url = 'https://api.twitter.com/1.1/statuses/user_timeline.json'
        params = { 'screen_name': screen_name, 'count': 200 } # 一度に取得可能な件数は'200'
        return url, params
 
    def __pickupTweet(self, res_text):
        """
        レスポンスを配列にまとめて返却
        """
        tweets = []
        for tweet in res_text:
            tweets.append(tweet)
 
        return tweets

    def __getLimitContext(self, res_text, url):
        """
        レスポンスに応じた回数制限を取得 
        """
        remaining = res_text['resources']['statuses'][url]['remaining']
        reset     = res_text['resources']['statuses'][url]['reset']
 
        return int(remaining), int(reset)
 
    def __checkLimit(self, limit_check_url):
        """
        回数制限を確認(アクセス可能になるまで待機する)
        """
        unavailable_cnt = 0
        while True:
            url = "https://api.twitter.com/1.1/application/rate_limit_status.json"
            res = self.__session.get(url)

            if res.status_code == 429:
                # 429 : Too Many Requests
                if unavailable_cnt > 10:
                    raise Exception('Twitter API error %d' % res.status_code)
                    
                unavailable_cnt += 1
                print ('Too Many Requests 429(wait 60 seconds)')
                time.sleep(60) 
                continue

            elif res.status_code == 503:
                # 503 : Service Unavailable
                if unavailable_cnt > 10:
                    raise Exception('Twitter API error %d' % res.status_code)
 
                unavailable_cnt += 1
                print ('Service Unavailable 503')
                self.__waitUntilReset(time.mktime(datetime.datetime.now().timetuple()) + 30)
                continue
            
            unavailable_cnt = 0
 
            if res.status_code != 200:
                raise Exception('Twitter API error %d' % res.status_code)
 
            remaining, reset = self.__getLimitContext(json.loads(res.text), limit_check_url)
            print ('[remaining]', limit_check_url, remaining)
            if remaining == 0:
                self.__waitUntilReset(reset)
            else:
                break
 
    def __waitUntilReset(self, reset):
        """
        Twitter API が再度使えるようになる時間まで待機
        """
        seconds = reset - time.mktime(datetime.datetime.now().timetuple())
        seconds = max(seconds, 0)
        print ('\n     =====================')
        print ('     == waiting %d sec ==' % seconds)
        print ('     =====================')
        sys.stdout.flush()
        time.sleep(seconds + 10)  # 念のため +10 秒
 
    def collectTweetFromShow(self, tweet_id):
        """
        プロフィールから単体ツイートを取得
        """
        # 念のため3秒待ってから取得
        time.sleep(3)
        
        # 回数制限を確認
        self.__checkLimit(SHOW_URL)

        # ツイート取得
        url = 'https://api.twitter.com/1.1/statuses/show.json?id=' + str(tweet_id)
        res = self.__session.get(url)
        if res.status_code != 200:
            return None

        return json.loads(res.text)
 
    def collectTweetsFromUserTimeline(self, screen_name, max_count, start_tweet_id, end_tweet_id):
        """
        タイムラインから複数ツイートを取得
        """
        # 回数制限を確認
        self.__checkLimit(USER_TIMELINE_URL)
 
        # URL、パラメータ
        url, params = self.__specifyUrlAndParams(screen_name)
        params['include_rts'] = str(True).lower()
        if start_tweet_id > 0:
            params['max_id'] = start_tweet_id - 1
 
        cnt = 0
        unavailableCnt = 0
        while True:
            # タイムライン取得
            res = self.__session.get(url, params = params)
            if res.status_code == 503:
                # 503 : Service Unavailable
                if unavailableCnt > 10:
                    raise Exception('Twitter API error %d' % res.status_code)
 
                unavailableCnt += 1
                print ('Service Unavailable 503')
                self.__waitUntilReset(time.mktime(datetime.datetime.now().timetuple()) + 30)
                continue
 
            unavailableCnt = 0
 
            if res.status_code != 200:
                raise Exception('Twitter API error %d' % res.status_code)
 
            tweets = self.__pickupTweet(json.loads(res.text))
            if len(tweets) == 0:
                break
 
            for tweet in tweets:
                if tweet['id'] == end_tweet_id:
                    return
                else:
                    yield tweet

                cnt += 1
                if max_count > 0 and cnt >= max_count:
                    return
 
            params['max_id'] = tweet['id'] - 1
 
            # ヘッダ確認 (回数制限)
            if ('X-Rate-Limit-Remaining' in res.headers and 'X-Rate-Limit-Reset' in res.headers):
                if (int(res.headers['X-Rate-Limit-Remaining']) == 0):
                    self.__waitUntilReset(int(res.headers['X-Rate-Limit-Reset']))
                    self.__checkLimit(USER_TIMELINE_URL)
            else:
                print ('not found  -  X-Rate-Limit-Remaining or X-Rate-Limit-Reset')
                self.__checkLimit(USER_TIMELINE_URL)
            
            print (' go to next loop...(wait 5 seconds)')
            time.sleep(5) 
        
        print (' done')

if __name__ == '__main__':
    # Twitter API
    CK = 'Your Consumer Key'
    CS = 'Your Consumer Secret'
    AT = 'Your Access Token'
    AS = 'Your Accesss Token Secret'

    # 取得するTwitterアカウントの表示名
    screen_name = 'Target ScreenName'

    # 最大取得件数(負数:全件)
    max_count = -1

    # 取得開始TweetID(負数:最初から)
    start_tweet_id = -1

    # 取得終了TweetID(負数:最後まで)
    end_tweet_id = -1
    
    # Twitterの会話を取得
    collecter = TweetCollecter(CK, CS, AT, AS)
    cnt = 0
    for reply in collecter.collectTweetsFromUserTimeline(screen_name, max_count, start_tweet_id, end_tweet_id):
        cnt += 1
        tweet = collecter.collectTweetFromShow(reply['in_reply_to_status_id'])
        if tweet is None:
            print ('[' + str(cnt) + ']', '\'tweet\' is None')
            continue
        print ('[' + str(cnt) + ']', tweet['text'], ' -> ', reply['text'])

動かす前にこちらのライブラリを入れて

$ pip install requests requests_oauthlib

ココにTwitter APIの登録情報を書いて

# Twitter API
CK = 'Your Consumer Key'
CS = 'Your Consumer Secret'
AT = 'Your Access Token'
AS = 'Your Accesss Token Secret'

ココに取得するTwitterアカウント名を書いて(@のうしろのやつ)

# 取得するTwitterアカウントの表示名
screen_name = 'Target ScreenName'

動かせば、そのTwitterアカウントのリプライ情報をのんびりと取得します。あとは、標準出力ではなくDBなりファイルなりお好みの場所に出力していただければOKです(私はMongoDBを使っています)

Twitter APIの15分制限は有名ですが、短時間にリクエストを投げ過ぎると、回数制限内でも**「Too Many Requests 429」**が返ってきてエラーになります。少し待っていれば解除されるので適当に1分×数回スリープを入れるようにしておきます。

if res.status_code == 429:
    # 429 : Too Many Requests
    if unavailable_cnt > 10:
        raise Exception('Twitter API error %d' % res.status_code)
                    
    unavailable_cnt += 1
    print ('Too Many Requests 429(wait 60 seconds)')
    time.sleep(60) 
    continue

以上でTwitterのリプライが取得できました。ちなみに、Twitter APIでタイムラインから取得できるツイート数は最新3200件?程度なので、cronなどで定期的に取得してDBなどに置いておくのが良いと思います。
3200件よりも前のツイートを取得したい?それはまた別の記事で…

機械学習でモデル作成

作成手順

  1. リプライをテキストファイルに出力
  2. 環境構築:MeCab
  3. 環境構築:TensorFlow
  4. 機械学習を動かす
  5. 機械学習の結果を確認

リプライをテキストファイルに出力

教師データとしてこんな形式のテキストファイルを作成します。奇数行がメッセージで、次の偶数行が対応する返信になります。ファイル名は後々のために「tweets1M.txt」にしておきます。

tweets1M.txt
ただいま。
おかえり。
ごちそうさまでした。
おそまつさまでした。
君がッ!泣くまで!!殴るのをやめないッ!!!
このきたならしい阿呆がァーーーーーーッ!!

上記twitter.pyで取得したリプライ情報を使いますが、ユーザー名やハッシュタグなどのノイズがありますので削除します。

cleaner.py
import re
from xml.sax.saxutils import unescape

def sentence(sentence):
    if sentence is None:
        return ''

    # 特殊文字デコード
    sentence = unescape(sentence)

    # ユーザー名削除
    sentence = re.sub(r'@[0-9a-zA-Z_:]*', "", sentence)
    
    # ハッシュタグ削除
    sentence = re.sub(r'#.*', "", sentence)

    # URL削除
    sentence = re.sub(r'(https?)(:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+)', "", sentence)

    return sentence.strip()

if __name__ == '__main__':
    tweet = "@hoge ただいま。 #アプリ文化祭"
    reply = "@maru おかえり。 https://mascot-apps-contest.azurewebsites.net/Works/409"

    tweet = sentence(tweet)
    reply = sentence(reply)

    print("%(tweet)s -> %(reply)s" % {'tweet': tweet, 'reply': reply})

動かすと

ただいま。 -> おかえり。

こんな感じに。本来ならばもっと丁寧にノイズを削除した方が良いと思うのですが、今回はコレでいきます。

環境構築:MeCab

Cコンパイラをインストール

$ yum install gcc
$ yum install gcc-c++
$ yum install swig

MeCab本体のインストール

$ git clone https://github.com/taku910/mecab.git
$ cd mecab/mecab
$ ./configure  --enable-utf8-only
$ make
$ make check
$ make install

辞書(ipadic)のインストール

$ cd ../mecab-ipadic
$ ./configure --with-charset=utf8
$ make
$ make install

mecab-python3のインストール

$ pip install mecab-python3

すると**「python setup.py egg_info” failed with error code 1」**というエラーが出ますので、こちらの記事を参考にしてソースコードを修正します。

ここで一度動かしてみます。

$ mecab
マスコットアプリ文化祭2018 でLINEBotを作りました。
マスコットアプリ        名詞,一般,*,*,*,*,*
文化    名詞,一般,*,*,*,*,文化,ブンカ,ブンカ
祭      名詞,接尾,一般,*,*,*,祭,サイ,サイ
2018    名詞,数,*,*,*,*,*
で      助詞,格助詞,一般,*,*,*,で,デ,デ
LINEBot 名詞,一般,*,*,*,*,*
を      助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
作り    動詞,自立,*,*,五段・ラ行,連用形,作る,ツクリ,ツクリ
まし    助動詞,*,*,*,特殊・マス,連用形,ます,マシ,マシ
た      助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
。      記号,句点,*,*,*,*,。,。,。

いい感じですね。仕上げにWeb新語辞書(mecab-ipadic-NEologd)をインストールして使わせていただきます。インストール方法と使い方に関しては、リンク先にとてもわかりやすく書いてありますので、そちらを確認してみてください。

辞書ファイルの場所を確認

$ echo `mecab-config --dicdir`"/mecab-ipadic-neologd"
/usr/local/lib/mecab/dic/mecab-ipadic-neologd/

もう一度動かしてみます。

$ mecab -d /usr/local/lib/mecab/dic/mecab-ipadic-neologd/
マスコットアプリ文化祭2018 でLINEBotを作りました。
マスコットアプリ        名詞,一般,*,*,*,*,*
文化祭  名詞,固有名詞,一般,*,*,*,文化祭,ブンカサイ,ブンカサイ
2018    名詞,数,*,*,*,*,*
で      助詞,格助詞,一般,*,*,*,で,デ,デ
LINEBot 名詞,一般,*,*,*,*,*
を      助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
作りました      名詞,固有名詞,一般,*,*,*,作りました,ツクリマシタ,ツクリマシタ
。      記号,句点,*,*,*,*,。,。,。

おお。「文化祭」が1つのワードに分類できていますね。
…ん?「作りました:名詞,固有名詞」…?
うーん…まあいっか。そういうラノベみたいなのがあるということで。。。

環境構築:TensorFlow

こちらの記事にあるリポジトリを使わせていただきました。ありがとうございます。

$ git clone https://github.com/higepon/tensorflow_seq2seq_chatbot.git

続いてTensorFlowをインストール。バージョンは1.1.0です。Windowsの1.1.0は存在しないようなので、LinuxかMacで環境構築をする必要があります。ライブラリ足らないよ~とエラーが出たら適宜インストールしてあげましょう。

$ pip install tensorflow==1.1.0

上記で作成した「tweets1M.txt」を配置

$ cd old
$ ls data
tweets1M.txt

結果出力用、ログ用ディレクトリを自分の好きな場所に変更

config.py
GENERATED_DIR = os.getenv("HOME") + "/chatbot_generated"
LOGS_DIR = os.getenv("HOME") + "/chatbot_train_logs"

「mecab-ipadic-NEologd」を使うようにする

data_processer.py
#tagger = MeCab.Tagger("-Owakati")
tagger = MeCab.Tagger("-Owakati -d /usr/local/lib/mecab/dic/mecab-ipadic-neologd/")

教師データをベクトル変換(たぶん)

$ python data_processer.py

config.pyで設定した「GENERATED_DIR」にテキストファイルがたくさん出来ていれば成功です。

機械学習を動かす

以上で機械学習を動かす準備が整いました。いよいよ実行させます。

$ python train.py

どばーっと表示されたあと

model initialized
Created model with fresh parameters.
done
before train loop.........................Saving checkpoint...done
global step 50 learning rate 0.5000 perplexity 2193.29
  eval: bucket 0 perplexity 5361.56
  eval: bucket 1 perplexity 7012.37
  eval: bucket 2 perplexity 7180.52
  eval: bucket 3 perplexity 7505.75
.........................Saving checkpoint...done
global step 100 learning rate 0.5000 perplexity 1133.63
  eval: bucket 0 perplexity 1019.29
  eval: bucket 1 perplexity 1380.75
  eval: bucket 2 perplexity 1601.04
  eval: bucket 3 perplexity 1791.01
.
.
.

と、延々と表示されれば成功です。「perplexity」が1に収束していきますので、そろそろかな~と自分が思ったタイミングでCtrl+Cで終了します。
「Saving checkpoint...done」と表示された時点でセーブされていますので、もう一度学習すると、その時点から再開されます。不意にマシンが止まったりしても数日間の処理結果がパァになったりはしません。逆に学習をイチからやり直したい場合は「GENERATED_DIR」をまっさらにすればOKです。

もし実行中に

model initialized
Created model with fresh parameters.
done
before train loop...killed

と表示された場合、メモリ不足のため実行できないよ…とPythonが悲鳴を上げているようです。あきらめて性能の良いマシンを使いましょう。

ちなみに私は最初、ConoHa VPSの2GBプランで動かそうとしたのですが、見事にこのエラーとなりました。メモリ不足ということはプランを上げれば大丈夫かな?と思い、一瞬だけ最上位プランにして動かしてみましたが、結果は同じでした。信じてたのにこのはちゃん…HPCプランはやく…

仕方なく手元にあった旧MacBook Airに同じ環境を構築して、あまり期待せず実行したら…動きました。処理自体はゆっくりですが、1週間ぐらいぶっ続けで動かしても、問題なく頑張ってくれています。

機械学習の結果を確認

できあがったモデルの動作を確認します。ドキドキの瞬間です。

$ python predict.py

すると、最終的に入力待ち状態になるので何か話しかけてみます。

> かわいい!
0 かわいいでしょ~っ!!
1 いえーい!やったね!
2 いえーいっ!👻
3 ありがとー~!
4 かわいいっ!やっね!
5 かわいいでしょっ!
6 いえーいっ~うっ!
7 ありがとーっ👻
8 かわいいでしょ~ね👻!!
9 かわいいでしょぁ!
10 かわいいー~た!ね!
11 いえーいっ!やっ!
12 かわいいでしょ~っ!
13 いえーいっっ!ね!

おお。…おばけ?

> このはちゃんかわいい!
0 やったー!うれしーがなるね!
1 やったー!うれしーがとー!っ!
2 やったー!うれしいっがなるね
3 やったー!うれしいーがっ
4 やったー!うれしーがなるっ!!聴い
5 やったー!うれしいっがなるっ!
6 やったー!うれしーがとー!ね!!
7 やったー!うれしーがよろしくね!
8 やったー!うれしーがとー!!
9 やったー!うれしいーっ!ね!
10 やったー!うれしいー~っ!
11 やったー!うれしいっがなるね!
12 やったー!うれしいっがとー!ね!
13 いえーい!うれしいっっ!
14 やったー!うれしいっがっ
15 やったー!うれしーがとー!ね!
16 やったー!うれしいーがね!
17 やったー!うれしーがよろしくっ!
18 やったー!うれしいっがとー

いえーい、の孤独感

> プロ生ちゃんかわいい!
0 うれしいっ!?ななだけど
1 うれしいっ!じゃあななだけど
2 うれしいっ!そうななだけど!
3 わーいっ!?なだけどこれからもかわいい
4 うれしいっ!?そうだけどこれから
5 うれしいっ!じゃあそうだけどもかわいいなぁ
6 うれしいっ!そうななだけど
7 うれしいっ!じゃあなだけどなぁ!なぁ

じゃあなだけどなぁ!なぁ(迫真

> 今日のお昼なに食べた?
0 あんまりちゃ終わっちゃないよ!!
1 あんまりうまくちゃ終わっないよ!
2 もううまくちゃ終わっないよ!!
3 あんまりうまくちゃ終わっないよ!!
4 あんまり・・・・ちゃ終わっないよ!!
5 あんまりろちゃ終わっないよ!!

まだ食べてないってことかい?

> 趣味はなんですか?
0 正確のさんじゅうはなれよ!はある!
1 モニターのコーンはなれよ!
2 正確のカラーはなれよ!
3 正確の開放はなれよ!てある!
4 正確のコーンはなれよ
5 正確のコーンはあるよ!
6 正確の開放はなれよ!はある!
7 正確の一応ませ!ない!てある
8 注文のコーンはない!ありがとない
9 正確のさんじゅうはある!ある
10 正確のさんじゅうはある!ある!!!
11 正確の開放は関係ない!
12 誤のコーンはあるよ!
13 正確の開放はなれよ!もある
14 正確の一応ませ!ない!てない!
15 誤の開放はなれよ!てあるよ
16 正確の開放はある!ある!!て

多種多様のコーン

面白いですね。日本語でおk状態ですが、そこがまたカワイイ…と思うのは逃げでしょうかね。今後少しずつ改善していきたいと思います。

LINEBotを用意

作成手順

  1. LINE Developersアカウント登録
  2. Webhook URL作成

LINE Developersアカウント登録

LINEBotを作るには、LINE Developersアカウントの登録が必要になります。特に審査はなく、難所もありませんが、こちらの記事がわかりやすくまとまっているので、ぜひ参考にしてみてください。

Webhook URL作成

Webhook URLとは、メッセージをリクエストで投げて、返事をレスポンスとして受け取るAPIのエンドポイントのことです。おそらく作り方は2通りあるかなと。

  • 自分のサーバーを使う
  • HerokuなどのPaaSを使う

個人的には前者をお勧めします。独自ドメインとSSLを自分で用意する必要はありますが、SSLはLet's Encryptで大丈夫ですし、PaaSと違って何も制約がないので好き勝手できますし。なにより、自分でサーバーをいじくり回すというのは、色々勉強になりますからね。というか、なりました(今回初めてnginx+Flaskを触りました)

エンドポイント周りの仕様については、LINE公式GitHubにSDKとサンプルがありますので、そのまま使えば良い感じに動いてくれます。あとはそこに、上記の「predict.py」を合体させればOKです。「EasyPredictor」という便利なクラスも用意されています。

  • *.txt
  • checkpoint
  • seq2seq.ckpt-XXXXX.meta
  • seq2seq.ckpt-XXXXX.index
  • seq2seq.ckpt-XXXXX.data-00000-of-00001

サーバーにデプロイするときは「GENERATED_DIR」の中にある上記ファイルを一緒にコピーしてください。「seq2seq.ckpt-XXXXX」は一番新しいもの(XXXXXの数値が一番大きいもの)だけで大丈夫です。5世代分出力されていますが、1世代分で1GB近くあるので、全部コピーしてしまうと無駄に容量を使ってしまいます。

まとめ

手軽にできますのでぜひ作ってみてください。同じキャラでも個性が出ればまったく違うBotになりますし、Bot同士をVR空間に集めて会話させたりするとなかなかカオスな絵面になって面白そうな気がします。
このBotについても、少しずつ良いものにしていきたいと考えていますので、もしよろしければ色々とご助言をいただけるとうれしいですmm

明日はmiunet0123さんの記事になります。楽しみにしています~

今後やりたいこと

  • スタンプや画像にも反応させる
  • サービス説明やチュートリアルに誘導させるようにしてみる
  • リプライ情報を水増しして教師データを増やしてみる
  • リプライ情報から最大限ノイズを消して機械学習にかける
  • そもそもちゃんと勉強してTensorFlow周りをいじれるようにする
  • Amazon EC2 P2 インスタンスを使ってみる
  • 汎化性能…
10
8
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?