すなぶろ

フリーランスのWebエンジニアがPythonを中心にプログラミング情報を紹介していきます。Rubyも書きますが、今はPython勉強中。一緒にPythonista目指しましょう!

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

前回はAIに思考エンジンを追加しました。まだまだ会話にはなりませんが、継承を使ってコードを簡潔に保つことで、機能を追加しやすい状況にしています。今回のソースはこちら

sandmark.hateblo.jp

f:id:sandmark:20171009033450j:plain
チャットボットっぽくなってきた?

今回のテーマは『Responderの動的切り替え』『データとプログラムの分離』『新たなResponderの追加』です。Python的には、ファイルの読み込み、リスト内包表記、正規表現を扱います。


Responderの切り替え

現状UnmoクラスはRandomResponderしか保持していません。これに加えてWhatResponderも保持するように変更し、入力のたびにランダムでResponderを切り替えるようにしてみましょう。以下はunmo.py__init__メソッドです。

def __init__(self, name):
    """文字列を受け取り、コアインスタンスの名前に設定する。
    WhatResponder, RandomResponderインスタンスを作成し、保持する。
    """
    self._responders = {
        'what':   WhatResponder('What'),
        'random': RandomResponder('Random'),
    }
    self._name = name
    self._responder = self._responders['random']

self._respondersという変数が新しく登場しました。値は辞書型と呼ばれるもので、'what'キーにはWhatResponder('What')'random'キーにはRandomResponder('Random')が入っています。

単にリストにしてしまうと、self._responders[1]という呼び出し方になって、「1って何のResponderだっけ」と悩みがちです。辞書型であればself._responders['random']という呼び出しができるので、RandomResponderであることがわかりやすくなります。

self._responderにはこの呼び出しを使って、RandomResponderが代入されています。

続いてdialogueメソッドを見てみましょう。会話が発生したタイミングでResponderを切り替えることにします。

def dialogue(self, text):
    """ユーザーからの入力を受け取り、Responderに処理させた結果を返す。
    呼び出されるたびにランダムでResponderを切り替える。"""
    chosen_key = choice(list(self._responders.keys()))
    self._responder = self._responders[chosen_key]
    return self._responder.response(text)

ちょっとややこしくなりました。紐解いてみましょう。

Python標準モジュールに含まれるchoiceは「リストからランダムな要素を返す」関数です。ここでは辞書型に対してランダムに選択してほしいのですが、辞書型は言うまでもなくリストではありません。ではchoice関数は使えないのかというと、実はそうでもないのです。

辞書型に対してkeys()メソッドを呼び出すと、キーの一覧が返ってきます。今回の例で言えばself._responders.keys()の結果はdict_keys(['what', 'random'])になります。

dict_keysというのは辞書型データのキーを表現するオブジェクトなので、やはりリストではありません。しかしリストに変換することは可能なので、list関数を使って変換しています。これでようやくchoice関数に渡せるデータになるわけです。

chosen_key = choice(list(self._responders.keys()))  # 脳内で変換してみる
chosen_key = choice((list(dict_keys(['what',random])))) # キーオブジェクトに変換
chosen_key = choice(['what', 'random'])  # list関数でリスト化する
chosen_key = 'what'  # choice関数によりランダムに決定される

self._responder = self._responders[chosen_key]  # chosen_keyの中身は決定している
self._responder = self._responders['what']  # 最終結果

こういうことが行われています。ちなみにこれはpython dict choiceで検索したら出てきたことなので、理解を深めたい方はそういったワードで調べてみることをお勧めします。このコードよりもわかりやすく書けるかもしれません。

choice関数を使うからには、使う旨をインポートしなければなりません。unmo.pyの先頭にfrom random import choiceと書くのを忘れないでください。

ともあれ、これでResponderが実行中にランダムに切り替わるようになりました。python main.pyと実行して試してみてください。

データとプログラムの分離

現在RandomResponderには複数の応答パターンが含まれています。今のところ3行分だけですが、仮に100万行用意したいとなったら、ファイルサイズはとんでもないことになります。また、どこまでがデータでどこからがプログラムなのかの判別が難しくなり、エディタで開くのも一苦労ですし、C言語Javaなどのコンパイラ言語の場合は、コンパイルに余計な時間がかかってしまいます。これは憂慮すべき問題です。

というわけで、データはテキストファイルに保存することにしましょう。プログラムのロード時に読み込むようにすればいいだけですし、今後データ入力のアルバイトを雇ったとしてもソースコードを公開する必要はありません。

形式は1行に1つの応答メッセージというシンプルなものにして、dics/random.txtに保存しましょう。「ランダム辞書」と呼ぶことにします。文字コードUTF-8を指定してください。

今日はさむいね
チョコたべたい
きのう10円ひろった

次はRandomResponderの定義です。dics/random.txtを読み込むように変更し、RandomResponder.RESPONSESという変数は削除します。これまで親クラスの__init__を使ってきましたが、辞書の読み込み処理を追加するので、新しく独自の__init__メソッドを定義しましょう。

class RandomResponder(Responder):
    """AIの応答を制御する思考エンジンクラス。
    登録された文字列からランダムなものを返す。
    """

    def __init__(self, name):
        """文字列nameを受け取り、オブジェクトの名前に設定する。
        'dics/random.txt'ファイルから応答文字列のリストを読み込む。"""
        super().__init__(name)
        self._responses = []
        with open('dics/random.txt', encoding='utf-8') as f:
            for line in f:
                if line:
                    line = line.strip()
                    self._responses.append(line)

    def response(self, _):
        """ユーザーからの入力は受け取るが、使用せずにランダムな応答を返す。"""
        return choice(self._responses)

一行ずつ見てみます。

新たに__init__メソッドを用意してしまったので、親クラスであるResponder__init__処理は行われません。かといって親クラスの__init__メソッドの中身をコピペするのは野蛮ですので、super().__init__(name)という形で親クラスの__init__メソッドを明示的に呼び出しています。

self._responses = []は応答文字列のリストです。

open関数にはファイル名と、読み取り専用モードを示す'r'、エンコードには'utf-8'を指定しています。fはファイルオブジェクトで、for文に渡すと一行ごとに読み込んでくれます。

if lineというのは「lineが空文字列でなければ」という意味で、もし空行があっても無視するようになっています。

.strip()メソッドは改行文字列を削除するもので、デフォルトでは'今日はさむいね\n'のように'\n'がくっついてしまいます。これを削除して'今日はさむいね'といった文字列に変換しています。

.append()は「リストに要素を追加する」メソッドなので、一行ずつself._responsesに追加していく処理になります。

コードを短く(したい人向け)

上記コードはもっと短くすることができます。リスト内包表記というPythonの機能を使います。

    def __init__(self, name):
        """文字列nameを受け取り、オブジェクトの名前に設定する。
        'dics/random.txt'ファイルから応答文字列のリストを読み込む。"""
        super().__init__(name)
        with open('dics/random.txt', mode='r', encoding='utf-8') as f:
            self._responses = [x for x in f.read().splitlines() if x]

まるで暗号のようですが、理解してしまえば単純ですし、使わないという選択肢もあります。端的に説明すると、

  1. まずf.read().splitlines()を呼び出し、行ごとに分割する
  2. 分割した行をxに代入する
  3. if xを実行し、空文字列でないものだけ絞り込む
  4. リストにする
  5. リストをself._responsesに代入する

という処理を一行で表現しています。リスト内包表記はHaskellClojureにもありますね。Rubyにはありませんが、splitselectを使って一行で表現できます。

必ずしも使いこなさなくてはならないものではありませんが、コードが短くなるケースも多く、知っていると周りから「やるじゃん」とか思われたりするかもしれません。Pythonistaを目指す人は覚えておいて損はないでしょう。

文脈を作る

ユーザーの入力を完全に無視するのはいただけません。「おはよう!」と挨拶しても返事が返ってこなかったら「嫌われてるのかな」と思って不安になります。精神衛生上よろしくない事態です。

そこで「パターン辞書」を考えてみます。

パターン [TAB] 応答

このような形式で、パターンに合致する入力が来たら、登録されている応答を返すというものです。パターンは「キーワード」と言い換えてもいいかもしれません。

疲れ [TAB] 大丈夫?
酒 [TAB] お酒は二十歳からです!
眠 [TAB] もう寝るの? おやすみー!

と登録しておけば、

> あー今日は疲れたー
proto> 大丈夫?
> 大丈夫じゃないからお酒飲む
proto> お酒は二十歳からです!
> もう飲んじゃって眠いしー
proto> もう寝るの? おやすみー!

こんな会話が想定されます……! パターン辞書は依然として手動で用意するわけですから、そこはかとなく自作自演のにおいがするのは否めません。が、それでもAIとそれらしい会話ができるはずです。実装してみましょう。

新たなResponderの名前はPatternResponderです。

今回のResponderには正規表現を使います。Pythonコード内での扱い方は説明しますが、正規表現だけで一冊の本ができるほど奥が深いものですので、極めたい人はネットで調べるなり何なりしてください。私もそれほど詳しくないです。

PatternResponderを作る…前に、Dictionaryクラスを作る

ランダム辞書にパターン辞書と、扱う辞書が増えてきました。今後もResponderが増えるたびに、Responderの__init__メソッドを書き換えていたのではちょっと手間になります。そこで新たにDictionaryクラスを作りましょう。dictionary.pyを作って以下のように記述します。

class Dictionary:
    """思考エンジンの辞書クラス。

    クラス変数:
    DICT_RANDOM -- ランダム辞書のファイル名
    DICT_PATTERN -- パターン辞書のファイル名

    プロパティ:
    random -- ランダム辞書
    pattern -- パターン辞書
    """

    DICT_RANDOM = 'dics/random.txt'
    DICT_PATTERN = 'dics/pattern.txt'

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

        self._pattern = []
        with open(Dictionary.DICT_PATTERN, encoding='utf-8') as f:
            for line in f:
                pattern, phrases = line.strip().split('\t')
                if pattern and phrases:
                    self._pattern.append({'pattern': pattern,
                                          'phrases': phrases})

    @property
    def random(self):
        """ランダム辞書"""
        return self._random

    @property
    def pattern(self):
        """パターン辞書"""
        return self._pattern

ランダム辞書、パターン辞書の読み込みを__init__メソッドで行っています。このうちランダム辞書についてはすでに解説したので、パターン辞書の読み込みについて見てみましょう。

まずは一行ずつ読み込んでいます。読み込んだ行lineに対してstrip().split('\t')を呼び出すことで、改行文字の削除とタブ文字による分割を行っています。splitメソッドの戻り値はリストですが、pattern, phrases = ...という代入式により、リストの最初の要素がpatternに、次の要素がphrasesに代入されます(アンパック代入といいます)。

具体的な動作を見てみましょう。どのような形式になるのか、dics/pattern.txtというファイルを用意して試してみましょう。

チョコ(レート)?   %match%おいしいよね|食べ過ぎると太るよ!
天気  明日晴れるといいなー

%match%という見慣れないものがありますが、今は気にする必要はありません。すぐ後に解説します。

Pythonシェルを起動しましょう。

>>> from dictionary import Dictionary
dict = Dictionary()
dict.pattern
[
  {
    'pattern': 'チョコ(レート)?',
    'phrases': '%match%おいしいよね|食べ過ぎると太るよ!'
  },
  {
    'pattern': '天気',
    'phrases': '明日晴れるといいなー'
  }
]

なるほど。辞書型データのリストとしてちゃんとパースされています。

リスト内包表記を使って

例によってパターン辞書の読み込みも短くすることができます。

    def __init__(self):
        """ファイルから辞書の読み込みを行う。"""
        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 make_pattern(line):
        """文字列lineを\tで分割し、{'pattern': [0], 'phrases': [1]}の形式で返す。"""
        pattern, phrases = line.split('\t')
        if pattern and phrases:
            # return {'pattern': pattern, 'phrases': phrases}           # 2017-10-09 修正
            return {'pattern': pattern, 'phrases': phrases.split('|')}  # `split`メソッドの呼び出しを`PatternResponder`ではなくこちらで行うようにしました

新たにスタティックメソッドであるmake_patternを定義しました。これはオブジェクトに属するメソッドとは違い、クラスに属するメソッドです。パターンデータを作る作業は読み込み時のみに行われ、オブジェクトからは実行する必要がないのでクラスに定義しています。

PatternResponder

いよいよPatternResponderを作る段階に来ました。まずは実装であるresponder.pyを見てみます。

import re
# 中略

class Responder:
    # 中略
    def __init__(self, name, dictionary):
        """文字列nameを受け取り、自身のnameに設定する。
        辞書dictionaryを受け取り、自身のdictionaryに保持する。"""
        self._name = name
        self._dictionary = dictionary

# 中略

class PatternResponder(Responder):
    """AIの応答を制御する思考エンジンクラス。
    登録されたパターンに反応し、関連する応答を返す。
    """

    def response(self, text):
        """ユーザーの入力に合致するパターンがあれば、関連するフレーズを返す。"""
        for ptn in self._dictionary.pattern:
            matcher = re.match(ptn['pattern'], text)
            if matcher:
                # chosen_response = choice(ptn['phrases'].split('|'))  # 2017-10-09修正
                chosen_response = choice(ptn['phrases']                # `split`メソッドの呼び出しを`Dictionary`クラスへ移動させました
                return chosen_response.replace('%match%', matcher[0])
        return choice(self._dictionary.random)

新たにimport reが追加されています。これは正規表現を扱うモジュールで、今回パターン応答を行うにあたって使用するのでインポートしています。

次にResponderクラスの__init__ですが、若干の変更が加えられました。引数に新たにdictionaryを受け取るようになり、保持しています。これでResponderクラスを継承するRandomResponderPatternResponderDictionaryをロードする必要がなくなります。

本題のPatternResponderresponseメソッドを見てみましょう。パターン辞書に対してループを回して、パターンに合致するもの(re.match関数でヒットしたもの)を選んでいます。チョコ(レート)?という正規表現パターンがあるとすれば、「チョコ」でも「チョコレート」でもヒットします。

ヒットした場合、そのパターンに登録されている応答(%match%おいしいよね食べ過ぎると太るよ!)のいずれかを選びます。ヒットしなければループを抜け、ランダム辞書からひとつ返します。

また、%match%という文字列をパターンに置き換えています。これにより、「チョコ食べたい」と発言すれば「チョコおいしいよね」と返事をし、「チョコレート食べたい」と発言した場合は「チョコレートおいしいよね」になって返ってきます。

サンプルが少ないのでありがたみが感じられませんが、そこはセンスの見せ所です。自分の手でAIを賢くしてあげましょう。

Unmo側の変更

Dictionaryを追加し、Responderにも変更が加わったので、ユーザーであるUnmoクラスにも変更を反映させる必要があります。unmo.pyを見てみましょう。

from random import choice
from responder import WhatResponder, RandomResponder, PatternResponder
from dictionary import Dictionary


class Unmo:
    """人工無脳コアクラス。

    プロパティ:
    name -- 人工無脳コアの名前
    responder_name -- 現在の応答クラスの名前
    """

    def __init__(self, name):
        """文字列を受け取り、コアインスタンスの名前に設定する。
        Responder(What, Random, Pattern)インスタンスを作成し、保持する。
        Dictionaryインスタンスを作成し、保持する。
        """
        self._dictionary = Dictionary()

        self._responders = {
            'what':   WhatResponder('What', self._dictionary),
            'random': RandomResponder('Random', self._dictionary),
            'pattern': PatternResponder('Pattern', self._dictionary),
        }
        self._name = name
        self._responder = self._responders['pattern']
    # 後略

変更点はわずかですね。Dictionaryオブジェクトを作成し、各Responderに渡すようになっただけです。あとは最初にセットされるResponderがPatternResponderになったくらいです。

では会話してみましょう。

f:id:sandmark:20171009033450j:plain
チャットボットっぽくなってきた?

当初と比べて随分成長しましたね。たまに予想のつかない話の流れになってハっとさせられますが、しかし結局は開発側が用意した辞書。自作小説のネタバレを書いて、後から順を追って読んでいるような、微妙な気分にさせられます。

そこで次回は『学習』をテーマに書いていきたいと思います。余力があれば『形態素解析による学習』にも触れていきます。

今回のソースコードこちらからどうぞ。