すなぶろ

Pythonistaを目指しているつもりが、Hyのおかげで無事脱線。Clojurian目指すことになりました。

Python初心者に送る「人工知能の作り方」 Part4

前回はリスト内包表記と正規表現を使って新しい思考エンジンを作りました。しかし依然として辞書はユーザーが手作業で作らなければならず、チャットをしても新しい発見があるとは思えません。

sandmark.hateblo.jp

今回はDictionaryに学習機能を組み込みます。Python的にはファイルへの書き込みと、形態素解析ライブラリを使ってみます。今回のソースコードこちら

f:id:sandmark:20171010012436j:plain
Unmo + 吾輩は猫である = ???


真・オウム返し

学習と一口に言ってもいろいろな方法があります。そのうちもっとも基本的なものとして、「聞いた言葉をそのまま返す」というものがあります。ユーザーが発言するたびに、その発言をランダム辞書へ追加していくというアプローチにしましょう。会話すればするほどデータが増えるので、賢くなるかどうかはわかりませんが、語彙は増えるはずです。

まずは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

まずはファイルの先頭でrejanomeをインポートしています。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メソッドは文字列を受け取り、形態素を[(表層形, 品詞)]の形にして返します。janomeTokenオブジェクトの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_randomstudy_patternの2つに分けられました。study_patternはとりわけ処理が複雑(そうに見える)ので、メソッドを切り分けることで見通しが悪くならないようにしています。

nextという見慣れないものがありますが、これはジェネレータに関連するものなので深くは触れません。「self._patternwordが存在すればそのパターンを返し、無ければNoneを返す」という式です。リファクタリングする際にはメソッドを分けたほうがいいかもしれませんね。

動作確認

では、ちゃんと学習してくれるのでしょうか。

今回は機械学習というほどではないですが、青空文庫から『吾輩は猫である』のテキストデータをダウンロードして、まとめて学習させてみました。元がギャグ小説だけに、さぞかし面白い会話になるに違いない……そう思っていた時期が私にもありました。

f:id:sandmark:20171010012436j:plain
違う、そうじゃない

いやな感じの語りを入れられました。どうしてこうなった……。

とはいえパターン辞書の自動学習により、手作業で辞書を保存するよりも鋭い切り返しが見られるようになりました。今回のように小説をまるごと読ませなくても、Twitter感覚でチャットしているだけで充分成長しているのがわかります。ぜひ育ててあげてください。

また、コードが長くなってきたのでリポジトリからダウンロードしておくことをお勧めします。

次回の課題

また新しい学習方法としてテンプレートについて考えてみます。まずはDictionaryクラスの改善に始まり、余裕があれば新しいResponderを作っていきましょう。