自然言語処理入門としての「botの作り方」


この前の記事とかツイートででぼやいてたことを、記事にして供養します。
本当は中学生くらいを対象にした教育動画として公開したかったんだけど、ちょっと作ってる余裕が無いのでとりあえず記事にします。

本記事は以下の内容で構成されています。


使用する言語はpythonです。
もしpythonを触ったことが無い、という方は以下のサイトを参考にすこしだけ勉強するとストレスなく読めると思います。

Python入門

0. 自己紹介

山奥にある研究室で自然言語処理学の勉強をしている熊です。
研究室の名前が偉大すぎて最近能力を過大評価されがちだけど、ぶっちゃけただの文系のポンコツ
学部では理論言語学を勉強していました。専門は統語論でした。英語の教員免許を一応持っています。
数学は苦手です。よろしくお願いします。


1. はじめに

この記事ではtwitterで散見されるbotを作ってみることで、言語処理の面白さを体験することを目的としています。

主に文系プログラマの卵をターゲットとしており、特に

・数学がちょっとよくわからない
・プログラミングが少しだけできる
・最近言語処理に興味を持って色々記事とか読んでる
・でも数式ばっかりでよくわからねえよ!!!

という方を対象にしています。そう、そこで頷いてるあなたです!

僕自身、学部2年のころに言語処理に興味を持ち、プログラミングを始めました。
でも言語処理系の記事の数式って謎なんですよね。最初の一年はつらい思いをしてました。
なにかとっかかりがあれば数式もイメージがしやすいのですが、最初のとっかかりってのは中々訪れません。
この記事が「よくわからないけどわかりたい」という人の役に立てばと思います。

数式や証明の類は一切載せませんので、数式のほうがわかりやすい!という方にはお勧めできない記事となっています。
上でも述べた通り、実装はpythonで行います。
4節まではプログラミングの話題に触れないので、もしプログラミング未経験の方は3節までを読み、あとは結果だけ見てもいいと思います。

ソースコードgithubに載せておいたので、みながら動かしてみてください。
https://github.com/mentaikoguma/bot_tutorial


なお、この記事ではツイッターとの連携部分については説明しません。
あくまでも、文生成についてのみ説明します。


2. botの分類

最初に、ここでいうbotとは何か、どのようなものがあるのかをまとめておきます。

ここでのbotとは、文章を出力するプログラムの事を指します。
例えばボタンを押すたびに何かしらの文が表示される、twitterで00分になると自動で文がツイートされるものなどをbotと呼ぶことにします。
そのうえで、botを二種類に分類します。

a. 記述型

作者があらかじめ用意した複数の文から、ランダム(あるいはルールに基づいて)ひとつを出力するものを、このように呼ぶことにします。
具体的には以下のbotなどが参考になります。

ワイト (@waightthinksso) | Twitter

リラックマfakebot (@rilakkuma_bot) | Twitter

これらは事前にセリフなどを用意しておき、それをランダムにひとつ選ぶことでツイートを行っています。
@waightthinkssoはテキストが一つしかない例、@rilakkuma_botリラックマの絵本の中のセリフを用意しているようです。
リラックマbot、いつまで生き延びるのかな)

また、記述型を応用することで対話っぽい事が出来ます。
「おはよう」「ねむいね」などのテキストを用意しておき、「おはよう」を含む文には「おはよう」と返すようプログラミングすれば、なんとなく会話をしている気分になります。
興味がある方は作ってみるといいと思います。テキストの用意がすごくつらいです。*1

ちなみに、記述型のtwitter botwebサービスを用いて簡単に作ることができます。
もしプログラミングなんてしたくないわ!って方は以下のサービスを利用してみてください。
(詳細な使い方はここでは行いません。)

twittbot - enjoy


b. 生成型

記述型と違い、作者が事前に文を用意しません。
例えばtwitterのタイムラインを1時間分溜めておき、それをもとに学習・文生成を行うものが挙げられます。
事前に文を用意しないので、同じ文が出力されることはほとんどありません。
「どのような文が生成されるのか」という点で非常に見ていて面白いbotとなります。

しゅうまい君 (@shuumai) | Twitter

最も有名なこの手のbotはしゅうまい君でしょう。
しゅうまい君はある一定時間のあいだ、フォローしているユーザーのツイートを分析、学習し、それをもとに文を作っています。
ご存知の方も多いと思いますが、非常に人間らしい文が生成されます。
この記事では、しゅうまい君と同じ挙動をするbotを作ることを目標とします。
詳細は次の節からお話します。


3. 文の生成

この節は、2節で紹介した生成型botがいかにして文を生成しているのかについて理解してもらうことを目標としています。
まず、しゅうまい君のホームページに書いてある挙動について紹介します。

しくみ
1.フォローしている人の発言を拾って形態素にばらして溜める
2.マルコフ連鎖で複数の短文を自動生成
3.適当な短文を選んでTwitterにつぶやく
(略)

…どうでしょうか。
この記事では自然言語処理の知識がほぼゼロ、という方を対象にしているので用語の説明をしっかりと行います。
(引用部分を見ただけで何を作ればいいのかわかったぜ!という方はもう読まなくて大丈夫です。)


形態素

言語の意味を持つ最小単位を形態素と呼びます。
例えば、「形態素」「と」「呼び」「ます」「。」などが形態素になります。
形態素にばらす」とは、単語分割を指しています。*2
というのも、日本語は英語と違って単語の切れ目がスペースによって明示されていないので、機械が処理するには少し不便なのです。
分割の例を以下に示します。

神様、僕は気づいてしまった*3
  ↓
「神様/、/僕/は/気づい/て/しまっ/た」

/によって形態素が区切られ、どれが単語なのかが分かるようになりました*4
これを単位として、言語処理を行います。
ちなみに、分割はpythonのパッケージをもちいて簡単に行えます。


マルコフ連鎖

これが生成部分を指す用語になります。自然言語処理では特に言語モデルとか、n-gramとかって言われることが多いです。
情報系であればよくご存じの単語だと思いますが、簡単に言うと

直前の数単語から次の単語を予測するという生成方法です。

この記事ではこの部分に重点を置いてしっかり解説したいと思います。
下線部の説明でもうわかったぜ!という方は、このまま実装の節まで進みましょう。


マルコフ連鎖(あるいはn-gram言語モデル)を体験してもらいます。
(数式を一切用いず説明するため、ここからの説明は冗長になります。)

Q. 以下の空欄を適当な形態素(単語)で埋めよ。ただし空欄以降も文が続く可能性がある。

私は(  )

空欄にはいろいろ入ると思います。
例えば「私は太郎」「私はペン」「私は食べ」などがあり得ますよね。
それぞれ、「私は太郎です」「私はペンを持っている」「私は食べることが好きです」のように派生する可能性があります。
ここでは「太郎」を入れたことにしてもう一度同じ問題を考えてもらいます。

Q. 以下の空欄を適当な形態素(単語)で埋めよ。ただし空欄以降も文が続く可能性がある。

私は太郎(  )

ここでの解答例は「私は太郎を」「私は太郎が」「私は太郎の」「私は太郎を」などになるでしょう。
間違っても「私は太郎ペン」や「私は太郎食べ」とはしませんよね。

カッコ内にどのような単語を入れるべきかは、直前の数単語に強く依存していることが分かると思います。
より一般化して、「次の状態は、直前の状態によって決定される」という状況をマルコフ性と呼びます。
これを繰り返すことで、一つの状態の遷移(ここでは単語列)が生成されます。この連鎖の事をマルコフ連鎖と呼びます。


実際に実装する際には、「ある数単語の後にどの単語が出現したか」の頻度辞書を作り、生成を行います。
上の形態素の例だと、以下のような辞書ができあがります。

「神様/、/僕/は/気づい/て/しまっ/た」

<BOS><BOS> 神様:1
<BOS>神様  、:1
神様、   僕:1
、僕    は:1
僕は    気づい:1
は気づい  て:1
気づいて  しまっ:1
てしまっ  た:1
しまった  <EOS>:1

ここで、<BOS>と<EOS>はbegin/end of sentenceで、文頭と文末を表す特殊記号として扱われるものです。
また:1はそれぞれの単語が、直前の二単語の次に1回現れたことを表しています。
すなわちこの辞書は、
文頭として「神様」が1回
「(文頭)-神様」に続いて「、」が一回、
「神様-、」に続いて「僕」が一回
...
という情報を表していることになります。

このように、直前の二単語の次にどの単語が来るかを数え上げるものを3-gram(tri-gram)言語モデルと呼びます。
(直前のn-1単語から次の語を予想する。)

少しはしっくり来たでしょうか。
次の節ではこれらを実際に実装してみましょう。


4. 実装

言語はpythonを用います。
記事の最初にある通り、一応ざーっと基本的な動作を確認してから実装してみることをお勧めします。
命名規則Java臭がありますが、大目に見てください。
また、エンジニアとしてのお作法が成ってないってのも見逃してください。

データセット

本当はツイートデータを使って実装できればいいのですが、ツイートのデータは二次配布ができないので青空文庫で我慢しましょう。
この記事の内容を実装してみて、さらにツイートでも同じことをやってみたいという方は、twitter developerとか使ってクロールしてください。
(詳細の説明は今回はしません。)

Welcome — Twitter Developers


今回使うデータセットは、夏目漱石の以下の文献にします。
・それから
吾輩は猫である
・こころ

上のテキストをすべてまとめたsoseki.txtをこちらからダウンロードして使ってください。
bot_tutorial/soseki.txt at master · mentaikoguma/bot_tutorial · GitHub

これを使って、今回は実装します。

Janome形態素解析ライブラリ)

前の節で単語分割についてお話しました。
pythonで単語分割をするにはJanomeというパッケージが必要です。

公式
Welcome to janome’s documentation! (Japanese) — Janome v0.3 documentation (ja)

解説
python で形態素解析。Janome が簡単。pip 一発でインストール | コード7区


基本的にはpipで入ります。

pip -install janome

windowsの場合はpipが使えないことがあるので、以下の記事を参照して頑張ってみてください。
windows環境のPython3.4でpipをつかってパッケージをインストールする - 意味悲鳴


学習部分を作る

これで準備が整いました。
あとはゴリゴリ書いていきましょう。
再掲しますが、githubにコードを載せてあります。
これを上から順になぞっていきます。
一応メソッドごとに区切って載せていきます。

import random
from collections import defaultdict
import janome
from janome.tokenizer import Tokenizer

インポートは上の通りです。ほかは使いません。
janomeは単語分割、defaultdictは辞書の初期化、randomは生成部分で使います。



●テキスト読み込み部分

def read(path):
  f = open(path, 'r')
  arr = []
  for line in f:
    arr.append(line.strip()) #改行文字を削除して配列に入れていく
  return arr

先ほど作ったsoseki.txtや、あとで作られる辞書ファイルを行ごとに読み込みます。
strip()では改行文字を削除しています。


●辞書作成部分

#readで読み込んだarrをdataとして受け取り、n-gram辞書を作る
def getDictOf(data, n):
  ngramDict = defaultdict(lambda:defaultdict(lambda:0)) #辞書の辞書を初期値0で設定
  t = Tokenizer() #Janomeをセット
  for sentence in data: #各文ごとに
    words = t.tokenize(sentence, wakati=True) #文を単語のリストにする
    for i in range(n-1): #<BOS> <EOS>を追加
      words.insert(0, '<BOS>')
    words.append('<EOS>')
      
    for indice in range((n-1), len(words)):
      biginOfPrev = indice - (n-1)
      prevWords = ''.join(words[biginOfPrev:indice]) #indice番目の単語の直前n-1単語の連結
      targetWord = words[indice] #indice番目の単語
      if prevWords.strip() and targetWord.strip(): #直前の単語、indice番目の単語がともに空白じゃないとき
        ngramDict[prevWords][targetWord] += 1 #辞書の値を++
  return ngramDict

生成に使う辞書を作る部分です。
上で説明した通り、n=3(3-gram / tri-gram)の時には直前2単語から次の単語を予測します。
つまり、ある二単語の次にどの単語が何回現れたかを辞書として記録すれば、確率としてあとで使うことができます。
defaultdictは初期値を設定するもので、よく使うメソッドです。
ここでは辞書のvalueを辞書にし、そのvalueを0として設定しています。
また、<BOS>をn-1個挿入することで、文頭のtri-gram確率を計算できるようにしています。
(先ほどの"<BOS><BOS> 神様:1"を思い出してください。)


●辞書保存部分

def write(path, data):
  f = open(path, 'w')
  for key1 in data:
    line = '%s\t' % key1
    for key2 in data[key1]:
      # key1[tab]key2/:/value/,/key2/;/value という形で保存する
      line += '%s/:/%d/,/' % (key2, data[key1][key2])
    line = line[:-3]
    f.write(line+'\r\n')

作成した辞書を保存します。
今回くらいの辞書であれば、毎回作ってもそこまでストレスではありません。
しかし、テキストが大きくなる場合は辞書を作るだけで数十秒、数分かかります。
そのため、一度作った辞書は外部に保存し、あとで読み込み直した方が便利です。
writeメソッドでは、辞書の直前n-1単語部分をkey1、直前単語が直後にとりうる単語をkey2としてforを回しています。
後でsplitして読み込みやすいよう、/:/、/,/という適当な記号で区切っておきます。*5

一度プログラムを動かしてみましょう。
ここまでのメソッドの下に以下のように記述し、プログラムを動かしてみてください。

n = 3
data = read('soseki.txt')
write('%d-gram.txt'%n,getDictOf(data, n),)

'%n-gram.txt'%nは、n-3のとき"3-gram.txt"という名前になります。
出来上がった辞書は以下のようになっているはずです。
(ソートをしていないため、作成するたびに辞書の見出しの並びは変わってしまいますが、大体一緒ならうまくいってるはずです。)

がなるべく	押し/:/1
「乱暴	だ/:/1
だけ感じ	た/:/1
という大丈夫	な/:/1
ず寐	る/:/1
真面目くさって	聞く/:/1/,/述べる/:/1
...
生成部分を作る

今度は作った辞書をもとに生成部分を作っていきます。

●辞書読み込み部分

def readDict(path):
  ngramDict = defaultdict(lambda:defaultdict(lambda:0))
  data = read(path)
  for line in data:
    key1, contents = line.split('\t')
    contents = contents.split('/,/')
    for content in contents:
      key2, value = content.split('/:/')
      ngramDict[key1][key2] = int(value)
  return ngramDict	

先ほどのwriteメソッドの逆の手続きで、辞書を読み込みます。


●生成部分

def generate(ngramDict, maxLength, n):
  sentence = ['<BOS>' for i in range(n-1)]  #最初の一単語を予測するためにn-1個の<BOS>で埋める
  while True:
    prevWords = ''.join(sentence[-(n-1):]) #現在の最後のn-1単語
    wordCands = [] #単語候補を入れるリスト
    for key2 in ngramDict[prevWords]:
      for i in range(ngramDict[prevWords][key2]):
        wordCands.append(key2) #辞書にある数字だけ単語候補を入れる
    if wordCands:
      random.shuffle(wordCands)		
      sentence.append(wordCands[0]) #シャッフルして一つ取り出し、文に追加
		
      if wordCands[0] == '<EOS>': #<EOS>を取り出したら文終了
        return ''.join(sentence[n-1:-1]) #<BOS><EOS>を取り除いて連結し、リターン
      elif len(sentence) > maxLength:
        return generate(ngramDict, maxLength, n) #指定した文字数を超えたら生成し直し
    else:
      return generate(ngramDict, maxLength, n) #単語候補が無い場合は生成し直し
  return none

いよいよ生成部分です。これさえかければもうbotは完成します。
上から順に追っていきましょう。
先ほど読み込んだ辞書を使って、文の生成を行います。

次に最初の一語を選ぶために、文を<BOS>で満たします。tri-gramの場合、<BOS><BOS>に続く単語が文頭になります。*6
辞書から<BOS><BOS>に続く単語を取り出し、wordCandsに追加していきます。
このとき、辞書にある出現回数ぶんだけ追加することが大切です。
文頭になりうる単語をすべてwordCandsに追加したとき、リスト内は「学習データでより多く観測された文頭の単語ほどたくさん入っている」という状態になっています。
これをシャッフルし、一つ取り出すことで、観測した出現頻度から作った確率分布のもとで抽選を行うことと等しくなります。

たとえば辞書の<BOS><BOS>の値が以下のようであったとします。

 私:4, 太郎:3, リンゴ:2, 見る:1

このとき、文頭になりうるwordCandsの中身は以下のようになります。

wordCands = [私,私,私,私,太郎,太郎,太郎,リンゴ,リンゴ,見る]

ここから、ランダムに一つ取り出すことで文頭を決めます。
必然的に、「私」を取り出す確率が高くなることは直感に従うでしょう。

これを繰り返し、文を生成していきます。
場合によっては無限に文が続いてしまうことがあるため、最大文字数は必ず設定するようにしましょう


あとは、これを適当に生成するようにプログラムを書くだけです。
例えば10個の文章を生成してほしければ、以下のように記述します。
maxLengthはツイートサイズと同じ140としました。

n = 3
maxLength = 140
ngramDict = readDict('%d-gram.txt'%n)
for i in range(10):
  print(generate(ngramDict, maxLength , n))


エンターキーを押すたびに生成するタイプならこうなります。

n = 3
maxLength = 140
ngramDict = readDict('%d-gram.txt'%n)
while True:
  s = input()
  if s == 'exit':
    break
  print(generate(ngramDict, maxLength , n))


生成結果の一例です。

「そりゃ無論さ。すると勝手の方にまだ這入って行く様に話す話さないのです。彼らは真
直に自白します。

「ええ顔を鏡で、どうだと主張する。主人は無言の感謝を改めて「さっきから云わんより
寧ろあの時の君の声で、滑稽と崇高の大差を来たした。

母は私の従妹に当る人のために」

こんな苦情をいうように読み出す。「それが唯動くものと思っていた」

私は退屈そうにもならず、真白なシャツに卸立ての四つばかりの金を貰い、三千代のいる
学校はどうしたら三寸も離れて、広い若葉の園は再び出ているようでした。

「いい積りだのかといったら、月桂寺さんは大に活動して嘆願に及んで来て、巻煙草の吸
い殻を蜂の巣のごとくにこにこと落ちつき払っているが、)父は、大分変って来ました。
私はそれを知っているとしか見えなかった。

よく分からない文ですが、前後3単語程度の範囲だけを見れば文法的になっていることが分かります。
今回はデータが夏目漱石なので硬い雰囲気になっていますが、ツイートなどで辞書を作れば当然、ツイートっぽい文を生成してくれます。


5. まとめ

この記事ではbotについて紹介し、中でも生成型のbotについて「数式を除いて」説明しました。
すこしでも理解の助けになればと思います。
もちろん、この記事でなんとなくイメージが付いたら、つぎは数式の理解のステップに進んでもらいたいと思います。
基本的な自然言語処理の用語や実装は、グラム先生のこちらのチュートリアルがとてもためになります。
Graham Neubig - チュートリアル資料

ちなみにマルコフ連鎖には重大な問題点があります。
ツイッターのデータを集めて学習するとき、その性質上
「元のツイートと全く同じ文」が生成されてしまうことが良くあります。
これを解決するためには、また別の言語モデルを使ったり、生成方法を変えてみたりする必要があります。
もし興味のある方は、この辺も考えて手直ししてみるといいでしょう。


僕もまだまだ初学者ですが、かつて躓いてた自分に説明するような感じでまた記事を投稿すると思うので、その時はまたお願いします。
「これの概念がよくわからん」みたいなメッセをついったーとかに送ってくれたら、僕の理解の範囲で説明してみるかもしれません。

最後に、この記事を読んでいて「ここがわからなかった」という部分があったらコメントで指摘してください。
僕も伝えることの練習として記事を書いているので、改めてその部分を書き直してみます。

では、よい言語ライフを。

*1:記述型のbotで有名なものとしてELIZAが挙げられます。

*2:余談ですが、僕は単語分割のタスクで研究しています。

*3:神様、僕は気づいてしまった - CQCQ - YouTube

*4:分割方法について興味がある方は、こちらのチュートリアルを読んでみてください。

*5:ツイートなどを扱う場合は、/:/や/,/といった文字列がテキストに含まれてる場合は弾く、といった処理をしなければ、読み込みの段階でバグってしまいます。

*6:別に<BOS>に続く単語でも確率は変わりませんが…

広告を非表示にする