Python初心者に送る「人工知能の作り方」 Part4
前回はリスト内包表記と正規表現を使って新しい思考エンジンを作りました。しかし依然として辞書はユーザーが手作業で作らなければならず、チャットをしても新しい発見があるとは思えません。
今回はDictionary
に学習機能を組み込みます。Python的にはファイルへの書き込みと、形態素解析ライブラリを使ってみます。今回のソースコードはこちら
真・オウム返し
学習と一口に言ってもいろいろな方法があります。そのうちもっとも基本的なものとして、「聞いた言葉をそのまま返す」というものがあります。ユーザーが発言するたびに、その発言をランダム辞書へ追加していくというアプローチにしましょう。会話すればするほどデータが増えるので、賢くなるかどうかはわかりませんが、語彙は増えるはずです。
まずはDictionary
クラスにstudy
メソッドとsave
メソッドを追加しましょう。
class Dictionary: ...中略... def study(self, text): """ユーザーの発言textをメモリに保存する。 すでに同じ発言があった場合は何もしない。""" if not text in self._random: self._random.append(text) def save(self): """メモリ上の辞書をファイルに保存する。""" with open(Dictionary.DICT_RANDOM, mode='w', encoding='utf-8') as f: f.write('\n'.join(self.random))
open
関数にmode='w'
を指定しています。ファイルへの書き込みを表す引数で、ファイルが無ければ作成し、存在すれば書き込み前に中身をクリアします。
write
は文字列をファイルへ書き込むメソッドです。引数の'\n'.join(self.random)
というのは、self.random
というリストを'\n'
で連結した文字列を返します。'\n'
は改行を表すエスケープシーケンスです。
>>> '+'.join([1,2,3]) '1+2+3' >>> '\n'.join(['ありおり', 'はべり', 'いまそがり']) 'ありおり はべり いまそがり'
Unmo
クラスに反映させましょう。ついでに、Responderの切り替えも完全ランダムではなく、「60%の確率でPatternResponder
、30%の確率でRandomResponder
、10%の確率でWhatResponder
」を選ぶように改造します。
from random import choice, randrange ...中略... class Unmo: ...中略... def dialogue(self, text): """ユーザーからの入力を受け取り、Responderに処理させた結果を返す。 呼び出されるたびにランダムでResponderを切り替える。 入力をDictionaryに学習させる。""" chance = randrange(0, 100) if chance in range(0, 59): self._responder = self._responders['pattern'] elif chance in range(60, 89): self._responder = self._responders['random'] else: self._responder = self._responders['what'] response = self._responder.response(text) self._dictionary.study(text) return response def save(self): """Dictionaryへの保存を行う。""" self._dictionary.save()
dialogue
メソッドでは、まず0から100の範囲で乱数を取得します。その乱数が0から59の間である場合はPatternResponder
に、60から89の間であればRandomResponder
、それ以外であればWhatResponder
という条件分岐を行っています。
Dictionary
オブジェクトへの学習はユーザーの入力があるたびに行いますが、Responderのresponse
呼び出しの前に学習させてしまうと、覚えたばかりの発言を返してくる可能性があります。それを避けるため、一旦response
に応答を保存してから学習させています。
save
メソッドはDictionary
クラスのsave
を呼び出すだけのつなぎ役です。学習はメモリ上で行うので速いのですが、ファイルへの保存を発言のたびに行っていたのではファイルIOオーバーヘッドが発生し、辞書ファイルが巨大になればなるほど動作が重くなってしまいます。
そこでインターフェイスからプログラムの終了を検知したときにsave
を呼び出すようにしたいわけです。とはいえ、main.py
からproto._dictionary.save()
と呼び出すのは長くなりますし、Unmo
クラスの仕様が変わったときに困ります。proto.save()
と呼び出したほうが自然に見えますね。
というわけでmain.py
はこんな感じです。
if __name__ == '__main__': print('Unmo System prototype : proto') proto = Unmo('proto') while True: text = input('> ') if not text: break response = proto.dialogue(text) print('{prompt}{response}'.format(prompt=build_prompt(proto), response=response)) proto.save() # 追加
これでプログラム終了時に自動保存してくれるようになりました。
形態素解析
文章をまるごと覚えるのでは個性がありませんので、形態素解析して学習させてみたいですね。形態素というのは「意味を持つことができる最小単位の単語」で、「あたしはプログラムの女の子です」という文章は次のような形態素に分解できます。
あたし 名詞
は 助詞
プログラム 名詞
の 助詞
女の子 名詞
です 助動詞
この学習を活用して、パターン辞書に保存することにしましょう。フォーマットはこんな感じ。
あたし あたしはプログラムの女の子です
プログラム あたしはプログラムの女の子です
女の子 あたしはプログラムの女の子です
これでまた「プログラムの女の子って性別あるのかな」といった発言をすると、
あたし あたしはプログラムの女の子です
プログラム あたしはプログラムの女の子です|プログラムの女の子って性別あるのかな
女の子 あたしはプログラムの女の子です|プログラムの女の子って性別あるのかな
性別 プログラムの女の子って性別あるのかな
といった感じで、関連キーワードごとにどんどん辞書が膨らんでいきます。予想外の返事を求めるアプローチとしては中々のものですね。
Janome
Pythonで形態素解析を行うにはjanome
というパッケージを使うと簡単です。pip
経由でインストールできます。
pip install janome
ちょっと試してみましょう。Pythonシェルを開きます。
$ python >>> from janome.tokenizer import Tokenizer >>> t = Tokenizer() >>> tokens = t.tokenize('あたしはプログラムの女の子です') >>> [print(token) for token in tokens] あたし 名詞,代名詞,一般,*,*,*,あたし,アタシ,アタシ は 助詞,係助詞,*,*,*,*,は,ハ,ワ プログラム 名詞,サ変接続,*,*,*,*,プログラム,プログラム,プログラム の 助詞,連体化,*,*,*,*,の,ノ,ノ 女の子 名詞,一般,*,*,*,*,女の子,オンナノコ,オンナノコ です 助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
動作してるっぽいですね。
学習するパターンの選別
形態素解析を行い、[(表層形, 品詞)]
の形にして返すメソッドanalyze
を実装しましょう。tokenize
メソッドで得られる情報は多岐に渡るので、必要な情報のみ抜き出します。
(x,y)
という形式はタプルといい、いわば変更不可能なリストです。リストよりも動作速度に優れ、また中身を変えたくない場合に用いられます。
dictionary.py
を見てみましょう。
import re from janome.tokenizer import Tokenizer
まずはファイルの先頭でre
とjanome
をインポートしています。re
は学習するパターン(形態素)の品詞判定に使います。
名詞はともかく、「は」や「です」といった助詞まで辞書に記録していると、逆に脈絡が無くなりそうです。そこで今回は名詞のみ、それも『一般』『固有名詞』『サ変接続』『形容動詞語幹』に分類される名詞のみを学習させることにします(『三』などの漢数字が『名詞・数』として認識されるため)。
class Dictionary: TOKENIZER = Tokenizer() ...中略... @staticmethod def analyze(text): """文字列textを形態素解析し、[(surface, parts)]の形にして返す。""" return [(t.surface, t.part_of_speech) for t in Dictionary.TOKENIZER.tokenize(text)] @staticmethod def pattern_to_line(pattern): """パターンのハッシュを文字列に変換する。""" return '{}\t{}'.format(pattern['pattern'], '|'.join(pattern['phrases'])) @staticmethod def is_keyword(part): """品詞partが学習すべきキーワードであるかどうかを真偽値で返す。""" return bool(re.match(r'名詞,(一般|代名詞|固有名詞|サ変接続|形容動詞語幹)', part))
is_keyword
は、渡された品詞情報が学習に値するものであるかどうかを判断します(そのためre
モジュールを使っています)。r''
というリテラル文字列はエスケープシーケンスを無視します。'\\n'
とr'\n'
は等価です。bool
で囲んでいるのは、戻り値をTrue
, False
のどちらかにするためです。
pattern_to_line
メソッドは簡単ですね。{'pattern': 'パターン', 'phrases': ['パターンマッチング?', 'パターンってゲシュタルト崩壊しそう']}
というオブジェクトを'パターン\tパターンマッチング?|パターンってゲシュタルト崩壊しそう'
という一行の文字列に変換するものです。
analyze
メソッドは文字列を受け取り、形態素を[(表層形, 品詞)]の形にして返します。janome
のToken
オブジェクトのsurface
メソッドで表層形を取得し、part_of_speech
メソッドで品詞情報を取得しています。実行すると以下のような結果が得られます。
[('あたし', '名詞,代名詞,一般,*'), ('は', '助詞,係助詞,*,*'),
('プログラム', '名詞,サ変接続,*,*'), ('の', '助詞,連体化,*,*'),
('女の子', '名詞,一般,*,*'), ('です', '助動詞,*,*,*')]
このうち、is_keyword
を適用した結果がTrue
であるパターンのみ学習していきます。
肝心の学習メソッドについて見ていきましょう。
class Dictionary: ...中略... def study(self, text): """ランダム辞書、パターン辞書をメモリに保存する。""" self.study_random(text) self.study_pattern(text, Dictionary.analyze(text)) def study_random(self, text): """ユーザーの発言textをランダム辞書に保存する。 すでに同じ発言があった場合は何もしない。""" if not text in self._random: self._random.append(text) def study_pattern(self, text, parts): """ユーザーの発言textを、形態素partsに基づいてパターン辞書に保存する。""" for word, part in parts: if self.is_keyword(part): # 品詞が名詞であれば学習 # 単語の重複チェック # 同じ単語で登録されていれば、パターンを追加する # 無ければ新しいパターンを作成する duplicated = next((p for p in self._pattern if p['pattern'] == word), None) if duplicated: if not text in duplicated['phrases']: duplicated['phrases'].append(text) else: self._pattern.append({'pattern': word, 'phrases': [text]})
ちょっと複雑になってきました。そのうちリファクタリングする必要がありそうです。
study
メソッドはstudy_random
とstudy_pattern
の2つに分けられました。study_pattern
はとりわけ処理が複雑(そうに見える)ので、メソッドを切り分けることで見通しが悪くならないようにしています。
next
という見慣れないものがありますが、これはジェネレータに関連するものなので深くは触れません。「self._pattern
にword
が存在すればそのパターンを返し、無ければNone
を返す」という式です。リファクタリングする際にはメソッドを分けたほうがいいかもしれませんね。
動作確認
では、ちゃんと学習してくれるのでしょうか。
今回は機械学習というほどではないですが、青空文庫から『吾輩は猫である』のテキストデータをダウンロードして、まとめて学習させてみました。元がギャグ小説だけに、さぞかし面白い会話になるに違いない……そう思っていた時期が私にもありました。
いやな感じの語りを入れられました。どうしてこうなった……。
とはいえパターン辞書の自動学習により、手作業で辞書を保存するよりも鋭い切り返しが見られるようになりました。今回のように小説をまるごと読ませなくても、Twitter感覚でチャットしているだけで充分成長しているのがわかります。ぜひ育ててあげてください。
また、コードが長くなってきたのでリポジトリからダウンロードしておくことをお勧めします。
次回の課題
また新しい学習方法としてテンプレートについて考えてみます。まずはDictionary
クラスの改善に始まり、余裕があれば新しいResponderを作っていきましょう。