すなぶろ

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

Clojure 入門者による【チャットボットづくり】 Part3

大切なことを言い忘れていました。このチャットボットの元ネタは、Ruby 向けの書籍『恋するプログラム』で紹介されていた unmo という名前のチャットボットです。今ではあまり聞きませんが、一昔前にチャット専門の AI がちょっとしたブームになった頃、そのトンチンカンな返事を揶揄して『人工知能』ならぬ『人工無能』と呼ばれていました。この MunoアナグラムUnmo となり、雲母(うんも・きらら)という宝石の名前にかかっています。

2005 年に刊行された古い本ですが、思考エンジンをひとつひとつ追加して完成に近づけていく感覚が楽しく、新しい言語を学ぶときはいつもこれを実装することにしています。著者の方が急逝されたのが残念でなりませんが、現在は電子書籍として復刊されているので、この記事でチャットボットに興味を持った方はぜひ手にとってみてください。

というわけで今回は『辞書』および『ランダムレスポンス』を実装していきます。

目次

ランダム辞書を作る

チャットボットである以上、会話の中で成長してほしいものです。そしてある日突然自我に目覚め、人類を支配しようとしたり、あるいは歩み寄ったりしてほしい。AI にはロマンがてんこ盛りです。

しかしさすがに無から何かを学習するプログラムを作るのは現代技術では不可能だと思われますので、まずは人間の力を貸してあげましょう。辞書の形式はこんな感じです。

{:random ["今日はさむいね" "きのう 10 円ひろった" "チョコ食べたい"]}

辞書の正体はただの Map です。 :random キーの値は「今日はさむいね」「きのう 10 円ひろった」「チョコ食べたい」という3つの文字列が入ったベクタです。この中からランダムにひとつ選択して返すことで、少なくとも前回よりは一歩進んだチャットボットに進化するはずです。

リストとベクタ

Clojure には「要素の集まり」を表現するデータ型がいくつかありますが、そのうち代表的なものが『リスト』と『ベクタ』です。この2つは大体似たような動作をしますし、そもそも (= '(1 2 3) [1 2 3]) => true になるので、 真にするくらいならなぜリテラルを別にした? という疑問が閉じ括弧のように湧き出てきます。聞いた話では

  • リストは遅延シーケンスになりうるけど、ベクタはならない
  • 無限リストは作れるけど、無限ベクタを作ろうとすると処理が返ってこなくなる
  • でも無限リストを使う機会があまりないので、基本的にベクタを使う
  • つまり Clojure は ListProcessor 族ではなく、VectorProcessor という新型エイリアンなんだよ!!

とのことです(個人の感想が多く含まれています)。入門者としては「ベクタを使えばいいらしい」で思考を停止させておかないと遠い世界に旅立ちそうなので、調べるのはやめます。

respond :random

辞書を用意したところで、それを使ってくれなければ困ります。コレクションから要素をランダムに選択してほしいときは rand-nth 関数を使います。

(let [dictionary {:random ["今日はさむいね" "きのう 10 円ひろった" "チョコ食べたい"]}]
  (-> dictionary
      (get :random)
      (rand-nth)))
=> "チョコ食べたい"

何度か評価してみると、その都度返り値が違うのがわかると思います。上記はテストコードですが、実はこれで respond :random の実装はほとんど終わっています。ちょっと書き換えて関数にしてしまいましょう。

(defmethod respond :random [request]
  (let [dictionary (:dictionary request)]
    (-> dictionary
        (get :random)
        (rand-nth))))

SPC k s ((sp-forward-slurp-sexp)) などの S 式操作コマンドを駆使すれば、テストコードを defmethod で包み込んで関数本体にすることができます。Lisp 系言語を触るならぜひとも覚えておきたい機能ですね。 などとドヤ顔でほざく私は、こういったコマンドを長らく知らないまま S 式を書いてきた情報弱者です。 ごめんなさいめっちゃ便利でした……知らなくてすみません……。

しかしこのままでは respond :what と動作が違っています。あちらは Map オブジェクトを返しますが、この関数が返すのは文字列のみ。また rand-nth は与えられたコレクションが空だと IndexOutOfBoundsException 例外を投げてしまうので、プログラムがそこで終了してしまいます。この2点を修正してみましょう(ついでに分配束縛を使って let も省略します)。

(defmethod respond :random [{{random :random} :dictionary :as request}]
  (if (empty? random)
    (assoc request :error {:message "ランダム辞書が空です。"
                           :type    :dictionary-empty})
    (assoc request :response (rand-nth random))))

ランダム辞書が空であった場合は、 :response の代わりに :error を設定するようにしました。「例外をハンドリングするより全部 Map に詰め込んだほうが楽になりそう」という思いつきでこういう実装にしただけで、特に深い意味はないです。でも「Clojure はデータベースに問い合わせた結果も認証情報も API が返すデータも、何もかも Map なんだよ」という話は聞いていたので、 Map の母性愛すごい みたいなことは考えました。

では動作テストをしてみましょう。

unmo.core> (respond {:responder :random :dictionary {:random ["今日はさむいね" "きのう 10 円ひろった" "チョコ食べたい"]}})
{:responder :random, :dictionary {:random ["今日はさむいね" "きのう 10 円ひろった" "チョコ食べたい"]}, :response "きのう 10 円ひろった"}

unmo.core> (respond {:responder :random :dictionary {}})
{:responder :random, :dictionary {}, :error {:message "ランダム辞書が空です。", :type :dictionary-empty}}

そうだった Map で返ってくるんだった。 自分で実装しておきながらこの仕様に慣れません。今後巨大になるであろう辞書がまるごと関数を行ったり来たりするのは果たして健康的なのかどうかはさておき、意図した通りに動いてくれています。怪しい部分は未来の自分がリファクタリングしてくれるでしょう。

次回は辞書の学習だよ

学習も書こうかと思いましたが、もう疲れたので次回に持ち越しすることにします。 コード量のわりに無駄な文章が多いのが疲れる原因 だとわかってはいるんですが、コードだけで全てを語れるほど男らしくもなれませんので、このペースでだらだら続けていきます。なお、記事に掲載するにあたって過去のコードを読み返すたびに「なんというクソコードなの……」と絶望感に打ちひしがれていますので、石や腐ったトマトなどを投げつけるといった行為はできるだけお控えください。一応こっそりリファクタリングしています……。