すなぶろ

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

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

前回は形態素解析による学習と、名詞に関連した文章を返すPatternResponderを追加しました。キーワードで連想的に応答を生成させることで、そこそこ文脈を保った会話が実現した気がします。とはいえ一文をまるごと保存しているため、オウム返しされている感は否めません。

sandmark.hateblo.jp

そこで今回は本格的に「文章を生成させる」ことに焦点を当ててみます。ゼロから生成させるのは手がかりがないので、形態素解析を応用してユーザーの発言を分解してテンプレートとして学習し、そこから文章を作り出してみます。今回のソースコードはこちら

f:id:sandmark:20171011001912j:plain
ごくまれに絶妙なボケをかますAI


テンプレートの形式

「あたしはプログラムの女の子です」という文章があるとしましょう。このうち名詞にあたるのは『あたし』『プログラム』『女の子』ですが、『あたし』は代名詞にあたるので除外します。これをテンプレートにすると、

あたしは [   ] の [   ] です

となります。ここにそれぞれ別の名詞を割り当てると、

あたしは [世界] の [終わり] です

というように、セカイ系AIアイドルが出来上がりそうです。穴埋めに使う名詞は直前のユーザー発言から抽出することにします。つまり上記のような応答が返されたとしたら、『世界』と『終わり』という2つの名詞を含んだ発言をユーザーが行ったことになります。哲学的ですね。

ランダム辞書やパターン辞書とは違ってまるごと覚えるわけではないので、新しい辞書とResponderが必要になりそうです。

ユーザーの発言に含まれる名詞の数からテンプレートを抽出するので、名詞の数をキーに持ち、テンプレートの配列を要素に持つハッシュが良さそうです。テンプレートの空欄を表すには、パターン辞書の%match%と同じように%noun%という文字列を使いましょう。

{
    2: ['あたしは%noun%の%noun%です', '%noun%って%noun%だよね']
    3: ['この間%noun%に行ったら%noun%の%noun%に会ったよ']
}

辞書ファイルの形式はこんな感じ。

2   あたしは%noun%の%noun%です
2   %noun%って%noun%だよね
3   この間%noun%に行ったら%noun%の%noun%に会ったよ

ではまず、Dictionaryクラスを見ていきます。

Dictionaryクラスのリファクタリング

機能を追加する前に、ちょっとした問題を修正しましょう。これまではdictionary.py形態素解析を行っていましたが、今回からresponder.pyでも形態素解析結果を扱う必要が出てきます。そこで、Tokenizeranalyzeメソッドをmorph.pyファイルに移動させます。

import re
from janome.tokenizer import Tokenizer


TOKENIZER = Tokenizer()


def analyze(text):
    """文字列textを形態素解析し、[(surface, parts)]の形にして返す。"""
    return [(t.surface, t.part_of_speech) for t in TOKENIZER.tokenize(text)]


def is_keyword(part):
    """品詞partが学習すべきキーワードであるかどうかを真偽値で返す。"""
    return bool(re.match(r'名詞,(一般|代名詞|固有名詞|サ変接続|形容動詞語幹)', part))

これにより、他のファイルからimport morphとすることでanalyzeis_keyword関数にアクセスできるようになります。

次に、辞書を簡単に空にできるようにします。今のところDictionary辞書ファイルがある前提で動作するので、random.txtなどが存在しない場合、エラーを吐いて終了してしまいます。dictionary.pyに辞書が存在しなければ作成する処理を追加しましょう。

同時に、Dictionary.DICT_RANDOMといった名前だった辞書ファイル名もDictionary.DICT['random']などで参照できるようにします。

import os.path
from collections import defaultdict
import morph

class Dictionary:
    DICT = {'random': 'dics/random.txt',
            'pattern': 'dics/pattern.txt',
            'template': 'dics/template.txt',
            }

    def __init__(self):
        """ファイルから辞書の読み込みを行う。"""
        Dictionary.touch_dics()
        with open(Dictionary.DICT['random'], encoding='utf-8') as f:
            self._random = [l for l in f.read().splitlines() if l]

        with open(Dictionary.DICT['pattern'], encoding='utf-8') as f:
            self._pattern = [Dictionary.make_pattern(l) for l in f.read().splitlines() if l]

    @staticmethod
    def touch_dics():
        """辞書ファイルがなければ空のファイルを作成し、あれば何もしない。"""
        for dic in Dictionary.DICT.values():
            if not os.path.exists(dic):
                open(dic, 'w').close()

ファイルの存在チェックにはos.path.exists関数を使います。DICT_RANDOM, DICT_PATTERNなどに対して一回ずつ書くのではなく、DICTというハッシュに保存することでforループできるようにしています。

空ファイルの作成にはopen(filename, 'w').close()というイディオムを用います。Python3.4からはpathlibtouchメソッドが用意されているので、3.4+を使っている方はその方法でも良いでしょう。

analyzeis_keywordmorphモジュールに移動したので、morph.analyzeのように呼び出すよう変えるのを忘れないでください。

テンプレート辞書の読み込み

テンプレート辞書はこんな形式でした。

2   あたしは%noun%の%noun%です
2   %noun%って%noun%だよね
3   この間%noun%に行ったら%noun%の%noun%に会ったよ

行ごとに\tで分割し、数値をキーとするハッシュにテンプレートをリストで追加していきます。__init__メソッドに以下を追加してください。

with open(Dictionary.DICT['template'], encoding='utf-8') as f:
    self._template = defaultdict(lambda: [], {})
    for line in f:
        count, template = line.strip().split('\t')
        if count and template:
            count = int(count)
            self._template[count].append(template)

defaultdictlambdaと、見慣れないものがあります。紐解いてみましょう。

defaultdictcollectionsモジュールに含まれているもので、「ハッシュのデフォルト値を設定」してくれます。今回は[]、つまり空リストを設定しています。実際にPythonシェルで確認してみましょう。

>>> dic = {'A': 'apple', 'B': 'banana'}  # 通常のハッシュ
>>> dic['A']  # キー'A'と'B'には値が設定されているため
'apple'       # 呼び出したときに設定した値が返ってくる
>>> dic['B']
'banana'
>>> dic['C']   # 存在しないキーを指定した場合
KeyError: 'C'  # 'C'というキーがない旨のエラーが出る

今回は空リストとなっていて欲しいですし、「キーが存在しなければ[template]を追加し、キーが存在しなければ.append(template)を行う」という処理を組み込むと、コードが上下左右に長くなり、可読性が落ちます。

デフォルト値を使ってみましょう。

>>> from collections import defaultdict
>>> dic = defaultdict(lambda: '', dic)  # dicのデフォルト値を''に設定
>>> dic['C']
''

KeyErrorは発生せず、デフォルト値である空文字列が返ってきました。lambda: default_valueの形式でデフォルト値を設定し、次の引数にはデフォルト値を設定したいハッシュを指定します。

今回の例では「デフォルト値は[]」で、設定したいハッシュが既にあるわけではありません。そこで単に2番めの引数には{}と指定しています。

次に行linestripを適用し、さらにsplitで分割しています。stripを挟むのは、他の辞書と違ってsplitlines()を使用していないため、行の末尾に'\n'がついてしまっているからです。

ここでも多重代入が登場します。countがキーとなる数値です。ファイルから読み込んだ時点では'2'のような文字列なので、intを使って2という数値に変換しています。templateは文字通りテンプレート文字列ですね。

ハッシュにデフォルト値が設定されているため、self._template[count].append(template)と書くだけで要素の追加が可能です。

テンプレート辞書の学習

今回からstudyメソッドが形態素解析結果partsを受け取るようになりました。これまではstudy内部でanalyzeを呼び出していましたが、後述のTemplateResponderでも形態素解析結果を使うことになるため、analyzeの呼び出しはUnmoクラスで行うことにします。

def study(self, text, parts):
    """ランダム辞書、パターン辞書、テンプレート辞書をメモリに保存する。"""
    self.study_random(text)
    self.study_pattern(text, parts)
    self.study_template(parts)

def study_template(self, parts):
    """形態素のリストpartsを受け取り、
    名詞のみ'%noun%'に変更した文字列templateをself._templateに追加する。
    名詞が存在しなかった場合、または同じtemplateが存在する場合は何もしない。
    """
    template = ''
    count = 0
    for word, part in parts:
        if morph.is_keyword(part):
            word = '%noun%'
            count += 1
        template += word

    if count > 0 and template not in self._template[count]:
        self._template[count].append(template)

forループでは、名詞を'%noun%'に置き換えつつtemplateを作ります。また、'%noun%'の数だけcountを作り、テンプレート辞書のキーにしています。リスト内包表記で書けなくもなかったのですが、条件分岐を組み込むと闇が深くなりそうな気がしたのでこのままにしておきます。

'%noun%'がひとつ以上あり、テンプレートが既存のものでなければ辞書に追加します。

テンプレート辞書への書き込み

データ構造が(私にとって)複雑になってきたので、そろそろユニットテストやインテグレーションテストを導入したいと強く思いますが、それを探すのはまた今度にして、まずはこのAIを完成させましょう。

確認になりますが、テンプレート辞書の構造は{count: ['template']}でした。ハッシュに対してforループを回すには、items()メソッドを使います。

def save(self):
    """メモリ上の辞書をファイルに保存する。"""
    with open(Dictionary.DICT['random'], mode='w', encoding='utf-8') as f:
        f.write('\n'.join(self.random))

    with open(Dictionary.DICT['pattern'], mode='w', encoding='utf-8') as f:
        f.write('\n'.join([Dictionary.pattern_to_line(p) for p in self._pattern]))

    with open(Dictionary.DICT['template'], mode='w', encoding='utf-8') as f:
        for count, templates in self._template.items():
            for template in templates:
                f.write('{}\t{}\n'.format(count, template))

countには数値(キー)が、templatesには文字列のリスト(値)が入ってきます。さらにその中でforループを回して、整形してから行ごとに書き込んでいます。\nをわざわざ付けなければ改行されない動作に不安がありましたが、内部的にOS依存の改行文字コードに変換してくれるそうです。'\r\n'などを使う必要はないみたいです。

おっと、self._templateをプロパティとして公開するのを忘れていました。これがないと他のクラスからテンプレート辞書にアクセスできないので忘れてはいけません。

@property
def template(self):
    """テンプレート辞書"""
    return self._template

TemplateResponder

いよいよResponderの開発に取り掛かります。といっても、これまでDictionaryクラスをがんばった分、TemplateResponderは至ってシンプルなものです。

class TemplateResponder(Responder):
    def response(self, _, parts):
        """形態素解析結果partsに基づいてテンプレートを選択・生成して返す。"""
        keywords = [word for word, part in parts if morph.is_keyword(part)]
        count = len(keywords)
        if count > 0:
            if count in self._dictionary.template:
                template = choice(self._dictionary.template[count])
                for keyword in keywords:
                    template = template.replace('%noun%', keyword, 1)
                return template
        return choice(self._dictionary.random)

Note: responseメソッドの引数の数が変化しています。新たに形態素解析結果も受け取るようになったため、selfを除いて2つの引数を要求するようになりました。これは他のResponderクラスのresponseメソッドも同様に変更しておかないと、呼び出し時にエラーが発生するので、忘れずに修正してください。


keywordsにはmorph.is_keyword(part)Trueになるもの、つまり名詞が集められます。そこから逆算してテンプレートのキーであるcountを取得し、1以上であれば次の処理に入ります。

テンプレート辞書にcountキーが存在していれば、そこに登録されているテンプレートからランダムに選択します。

続いて名詞のリストでループを回し、テンプレートに含まれる%noun%をひとつずつ名詞に置き換えていきます。replaceメソッドは文字列の置き換えを行いますが、3つ目の引数を指定しないと全て置き換えてしまいます。「%noun%は%noun%の%noun%です」がすべて「私は私の私です」になってはひどいので、1を指定して『ひとつずつ』を明示しています。

count0であるか、指定されたテンプレートが無かった場合は、PatternResponderと同じくランダム辞書から返すことにしています。

Unmoクラスの修正

さっくりやってしまいましょう。変更点は以下です。

  • 形態素解析dialogueメソッド内で行い、responseメソッドに渡す
  • TemplateResponderをインポートする
  • Responderの選択確率を調節する

unmo.pyの変更箇所を抜粋します。

from responder import WhatResponder, RandomResponder, PatternResponder, TemplateResponder  # 追加
import morph  # 追加

class Unmo:
    def __init__(self, name):
        ...中略...
        self._responders = {
            'what':   WhatResponder('What', self._dictionary),
            'random': RandomResponder('Random', self._dictionary),
            'pattern': PatternResponder('Pattern', self._dictionary),
            'template': TemplateResponder('Template', self._dictionary)  # 追加
        }
        ...中略...

    def dialogue(self, text):
        chance = randrange(0, 100)
        if chance in range(0, 39):  # 変更
            self._responder = self._responders['pattern']
        elif chance in range(40, 69):  # 追加
            self._responder = self._responders['template']  # 追加
        elif chance in range(70, 89):  # 変更
            self._responder = self._responders['random']
        else:
            self._responder = self._responders['what']

        parts = morph.analyze(text)  # 追加
        response = self._responder.response(text, parts)  # 変更
        self._dictionary.study(text, parts)  # 変更
        return response

行数は多いですが、いずれもちょっとした変更で済んでいます。

仕上げ: main.py

辞書を削除したなどが理由でランダム辞書が空である場合、choice関数がエラーを発生させることがあります。「空のリストからはランダムに選択できない」といったもので、まったく正論だと言えます。

これは厳密にはエラーではなく、例外というものが発生しています。リストの要素選択に関わることなので、IndexErrorという種類の例外が投げられます。

この例外が発生した瞬間に処理を切り替え、トレース情報を表示して終了するのではなく、警告文を出すのみでプログラム自体は続行させるようにしてみましょう。

try:
    response = proto.dialogue(text)
except IndexError as error:
    print('{}: {}'.format(type(error).__name__, str(error)))
    print('警告: 辞書が空です。(Responder: {})'.format(proto.responder_name))
else:
    print('{prompt}{response}'.format(prompt=build_prompt(proto),
                                          response=response))

choice関数による例外が発生する可能性があるのはdialogueメソッドの呼び出し時です。そこでtryというブロックで包み、例外を捕捉する準備をします。

except IndexError as errorブロックは、IndexErrorが発生したときのみ、例外オブジェクトをerrorに代入し、実行されます。他の例外(ファイルが存在しない・メモリが足りないなど)はこの網に引っかからず、通常通りエラーとなります。

exceptブロックでは、errorの名前(IndexError)とメッセージを表示します。また、「辞書が空です」という警告文も出力し、情報としてどのResponder動作時に起こったかも出力します。

例外が発生しなければelseブロックが実行されます。この部分は(インデントが深くなっただけで)これまでと同様です。

動作確認

ではようやくの動作確認をしてみましょう。ユーザーの発言を原型にするとはいえ、初めてAIが自分の文章を作り上げる瞬間です。

f:id:sandmark:20171011001912j:plain
ごくまれに絶妙なボケをかますAI

まだデータが少ないので「あ、この発言パクったな」と思う瞬間もありますが、予想外度は大きく高まっています。辞書が大きくなればそれなりに面白くなるでしょう。

私はボケと見るや否や反射的にツッコミを入れてしまうので、さらにそれを学習された結果、暴言を吐くだけのAIになりそうで怖いです。

デザインパターンの話

新たなアプローチを思いついても、それを実装できるかどうかはプログラマの腕にかかっています。今回の機能追加ではDictionaryクラスという、ある意味もっともユーザーから遠い場所に多くの新規コードを書きました。対照的にmain.py, unmo.py, responder.pyの変更は微々たるものです。

「新たな思考エンジンを追加する」というとResponderの拡張が多くなりがちかと思いきや、実はそれをサポートするDictionaryクラスのほうが変更点が多い、というのはよくあるケースです。Dictionaryクラスがサポート役に徹しているからこそ、入出力を行う他のクラスのコードは必然的に短くなります。

こういった開発のデザインパターントップダウンとボトムアップとして知られ、これを意識することで

  • コードを書く速さ
  • コードの短さ、読みやすさ
  • コードの再利用性の高さ
  • 単体テストの組み込みやすさ

などが向上します。

形態素解析Dictionaryクラスに組み込んだのはボトムアッププログラミングを目指した失敗で、今回のようにResponderとDictionaryが共用で使うとわかっていれば、初めからunmo.pyに書くか、モジュールとして分離しておけばよかったのです。

とはいえトップダウンのみで開発していたら、UnmoResponderの負担は大きくなり、Dictionaryクラスまでもが影響を受けて破綻していたでしょう。トップダウンボトムアップもプログラミングする上で重要な構成要素なので、いいとこ取りのバランス感覚がつかめれば、レベルもひとつ上がりそうです。

次の課題

テンプレート辞書により、形式はある程度限定されるものの、初めてAI自身が喋ってくれるようになりました。知性がそこにあるかどうかはさておき、自分の言葉を持ったということです。

次回はさらに積極的にアプローチし、ゼロから文章を作り出してもらうことにします。自由度が高くなる反面、余計にわけのわからない文章を作り出される可能性もある諸刃の剣です。じっくり作っていきましょう。

宿題(おまけ)

形態素解析というものの性質上、「ー」(伸ばし棒)なども名詞として認識され、パターン辞書やテンプレート辞書に組み込まれてしまいます。また、「私」などの代名詞もTemplateResponderが置き換えるため、「私はなー」という文章が「ギターはな歌」といった、一瞬「ん?」となる文章に変換されることがあります。

暇な人はチューニングしてみると会話生成精度が上がるかもしれませんよ。