モチベ
TwitterのBotである「しゅうまい君」はご存じだろうか。
私「パートさん胸おっきいね」
— しゅうまい君 (@shuumai) 2018年8月8日
言ってることはめちゃくちゃだが、構文はそれほど間違っていないあのシュールなBot。
あれを作りたい!!
フロー
1.他人のツイートから学習する
2.それを基にツイートする
わぁ、超簡単!
他人のツイートから学習する
今回はcsv形式のファイルから学習する。
(もちろんタイムラインから情報を持ってくることも可能。)
Twitterの「設定とプライバシー」を選択、「Twitterデータ」を選択、一番下の「Twitterデータをダウンロード」欄の「Twitter データをリクエスト」を選択すると、自分の過去の全ツイート情報を含むcsvファイルを入手できる。俺は友人からデータをもらった(優しい)
それを基にツイートする
Python3にはTwitterAPIを叩けるプログラム誰かさんが作っているのでそれを使う。
環境
RaspberryPi 2 Model B
Python3
importしたモジュールは以下の通り
import mysql.connector
import requests
from xml.etree.ElementTree import *
from requests_oauthlib import OAuth1Session
import json
import csv
import setting
import random
import urllib.parse
from time import sleep
import unicodedata
import re
from datetime import datetime
import settingはsetting.pyにセンシティブな情報を書き込んである。
後述するが、形態素解析にyahooAPIを使っている。
#yahoo
appId="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
#twitter
consumerKey="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
consumerSecret="XXXXXXXXXXXXXXXXXXXXXXXXXXXX"
accessToken="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
accesssTokenSecert="XXXXXXXXXXXXXXXXXXXXXXXX"
#mysql
user="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
password="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
host="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
database="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
原理
少し難しい単語が出てくるが、ざっくり説明する。
マルコフ連鎖
マルコフ連鎖は、一連の確率変数 X1, X2, X3, ... で、現在の状態が決まっていれば、過去および未来の状態は独立であるものである。形式的には、
Xi のとりうる値は、連鎖の状態空間と呼ばれ、可算集合S をなす。マルコフ連鎖は有向グラフで表現され、エッジにはある状態から他の状態へ遷移する確率を表示する。
例えば、「私はゲームが好きです」という文章と、「俺はゲームが嫌いだ」という文章があったとする。それぞれの文章を形態素解析すると次の通りになる。(中学時代にやった各文節ごとにわけるあれである)尚、文末にEOS(End of Sentence)を付加する。
「私/は/ゲーム/が/好き/です/EOS」
「俺/は/ゲーム/が/嫌い/だ/EOS」
これをマルコフ連鎖に基づいて表に起こす。
接頭語と接尾語とを分けるのだが、列数は3つがベーシックらしいのでそれでいく。
「私/は/ゲーム/が/好き/です」を表におこすと以下のようになる。
接頭語(prefix) | 接尾語1(suffix1) | 接尾語2(suffix2) |
---|---|---|
私 | は | ゲーム |
は | ゲーム | が |
ゲーム | が | 好き |
が | 好き | です |
好き | です | EOF |
3文節を右に1つずつシフトしてそれを表に追加していくイメージ。
この表に「俺/は/ゲーム/が/嫌い/だ」も同じ要領で追加していく。
同じレコードは追加しない。
接頭語(prefix) | 接尾語1(suffix1) | 接尾語2(suffix2) |
---|---|---|
私 | は | ゲーム |
俺 | は | ゲーム |
は | ゲーム | が |
ゲーム | が | 好き |
ゲーム | が | 嫌い |
が | 好き | です |
が | 嫌い | だ |
好き | です | EOS |
嫌い | だ | EOS |
基本はこの表を使って文章を作る。以下に例を示す。
「俺/は/ゲーム」の行から文章を作るとする。これが出力用の文章になる。
以降、この出力用の文章の末尾に単語を連結させていき、文章を完成させる。
次にprefixとsuffix1が「は/ゲーム」の行を探す。複数行あった場合はどちらか一つを選ぶ。
「は/ゲーム」の行は1つしかないのでそれを選ぶ。そして、その行のsuffix2を出力する文章の末尾に連結させる。すると、出力用の文章は「俺/は/ゲーム/が」になる。
次にprefixとsuffix1が「ゲーム/が」の行を探す。この検索条件に引っかかる行は2つあるが、
「ゲーム/が/好き」の行を選ぶとする。そして、その行のsuffix2を出力する文章の末尾に連結させる。すると、出力用の文章は「俺/は/ゲーム/が/好き」になる。
次にprefixとsuffix1が「が/好き」の行を探す。
「が/好き」の行は1つしかないのでそれを選ぶ。そして、その行のsuffix2を出力する文章の末尾に連結させる。すると、出力用の文章は「俺/は/ゲーム/が/好き/です」になる。
次に「好き/です」の行は1つしかないのでそれを選ぶ。そして、その行のsuffix2を出力する文章の末尾に連結させる。すると、出力用の文章は「俺/は/ゲーム/が/好き/です/EOS」になる。
EOSに到達したので文章の作成は終了。これが出力となる。
「私はゲームが好きです」
「俺はゲームが嫌いだ」
の2つの文章から
「俺はゲームが好きです」という文章を生成できた。
ツイートをデータベースに登録するフロー
csvの1レコードは次の通りで使うのはphpMyAdmin。
"tweet_id","in_reply_to_status_id","in_reply_to_user_id","timestamp","source","text","retweeted_status_id","retweeted_status_user_id","retweeted_status_timestamp","expanded_urls"
リプライやリツイートには、個人情報が含まれていることがあるので、除外する。つまり、in_reply_to_status_idとretweeted_status_idとが空欄のレコードからのみ学習する。
データベースの仕様
Twitterではもちろん絵文字を使われることを想定しないといけないので文字コードは
utf8mb4にする。
テーブル仕様
通常の文節を格納するテーブル、emojiテーブル、startテーブル、replyテーブルを用意してある。emojiテーブルはprefixが絵文字のレコードが格納される。startテーブルは文章の開始三文節を格納する。replyテーブルはBot宛にきたreplyIDを格納する。
通常テーブル
prefix | suffix1 | suffix2 |
---|---|---|
CHAR(50) | CHAR(50) | CHAR(50) |
emojiテーブル
prefix | suffix1 | suffix2 |
---|---|---|
CHAR(50) | CHAR(50) | CHAR(50) |
startテーブル
prefix | suffix1 | suffix2 |
---|---|---|
CHAR(50) | CHAR(50) | CHAR(50) |
replyテーブル
id |
---|
CHAR(50) |
データ格納規則
テーブル名は文章の頭文字にする。また文章自体の頭3文節だけをとったテーブルstartも作る。
例えば、「私/は/ゲーム/が/好き/です/EOS」は
「start」テーブルに「私/は/ゲーム」を格納する。
「は」テーブルに「は/ゲーム/が」を格納する。
「ゲ」テーブルに「ゲーム/が/好き」を格納する。
「が」テーブルに「が/好き/です」を格納する。
「好」テーブルに「好き/です/EOS」を格納する。
プログラム
全体はGitHubにあげてるので、きになったらここからみてください。主要な部分だけをかいつまんで説明します。
addFromCSV関数
twitterから得られるcsvファイルから学習するための関数。
引数のpathにcsvまでのパスを渡す。
def addFromCSV(self,path,offset=0):
"""csvを読みこんでDBに登録する\n
csvファイルはutf-8(BOM無し)\n
何行目から登録するかのoffset"""
tweet_id=[]
in_reply_to_status_id=[]
in_reply_to_user_id=[]
timestamp=[]
source=[]
text=[]
retweeted_status_id=[]
retweeted_status_user_id=[]
retweeted_status_timestamp=[]
expanded_urls=[]
self.initializeDB()
csvFile=open(path,"r")
f=csv.reader(csvFile)
cnt=0
for row in f:
tweet_id.append(row[0])
in_reply_to_status_id.append(row[1])
in_reply_to_user_id.append(row[2])
timestamp.append(row[3])
source.append(row[4])
text.append(row[5])
retweeted_status_id.append(row[6])
retweeted_status_user_id.append(row[7])
retweeted_status_timestamp.append(row[8])
expanded_urls.append(row[9])
#リプ,RTは除く
if(row[1]=="" and row[6]==""):
print(row[5])
cnt+=1
if(offset<cnt):
self.addStringToDB(row[5])
isEmoji関数
stringの1文字目が絵文字かどうかを判定する。
def isEmoji(self,string):
"""stringの頭文字が絵文字かどうか"""
head=self.getInitial(string)
print(unicodedata.category(head))
if(unicodedata.category(head)=='So' or unicodedata.category(head)=='Cn'):
return True
else:
return False
tweet関数
引数のstringをツイートする。
「環境」見出しの部分で説明したけど、パスワードなどのセンシティブな情報はsetting.pyから取得している。
#tweetする
def tweet(self,string):
url = "https://api.twitter.com/1.1/statuses/update.json"
params = {"status": string}
twitter = OAuth1Session(setting.consumerKey, setting.consumerSecret, setting.accessToken, setting.accesssTokenSecert)
req = twitter.post(url, params = params)
if req.status_code == 200:
self.logging(string)
print ("OK")
else:
print ("Error: %d" % req.status_code)
MorphAnalyze関数
stringを形態素解析して、分割後リストで返す。
URLには個人情報が含まれる可能性があるので除外している。
#stringを形態素解析してarrayで取得
#URLは解析対象外
#返り値にURLを付加するかは考え中。今は除外する。
def MorphAnalyze(self,string):
flg=False
escape=""
URLpos=[]
prepare=string.split()
#URLの位置を記憶
for i in range(len(prepare)):
if self.isURL(prepare[i]):
flg=True
URLpos.append(i)
#URL部分を削除し、元の文章の復元
for i in range(len(prepare)):
if not self.isURL(prepare[i]):
escape+=prepare[i]+" "
string=escape[:-1]
#print(string)
requestURL = "https://jlp.yahooapis.jp/MAService/V1/parse"
parameter = {'appid': setting.appId,
'sentence': string,
'results': 'ma'}
r = requests.get(requestURL, params=parameter)
elem = fromstring(r.text.encode('utf-8'))
array=[]
for e in elem.getiterator("{urn:yahoo:jp:jlp}surface"):
array.append(e.text)
#以下のコメントアウトを外すとURLも含む
#if flg:
# for i in range(len(URLpos)):
# array.append(prepare[URLpos[i]])
return array
addStringToDB関数
DBにstringを形態素解析して、登録する。登録する規則は上述した通り。
使われてる他の関数はGitHubにあげてるから割愛。
def addStringToDB(self,string):
"""DBにstringを登録する"""
array=self.MorphAnalyze(string)
if len(array)!=0:
self.addArrayToDB(array)
getTL関数
タイムラインから最新からnumberまでのツイートをリストで取得する。
#TL最新number件のtweetを取得する
#1<=number<=200
def getTL(self,number):
url = "https://api.twitter.com/1.1/statuses/home_timeline.json"
params = {"count":number}
twitter = OAuth1Session(setting.consumerKey, setting.consumerSecret, setting.accessToken, setting.accesssTokenSecert)
req = twitter.get(url, params = params)
array=[]
if req.status_code == 200:
timeline = json.loads(req.text)
for tweet in timeline:
array.append(tweet["text"])
else:
print ("Error: %d" % req.status_code)
return array
getAllNoun関数
引数のstringに含まれている名詞をすべて取得し、リストで返す。
#string中の名詞すべてを取得する
def getAllNoun(self,string):
requestURL = "https://jlp.yahooapis.jp/MAService/V1/parse"
parameter = {'appid': setting.appId,
'sentence': string,
'results': 'ma',
'filter':'9'}
r = requests.get(requestURL, params=parameter)
elem = fromstring(r.text.encode('utf-8'))
array=[]
for e in elem.getiterator("{urn:yahoo:jp:jlp}surface"):
array.append(e.text)
return array
stringGenRandom関数
DBから文章をランダムで作成する。
これがメイン。これから得られた文章をツイートする関数にぶち込んで完了。
def stringGenRandom(self):
"""ランダムに文字列を生成する"""
sql="select * from start order by rand() limit 1;"
row=self.getSQL(sql)
prefix=row[0]
suffix1=row[1]
suffix2=row[2]
sentence=self.stringGenHint(prefix,suffix1,suffix2)
return sentence
感想
仲間内で作ったBotなので、結果は見せられないんだけど、超うまくいってる。
文章を作成するアルゴリズムの部分だけでも理解してくれるとうれしい。