Python初心者に送る「人工知能の作り方」 Part3
前回はAIに思考エンジンを追加しました。まだまだ会話にはなりませんが、継承を使ってコードを簡潔に保つことで、機能を追加しやすい状況にしています。今回のソースはこちら。
今回のテーマは『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]
まるで暗号のようですが、理解してしまえば単純ですし、使わないという選択肢もあります。端的に説明すると、
- まず
f.read().splitlines()
を呼び出し、行ごとに分割する - 分割した行を
x
に代入する if x
を実行し、空文字列でないものだけ絞り込む- リストにする
- リストを
self._responses
に代入する
という処理を一行で表現しています。リスト内包表記はHaskellやClojureにもありますね。Rubyにはありませんが、split
やselect
を使って一行で表現できます。
必ずしも使いこなさなくてはならないものではありませんが、コードが短くなるケースも多く、知っていると周りから「やるじゃん」とか思われたりするかもしれません。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.split('|')}) @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
クラスを継承するRandomResponder
やPatternResponder
でDictionary
をロードする必要がなくなります。
本題のPatternResponder
のresponse
メソッドを見てみましょう。パターン辞書に対してループを回して、パターンに合致するもの(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
になったくらいです。
では会話してみましょう。
当初と比べて随分成長しましたね。たまに予想のつかない話の流れになってハっとさせられますが、しかし結局は開発側が用意した辞書。自作小説のネタバレを書いて、後から順を追って読んでいるような、微妙な気分にさせられます。
そこで次回は『学習』をテーマに書いていきたいと思います。余力があれば『形態素解析による学習』にも触れていきます。