すなぶろ

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

Python初心者に送る「人工知能の作り方」 Part7(終)

前回はマルコフ連鎖で学習・応答するモジュールを作りました。それなりに複雑な処理が含まれていましたが、設計時点で仕様を決めておき、それをコメントを含めたコードにすることで、メンテナンス性を保っています。

sandmark.hateblo.jp

今回はmarkov.pyDictionaryクラスに組み込み、新たにMarkovResponderを追加します。といっても、学習も応答もmarkov.py内で完結しているのでほとんど変更を加える必要はありません。

その代わりといっては何ですが、辞書ファイルが存在しなかった場合の例外処理を組み込んでみます。今回のソースコードはこちら

f:id:sandmark:20171013214817p:plain
結論:ハムスターはAI。作者もAI

例外処理

以前、辞書が空であった場合のコードを追加しました。main.pyに変更を加えたものでしたが、より細かく砕いてDictionaryクラスに実装してみましょう。

今回捕捉する例外はIOErrorというもので、ファイルの入出力に失敗したときに投げられる例外です。辞書ファイルは複数あるので、その分だけ例外を捕捉する必要がありますが、警告文を表示するコードは同じなので、util.pyに定義しました。

def format_error(error):
    """例外errorを受け取り、'名前: メッセージ'の形式で返す"""
    return '{}: {}'.format(type(error).__name__, str(error))

もしerrorの中身がIOErrorオブジェクトで、random.txtを読み込もうとして失敗した場合は、

FileNotFoundError: [Errno 2] No such file or directory: 'dics/random.txt'

このようなメッセージに整形されます。

これを踏まえて、まずはDictionaryクラスの__init__メソッドを見ていきます。

from markov import Markov
from util import format_error
...中略...

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

    def __init__(self):
        """ファイルから辞書の読み込みを行う。"""
        self._random = Dictionary.load_random(Dictionary.DICT['random'])
        self._pattern = Dictionary.load_pattern(Dictionary.DICT['pattern'])
        self._template = Dictionary.load_template(Dictionary.DICT['template'])
        self._markov = Dictionary.load_markov(Dictionary.DICT['markov'])

    @staticmethod
    def load_random(filename):
        """filenameをランダム辞書として読み込み、リストを返す"""
        try:
            with open(filename, encoding='utf-8') as f:
                return [l for l in f.read().splitlines() if l]
        except IOError as e:
            print(format_error(e))
            return ['こんにちは']

    @staticmethod
    def load_pattern(filename):
        """filenameをパターン辞書として読み込み、リストを返す"""
        try:
            with open(filename, encoding='utf-8') as f:
                return [Dictionary.make_pattern(l) for l
                        in f.read().splitlines() if l]
        except IOError as e:
            print(format_error(e))
            return []

    @staticmethod
    def load_template(filename):
        """filenameをテンプレート辞書として読み込み、ハッシュを返す"""
        templates = defaultdict(lambda: [])
        try:
            with open(filename, encoding='utf-8') as f:
                for line in f:
                    count, template = line.strip().split('\t')
                    if count and template:
                        count = int(count)
                        templates[count].append(template)
            return templates
        except IOError as e:
            print(format_error(e))
            return templates

    @staticmethod
    def load_markov(filename):
        """Markovオブジェクトを生成し、filenameから読み込みを行う。"""
        markov = Markov()
        try:
            markov.load(filename)
        except IOError as e:
            print(format_error(e))
        return markov

辞書の読み込みコードが長くなってきたので、それぞれメソッドに分割しました。オブジェクト変数(self._randomなど)を__init__の外側で定義するとPythonから警告が出るので、ロードメソッドは値を返すのみに留めています。

ともあれ、これで辞書ファイルが無くても起動するようになりました。

マルコフ辞書の学習・保存・プロパティ

実質Markovクラスがほとんどやってくれるので、追加するコードは最小限で済みます。

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

def study_markov(self, parts):
    """形態素のリストpartsを受け取り、マルコフ辞書に学習させる。"""
    self._markov.add_sentence(parts)

def save(self):
    # ...中略...
    self._markov.save(Dictionary.DICT['markov'])

@property
def markov(self):
    """マルコフ辞書"""
    return self._markov

ファイルの入出力処理に比べると驚くほど短いですね。dillによるオブジェクト永続化もmarkov.pyがやってくれるので、dictionary.pyからimport dillする必要もありません。これでDictionaryの定義は完了しました。続いてMarkovResponderです。

MarkovResponder

Markovクラスの使い方は、markov.pyで予習済みです。Responderにもほぼそのまま書くだけで動作させることができます。responder.pyに以下を追加しましょう。

class MarkovResponder(Responder):
    def response(self, _, parts):
        """形態素のリストpartsからキーワードを選択し、それに基づく文章を生成して返す。
        キーワードに該当するものがなかった場合はランダム辞書から返す。"""
        keyword = next((w for w, p in parts if morph.is_keyword(p)), '')
        response = self._dictionary.markov.generate(keyword)
        return response if response else choice(self._dictionary.random)

これでMarkovResponderは完成です。ユーザーの発言は処理する必要がないので_を指定しています。Markovオブジェクトからの応答があればresponseを返し、何もなければランダム辞書から無作為に選択して返しています。

ジェネレータ

nextにはそろそろ決着をつけましょう。

例えば[w for w, p in parts if morph.is_keyword(p)]というリスト内包表記があった場合、morph.is_keyword(p)Trueとなるwのリストが生成されます。[w1, w2, w3...]といった具合ですね。

求めているのは「合致する最初のキーワード」なので、このリストの名前を仮にwordsとしたら、words[0]で得ることもできます。しかしその場合、使われないw2, w3といった要素は捨てられます。partsがものすごく長いリストで、時間をかけてw100くらいまで計算しても、使わないものは使わないのです。

そこでジェネレータを使います。(w for w, p in parts if morph.is_keyword(p))というように、[]ではなく()で囲まれているのが特徴です。こちらはリスト内包表記とは違い、記述しても即座に計算が行われるわけではありません。「morph.is_keyword(p)Trueとなるwが集まる予定のデータ」を作成するだけです。

あくまでも予定に過ぎないので、実際のデータは明示的に取り出してやらなければなりません。それを行うのがnextです。nextは引数にジェネレータを受け取り、一度だけ計算してw1を見つけたら、それを返して再び処理を停止します。

もう一度nextを呼び出せば、今度はw2を計算して返し、また止まります。そうして一致する要素がなくなったとき、StopIteration例外を投げて「もう返せるデータがない」ことを示して終わりです。

では最初からmorph.is_keyword(p)に該当するデータがなかったらどうなるのでしょう。リスト内包表記であれば空リストになるだけですが、ジェネレータの場合はStopIteration例外が発生します。

これをtryで捕捉してもいいのですが、nextの第二引数にデフォルト値を指定することで、例外の代わりにその値を返させることができます。今回は''を指定しているので、keywordにはmorph.is_keyword(p)Trueとなる何らかのwか、何もなければ空文字列が入ります。

Rubyfindselectが恋しくなることもありますが、慣れるとこれはこれで柔軟性があって使いやすいですね。

Unmoクラス

材料が整ったので、Unmoオブジェクトに使ってもらいましょう。unmo.pyを編集します。

# 中略
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),
            'markov': MarkovResponder('Markov', self._dictionary),  # 追加
        }

        # 中略

    def dialogue(self, text):
        # 中略
        chance = randrange(0, 100)
        if chance in range(0, 29):
            self._responder = self._responders['pattern']
        elif chance in range(30, 49):
            self._responder = self._responders['template']
        elif chance in range(50, 69):
            self._responder = self._responders['random']
        elif chance in range(70, 89):
            self._responder = self._responders['markov']
        else:
            self._responder = self._responders['what']

self._markovを用意してMarkovResponderを設定し、20%の確率で選択されるようにしました。辞書が大きくなって充分なデータが集まったら、もっと高い確率に変えてもいいかも知れません。

f:id:sandmark:20171013214817p:plain
結論:ハムスターはAI。作者もAI

学習の進まないうちはほとんどがランダム辞書からのオウム返しですが、辛抱強く教え込んでいるうちにいろいろな文章を作り始めます。じっくり育ててみてください。

次回の課題

実は未定です。Webサービスにするか、Googleと組み合わせて学習能力を強化するか。それともここで一旦終了しておくべきか。

恋するプログラム』のWebサービス化については他の方々がやっていらっしゃるので、改めてここで扱う必要もないかなと思います。Googleと組み合わせるアプローチには心惹かれるものがありますが、IRCTwitter, LINEのbotにしても面白いかもしれません。

というわけで、今回はひとまずここまでということにしておきましょう。Python的に「どうなの」というコードもあると思いますので、ボランティア感覚でsandmark/unmoをforkしていじってもらって構いません。

ではでは、ここまでお読み頂きありがとうございました。快適なAIライフを願っています。