すなぶろ

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

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

前回はAIを作るにあたって、全体の骨組みとインターフェイスを作ってみました。結果として「○○ってなに?」としか発言しない、とてもAIとは呼べないものになっていますが、今回はもう少し反応を改善してみます。

sandmark.hateblo.jp

前回のおさらい:

  • Responderクラス
    • 入力にもとづいて応答を返す思考エンジン。
  • Unmoクラス
    • 人工無脳コア。
    • 思考エンジンResponderを持ち、ユーザーと思考エンジンを接続する。
  • フロントエンド
    • とりあえずコンソールアプリケーション。
    • ユーザーからの入力を受け取り、Unmoオブジェクトへ渡し、応答結果を表示する。

しかし改善するといっても、どうやって行えばいいのでしょうか。また、Responder, Unmo, インターフェイスがひとつのmain.pyに記述されているのもいただけません。ひとつひとつ見ていきましょう。

f:id:sandmark:20171007192138j:plain
改善(?)されたAIとの会話

今回のソースはsandmark/unmoに置いてあります。


ファイルを分割する

「応答のバリエーションを増やす」ということは、「Responderを増やす」ということに他なりません。つまりResponderクラスの定義が長くなるということなので、ひとつのファイル(main.py)にいろいろなメソッドや関数が増えていくと管理が大変です。

そんなわけで、Responder, Unmo, インターフェイスの3つのファイルに分割しましょう。まずは短くなったmain.pyからご覧ください。

from unmo import Unmo


def build_prompt(unmo):
    """AIインスタンスを取り、AIとResponderの名前を整形して返す"""
    return '{name}:{responder}> '.format(name=unmo.name,
                                         responder=unmo.responder_name)


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))

importが追加され、UnmoResponderの記述がなくなりました。

from unmo import Unmoというのは、「unmo.pyからUnmoという名前をロードする」という意味です。インターフェイスであるmain.pyからResponderを直接操作する必要はないので、人工無脳コアクラスのロードに留めています。

この一文がないと

Traceback (most recent call last):
  File "main.py", line 12, in <module>
    proto = Unmo('proto')
NameError: name 'Unmo' is not defined

というエラーが表示されます。「Unmoという定義されていない名前を使おうとしている」という内容なので、正しいエラーです。ここで知っておいて欲しいのが、「エラーが出たから一巻の終わりだ」という考え方は間違いだ、ということです。

本当に怖ろしいのは「間違っているのにエラーが出ない。その結果バグに気づかず、動作がおかしいまま起動してしまうこと」です。おかしいコードに対しておかしいと言ってくれるエラーという存在は、むしろ親のように大切にすべきものです。

話を戻して、次は新しく作ったunmo.pyの内容です。

from responder import Responder


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

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

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

    def dialogue(self, text):
        """ユーザーからの入力を受け取り、Responderに処理させた結果を返す。"""
        return self._responder.response(text)

    @property
    def name(self):
        """人工無脳インスタンスの名前"""
        return self._name

    @property
    def responder_name(self):
        """保持しているResponderの名前"""
        return self._responder.name

こちらも違いはわずかです。main.pyと違って、UnmoクラスはResponderクラスを扱わなければなりません。そこで、from responder import Responderとしてロードしています。

最後にresponder.pyを見てみましょう。

class Responder:
    """AIの応答を制御するクラス。

    プロパティ:
    name -- Responderオブジェクトの名前
    """

    def __init__(self, name):
        """文字列を受け取り、自身のnameに設定する。"""
        self._name = name

    def response(self, text):
        """ユーザーからの入力(text)を受け取り、AIの応答を生成して返す。"""
        return '{}ってなに?'.format(text)

    @property
    def name(self):
        """応答オブジェクトの名前"""
        return self._name

このファイルからは何もインポートしていません。Responderクラスは他の機能を使わないので、何もロードする必要はありません。

こうしてファイルを分割した状態でも、python main.pyで以前と同じ動作をすることを確認してください。

Responderの拡張

無事ファイルを分割できたところで、Responderを集中して拡張する準備ができました。

「○○ってなに?」と返すResponderだけではなく、ランダムな文字列から選んで返すものを追加してみましょう。responder.pyを見てみます。

from random import choice


class Responder:
    """AIの応答を制御する思考エンジンクラス。

    プロパティ:
    name -- Responderオブジェクトの名前
    """

    def __init__(self, name):
        """文字列を受け取り、自身のnameに設定する。"""
        self._name = name

    def response(self, text):
        """ユーザーからの入力(text)を受け取り、AIの応答を生成して返す。"""
        return '{}ってなに?'.format(text)

    @property
    def name(self):
        """思考エンジンの名前"""
        return self._name


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

    クラス変数:
    RESPONSES -- 応答する文字列のリスト

    プロパティ:
    name -- RandomResponderオブジェクトの名前
    """

    RESPONSES = ['今日はさむいね', 'チョコたべたい', 'きのう10円ひろった']

    def __init__(self, name):
        """文字列を受け取り、自身のnameに設定する。"""
        self._name = name

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

    @property
    def name(self):
        """思考エンジンの名前"""
        return self._name

新たにRandomResponderが追加されました。見ていきましょう。

RandomResponder.RESPONSESには、3つの文字列を含むリストが定義されています。responseメソッドを呼び出したときに、このうちのどれかがランダムに選択されて返ってくるわけです。

「リストからひとつをランダムに選択する」という機能は、Pythonにデフォルトで用意されています。randomモジュールに含まれるchoice関数で、ファイルの先頭でインポートし、responseメソッドの中で使っています。

ユーザーの入力は一切無視する形になるので、responseメソッドの引数は「使用しない」ことを明示する_になっています。Unmoクラスは必ずresponse(text)の形式で呼び出すので、引数は受け取らなければなりません。ただ使用しないだけです。

動作確認

Pythonシェルを起動します。

$ python
>>> from responder import RandomResponder
>>> r = RandomResponder('random')
>>> r.response('こんにちは、トム。')
'今日はさむいね'
>>> r.response('これはトムですか?')
'今日はさむいね'
>>> r.response('いいえ、それはトムではありません。')
'チョコたべたい'
>>> r.response('これは何ですか?')
'今日はさむいね'
>>> r.response('それは花瓶です。')
'きのう10円ひろった'

もはや会話の体をなしていません。こちらの言うことにある程度の興味を示してくれるResponderのほうが、まだコミュニケーションのしがいがあったというものです。

同じコードをまとめる

それにしても、__init__nameメソッドは変わり映えしませんね。必要だから定義せざるを得ないのですが、仮に「nameメソッドはself._nameではなく'Hey!'という文字列にしたい」という意味不明で抗いがたい欲求に苛まれたときに困ります。

ResponderRandomResponderに定義されているnameメソッドを両方書き換えなければなりませんし、これからもResponderは増えていく予定ですから、同じコードが増えるばかりでメンテナンスが大変です。

そこで、共通の機能はResponderにまとめてしまうことにします。「○○ってなに?」と返す思考エンジンには新たにWhatResponderという名前を与えましょう。

新生responder.pyはこちらです。

from random import choice


class Responder:
    """AIの応答を制御する思考エンジンの基底クラス。
    継承して使わなければならない。

    メソッド:
    response(str) -- ユーザーの入力strを受け取り、思考結果を返す

    プロパティ:
    name -- Responderオブジェクトの名前
    """

    def __init__(self, name):
        """文字列を受け取り、自身のnameに設定する。"""
        self._name = name

    def response(self, *args):
        """文字列を受け取り、思考した結果を返す"""
        pass

    @property
    def name(self):
        """思考エンジンの名前"""
        return self._name


class WhatResponder(Responder):
    """AIの応答を制御する思考エンジンクラス。
    入力に対して疑問形で聞き返す。"""

    def response(self, text):
        """文字列textを受け取り、'{text}ってなに?'という形式で返す。"""
        return '{}ってなに?'.format(text)


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

    クラス変数:
    RESPONSES -- 応答する文字列のリスト
    """

    RESPONSES = ['今日はさむいね', 'チョコたべたい', 'きのう10円ひろった']

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

ぐっと短くなりました。WhatResponderRandomResponderの間で同じ処理をしていた__init__nameメソッドはResponderクラスにまとめられ、それぞれのクラスに同じコードを書かなくても済みます。これでコピペしたりする必要もありませんし、バグの発生確率もかなり減りました(専門用語でDRYとか『差分プログラミング』といいます)。

まとめられた共通のコードは、class RandomResponder(Responder)のようにResponderを指定することで組み込むことができます。これを継承と呼び、今回の場合は「ResponderクラスをWhatResponderRandomResponderクラスが継承している」または「WhatResponderおよびRandomResponderは、親クラスとしてResponderを持つ」と表現します。

__init__nameメソッドは継承により省略できますが、responseメソッドはそうも行きません。というより、子クラスで独自に定義してもらうものなので、親クラスであるResponderresponseメソッドの定義が不可能なのです。

再掲しましょう。

def response(self, *args):
    """文字列を受け取り、思考した結果を返す"""
    pass

今回は*args引数とpass文を使っています。

*argsはいわば「引数はなんでもいい」ことを表すキーワードで、WhatResponderRandomResponderでは引数が使われたり無視されたりするため、一定の形を持たないことを宣言しています(これが正しいのかどうか私には判断がつかないのでPythonistaからの意見募集中です)。

pass文は「何もしない」ことを宣言するキーワードです。Responderは継承されることが前提の骨組みのようなクラスなので、実際に応答を考える必要はありません。かといってメソッド定義に何も書かないわけにはいかないので、pass文を使って明示的に「何もしない」と宣言しています。

Unmoクラスに反映する

では早速、RandomResponderを使うようにUnmoクラスを変更しましょう。unmo.pyはこんな感じになります。

from responder import RandomResponder


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

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

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

    def dialogue(self, text):
        """ユーザーからの入力を受け取り、Responderに処理させた結果を返す。"""
        return self._responder.response(text)

    @property
    def name(self):
        """人工無脳インスタンスの名前"""
        return self._name

    @property
    def responder_name(self):
        """保持しているResponderの名前"""
        return self._responder.name

といってもちょっとしか変わっていません。インポートするものがRandomResponderになり、self._responderの中身がRandomResponder('Random')に変わっただけです。

あれだけresponder.pyの中身をいじったのにunmo.pyの変更がこれだけで済むというのは大きな利点です。ファイルを分割したこともそうですが、RandomResponderはあくまでもResponderの一種である」という保証があるため、Responderを扱う側(Unmo)のコードの変更は最小限で済みます。これがオブジェクト指向プログラミングを行うメリットのひとつであると言えます。

オブジェクト指向プログラミングでなくとも、こういった『構造化』はデザインテクニックとして様々な言語で用いられています。Haskellであれば型システムを使って、Lispであればジェネリック関数を使って、C言語においては構造体を使うことで実現しています。オブジェクト指向プログラミングが必ずしも「ベストな答え」を提供するわけではなく、「ベターを目指すための手法のひとつ」に過ぎないことを忘れないでください。そうすれば、オブジェクト指向言語でなくともクリーンなコードを書くことができます。

動かしてみる

せっかく書き直したWhatResponderが使われていませんが、それは後の課題として、とりあえず動かしてみましょう。今回はどんなチグハグな会話になるのか楽しみです。

f:id:sandmark:20171007192138j:plain
改善(?)されたAIとの会話

……会話になっているのでしょうか。そして今回のテーマは『改善』だったにも関わらず、こちらがかなり歩み寄らなければならない状態になっています。こんな調子で大丈夫でしょうか……。

次回の課題

複数のResponderを定義することに成功したので、今度はこれらをプログラムの実行中に動的に切り替えてみたいと思います。コンパイルの必要がないとはいえ、いちいちソースを編集していたのでは手間がかかります。プログラミングの真髄は自動化なのです。

「どういうときにどのResponderを使うか」を判断して……というのは難しいので後回しにしましょう。現時点のResponder達はそんな高尚なものを扱っても期待に応えてくれるとは思えません。

また、ユーザーの入力を学習してファイルに保存する機能も実装します。『学習』というワードが入ると一気にAIっぽくなりますね。ただしこれまでの経験上、過度な期待はしないほうが良さそうですが。予想以上に記事が長くなったので次々回に持ち越しです。ごめんなさい。