すなぶろ

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

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

前回は respond :random を実装しました。辞書も Map にしようというところまでは決めましたが、それをどう使っていくかに関しては説明していません。

ということで今回は『ランダム辞書の学習』『辞書ファイルの読み書き』をコーディングしていきます。同時にコード量も増えていくことが予想されるので、『ソースファイルの分割』についても。

目次


これまですべて core.clj に記述してきましたが、コードが縦に長くなると見通しが悪くなって大変です。今後 respond 関数の実装はもう少し増えますし、新たに辞書機能を実装する必要性も出てきました。そこで、 core.clj, responder.clj, dictionary.clj というファイルに分けて記述することにします。

responder.clj - AI の思考エンジン

これまでに実装した respond 関数は、 :what, :random の2つです。これらに加えて、あと3つのレスポンダーを追加する予定です。

ひとつずつ作っていくとして、まずはこれまでの実装をまとめましょう。

(ns unmo.responder)

(defmulti respond :responder)

(defmethod respond :what [{:keys [input] :as request}]
  (->> (str input "ってなに?")
       (assoc request :response)))

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

(defmethod respond :default [{:keys [responder]}]
  (throw (IllegalArgumentException.
          (if responder
            (str "存在しない Responder です (" responder ")")
            (str "Responder が指定されていません")))))

ファイルの先頭に (ns unmo.responder) と書くのを忘れないようにしてください。コードを分離したことで、他のファイルから respond 関数を呼び出すときは unmo.responder/respond のように『名前空間』を指定する必要が出てきます。 clojure.string/blank? と同じですね。 ns が無いと「 unmo.responder なんて名前空間は知らん」と怒られてしまいます。

core.clj - ユーザーインターフェイス

今はコンソールアプリケーションですが、後からグラフィカルなプログラムにしたり、サーバにデプロイして API を公開したりと、いろんな拡張をしたりするかもしれません(このブログではやらないけど)。思考は responder.clj がやってくれますので、 core.clj には『遊び』を残しておきましょう。

ついでにインターフェイスも改善します。

(ns unmo.core
  (:gen-class)
  (:require [bigml.sampling.simple :as simple]
            [clojure.string :as str]
            [unmo.responder :refer [respond]]))

(defn- rand-responder []
  (-> [:what :random]
      (simple/sample :weigh {:what     0.2
                             :random   0.8})
      (first)))

(defn- format-response [{:keys [responder response error]}]
  (let [responder-name (-> responder (name) (str/capitalize))]
    (if error
      (str responder-name "> 警告: " (:message error))
      (str responder-name "> " response))))

(defn -main [& args]
  (loop [dictionary {:random ["今日はさむいね" "きのう 10 円ひろった" "チョコ食べたい"]}]
    (print "> ")
    (flush)
    (let [input (read-line)]
      (if (str/blank? input)
        (println "Quit.")
        (do (-> {:responder (rand-responder), :input input, :dictionary dictionary}
                (respond)
                (format-response)
                (println))
            (recur dictionary))))))

新しく関数が2つ増え、 loop にも追加されたところがあります。ひとつひとつ見ていきましょう。

rand-responder

これまでレスポンダーは REPL 上で手動で指定してきました。でも実際に会話するときに「次は WhatResponder さんから『こんにちはってなに?』って聞き返されたいなぁ」と思う人はたぶんいません。いたらごめんなさい。大抵の人は『ボットからの予期しない返答』を期待しているはずなので、話しかけるときにレスポンダーを指定するのはネタバレになってしまいます。

rand-responder:what, :random のどちらかをランダムで返す関数です。単なる (rand-nth [:what :random]) と違うのは、確率を指定しているところです。というのも rand-nth では :what が10回連続で表れる可能性があり、さすがに10回も「なにそれ?」と聞き返されてはたまったものではありません。

bigmlcom/sampling はランダムサンプリングを行ってくれるライブラリで、 :weigh オプションを指定することで要素の偏りを表現してくれます。ここでは :what が 20%、 :random が 80%の確率で表れるよう指定しています。新たなレスポンダーを追加したら、この確率を調整して自然な会話を演出しましょう。

defn- - プライベート関数

関数定義は `defn` ですが、 `rand-responder`, `format-response` の定義では `defn-` とハイフンがついていました。これは関数を他の名前空間に公開しない、つまりプライベートなものであるということを明示しています。

これらの関数は `unmo.core` 固有のものであり、また補助関数であるという特徴があります。外部、例えば `unmo.responder` から `unmo.core/rand-responder` が呼び出されるようなことがあってはなりません。何のために名前空間を分けたのかわからなくなりますので、明示的にプライベートにしておき、あとで必要になったら新しく名前空間を作って行けば良いでしょう。

format-response

respond の戻り値は Map オブジェクトですから、あまり人間が読むのに向いているとは言えません。そこで、 What> Clojure ってなに? のような文字列に整形するための関数がこの format-response です。

:what のようなキーワードは name 関数で文字列にすることができ、それを clojure.string/capitalize で先頭を大文字に変換しています。

:error キーが設定されていた場合は、 :response ではなく :error :message を返すことで、例えば Random> 警告: ランダム辞書が空です という結果になります。

loop

上記を使って会話のキャッチボールを実現させるのが loop の仕事です。今回から dictionary という変数を使うようになり、初期値は {:random ["今日はさむいね" "きのう 10 円ひろった" "チョコ食べたい"]} になっています。

input が空行でなければ会話を成立させなければなりませんので、まずは respond に渡すための Map オブジェクトを作ります。内訳は rand-responder で決められるランダムなレスポンダー、ユーザーの入力 input 、そして辞書 dictionary です。

作られた Map オブジェクトは respond に送られ、その結果が format-response され、さらにそれを画面に表示します。それが終わったら繰り返しです。 recur の引数に dictionary を指定しているのは、 loop で使う変数がひとつ増えたからですね。

ただし、 dictionary の値はずっと変わりません。『学習』を行う処理を書いていないので、何百回繰り返しても respond :random の結果は3パターンのままです。これではランダムでも何でもありませんから、 dictionary.clj に辞書を扱う関数を書いていきましょう。

dictionary.clj - 辞書の学習・保存・読込

学習

ランダム辞書の学習は単純に、ベクタにユーザーの発言を追加していけば良いでしょう。でも同じ発言を学習して偏りが出ても困りますので、既に辞書にある言葉は学習しないでほしいところです。例えばこんな感じ。

unmo.core> (study-random {} "こんにちは")
{:random ["こんにちは"]}
unmo.core> (study-random *1 "おはよう")
{:random ["こんにちは" "おはよう"]}
unmo.core> (study-random *1 "こんばんは")
{:random ["こんにちは" "おはよう" "こんばんは"]}
unmo.core> (study-random *1 "こんにちは")
{:random ["こんにちは" "おはよう" "こんばんは"]}

ここで行われているのは『ベクタに要素を追加する』『 Map の特定のキーの値を更新する』の2つに分けられます。

conj - コレクションに要素を追加する

Lisp といえば cons が有名です。というよりもリストというデータ型がもともと cons から生まれた(らしい)ので、古いルーツを持つ言語である Haskell などでは、同じ動作をする演算子 : の名前が「cons 演算子」だったりします。ちなみに Construction の略なんだそうで。

Clojure にも cons 関数はありますが、似た動作をする conj (Conjunction)のほうがよく見受けられる……気がします。他の Lisp を触っていた人は conj を見て「typo かな?」と思うかもしれません(私のことです)。 consconj の違いについては深く掘り下げません。よくわかんないからです。

unmo.core> (conj [] "A")  ; コレクションと要素を受け取る
["A"]
unmo.core> (conj *1 "B" "C")  ; 要素は複数あっても良い
["A" "B" "C"]
unmo.core> (conj nil "D")  ; nil を渡すとリストになる
("D")

conj は要素が重複しているかどうかはチェックしないので、そこだけ関数にする必要があります。「与えられた要素 x がコレクション coll に含まれていない場合のみ conj する」という conj-unique を実装しましょう。 dictionary.clj に記述します。

(ns unmo.dictionary)
    
(defn- conj-unique [coll x]
  (if (some #{x} coll)
    coll
    (conj coll x)))

(some #{x} coll) は「coll に x が含まれているかどうか」を判定するイディオムです。 clojure.corein? といった名前で定義されていても良さそうなものですが、私にはわからない理由で存在しないようです。 contains? という紛らわしい名前の Map 用の関数 があって、世界中の Clojure 入門者の頭を悩ませているとかいないとか。

セット - 重複のないコレクション

Map, Vector, List と紹介してきましたが、Clojure にはもうひとつコレクションがあります。 #{1 2 3} という形式で表現される Set は『集合』であり、 clojure.set には project, intersection, union など集合演算関数が用意されています。「キーだけの Map」と言ってもいいかもしれません。上記 conj-uniquesome の引数としてもちょっとだけ登場していますね。

本質的に重複が許されないので、 (conj #{} 1 1 1) としても必ず #{1} が返ってきます。辞書に最適なデータ型なのですが、 インデックスがないため rand-nth できない という致命的な問題があったため見送りました……。 vec でベクタに変換すればもちろん rand-nth が使えますが、辞書が大きくなればなるほどオーバーヘッドがやばくなりそうなので、わりと仕方なく conj-unique を使っています。

update - Map の値を更新する

update 関数は最初のうちはちょっと戸惑いますが、慣れてしまえば簡単です。 assoc が「特定のキーの値を『別の値』で置き換える」のに対し、 update は「特定のキーの値を『関数の戻り値』で置き換える」というもの。今回のように「ベクタを conj-unique で更新したい」という場合はこうします。

unmo.core> {:random [1 2 3], :other-keys "他のキーには", :wont-be-affected "影響しないよ"}
{:random [1 2 3], :other-keys "他のキーには", :wont-be-affected "影響しないよ"}
    
unmo.core> (update *1 :random conj-unique 4)
{:random [1 2 3 4], :other-keys "他のキーには", :wont-be-affected "影響しないよ"}
    
unmo.core> (update *1 :random conj-unique 1 3 5)
{:random [1 2 3 4 5], :other-keys "他のキーには", :wont-be-affected "影響しないよ"}

:random の値だけが変動しているのがわかります。他のキーがあろうとなかろうと update は指定されたキー( :random )にしか触らないので、どんな巨大な Map でも気にせずいじることができます。また、 update に渡す関数から後ろの引数はすべてその関数( conj-unique )の引数としてそのまま渡されます。

これを assoc でやろうとすると結構大変です。

unmo.core> {:random [1 2 3], :other-keys "他のキーには", :wont-be-affected "影響しないよ"}
{:random [1 2 3], :other-keys "他のキーには", :wont-be-affected "影響しないよ"}

unmo.core> (let [value         (:random *1)
                 applied-value (conj-unique value 3 4 5)]
             (assoc *1 :random applied-value))
{:random [1 2 3 4 5], :other-keys "他のキーには", :wont-be-affected "影響しないよ"}

大変なんです。

study-random - ランダム辞書の学習

材料が揃いましたので、さっくり study-random を実装してしまいましょう。もっとも、さっき完成コードが登場していましたが…… dictionary.clj に以下のように追記します。

(defn- study-random [dictionary input]
  (update dictionary :random conj-unique input))
    
(def study [dictionary input]
  (study-random dictionary input))

study-random もまたプライベート関数になっています。代わりに study という関数が公開されていて、その中身は study-random を呼び出すだけ。

これには理由があります。今後いろいろなレスポンダーが増えるにつれて、辞書も多様化することが予想されます。つまり study-patternstudy-template などが増えるということなんですが、 unmo.core から見れば dictionary というひとつのオブジェクトであることに変わりありません。細かい辞書の仕様は unmo.dictionary に任せて、とりあえず study だけ使えれば良いのです。

保存

Clojure には edn(Extensible Data Notation)というデータフォーマットがあります。語弊があるのを承知で言えば JSON のようなものですが、Clojure のコードであるという点で大きく違います。

本当に生の Clojure コードですので、シリアライズなどは一切必要ありません。今回辞書は Map オブジェクトで実装していますから、これをそのままファイルに書き出せば保存完了ということになります。 save-dictionary 関数を実装しましょう。

(ns unmo.dictionary
  (:require [fipp.edn :rename {pprint fipp}]))

(defn save-dictionary [dictionary filename]
  (let [data (with-out-str
               (binding [*print-length* false]
                 (fipp dictionary)))]
    (spit filename data :encoding "UTF-8")))

fipp は PrettyPrinter です。Map オブジェクトをそのままファイルに書き込んでしまうと、後からファイル経由で辞書を編集しようと思ったときに、ものすごく長い1行で保存されていたりして困ります。そこで PrettyPrinter(オブジェクトを人間が読みやすい形で改行とか入れてくれる仕組み)を使うのですが、Clojure にデフォルトで入っている pprint はちょっと動作が遅いのです。

というより普通の使い方(関数の戻り値を整形するくらい)に最適化されているはずなので、今回のように巨大な Map オブジェクトを PrettyPrint したいときは、素直にライブラリに頼るのが良いでしょう。 fipp.edn には pprint という名前の PrettyPrinter が用意されていますが、そのままロードすると clojure.pprint/pprint と競合するので、 fipp という名前に置き換えています。

fipp 関数は文字列を戻り値とするのではなく、整形したものを *out* という名前のストリームに書き出します(特にいじらない限りデフォルトで標準出力になっているはずです)。今回は文字列として値がほしいので、 with-out-str フォームで出力を横取りします。

さらに *print-length* というダイナミック変数に数値、例えば 100 が設定されている場合、要素が 101 個以上のベクタなどは [99 100 ...] のように省略されてしまいます。辞書がカットされては本末転倒なので、 binding フォームで一時的に false を設定しています。

spit は指定されたファイルに文字列を書き込む関数です。他の言語に比べて驚くほど簡潔に書くことができますが、ロックとかそういうものがどうなっているのかは知りません。調べましょう。

ダイナミックスコープ

ほとんどの言語がレキシカルスコープを採用する中、Clojure は(宣言が必要になるけど)ダイナミックスコープな変数を定義することができます。「変数の中身を変えるだけで fipp の動作が大幅に変わる」という上記の魔法もそうで、Lisp 族にはよく見られる光景です。

ダイナミックスコープはレキシカルスコープよりも実装が簡単で、自分で Lisp 処理系などを作っていると、「気付いたらダイナミックスコープになっていた」なんてことがあります。ともすれば「レキシカルスコープの実装にバグがある」と思われがちですが、使いこなせば強力な道具になります。とりわけ Lisper は(歴史的な背景もあって)ダイナミックスコープに慣れているようです。

ただし諸刃の剣であることは確かなので、Clojure では meta 情報に ^:dynamic true を指定しない限りレキシカルスコープが採用されるようになっています。慣例的に * で囲まれた変数はダイナミックスコープとされています(が、自動でダイナミックスコープになるわけではありません)。興味があれば調べてみてください。私も詳しくないです。

読み込み

続いて load-dictionary 関数を実装しましょう。ファイルが存在しなかった場合は {} を返すようにします。

(ns unmo.dictionary
  (:require [clojure.edn :as edn]      ;; 追加
            [clojure.java.io :as io])) ;;

(defn- file-exists [filename]
  (.exists (io/as-file filename)))

(defn load-dictionary
  "指定されたファイルから辞書をロードして返す。"
  [filename]
  (if (file-exists? filename)
    (-> filename
        (slurp :encoding "UTF-8")
        (edn/read-string))
    {}))

ファイルの存在チェックは Java を直接呼び出します。 clojure.java.io/as-file は与えられたファイルの java.io.File インスタンスを生成するため、 .exists メソッドを呼び出して存在可否を真偽値で取得することができます。

存在すれば slurp 関数で中身をまとめて読み出し、 edn/read-stringClojure の Map オブジェクトに戻しています。

Java の知識

io/as-file はファイルが存在した場合にその中身を返すので、この実装だとファイルの読み込みが2回発生していることになります。 as-fileslurp です。

Java の知識がほしいと思うのはこういうときです。Clojure のスタンスは「Java に実装されている機能があるならそれを呼び出したほうが効率的」であって、あくまでも Java に取って代わる気はなく、共存を目指しているように見えます。それはもちろん JVM という技術の結晶があるからこそであって、わざわざ車輪の再発明をすることはないよね、とも思うんですが……。

このまま Clojure を学ぶべきか、一旦 Java を学ぶべきか、あるいは両方必要なところだけ学んでいくのか。入門者にとっては悩ましいところであります。

core.clj - 辞書と連携する

晴れて辞書が実装できましたので、 core.clj に反映してみましょう。

(ns unmo.core
  (:require [unmo.dictionary :refer [save-dictionary load-dictionary study]]))  ;; 追加

(def dictionary-file "dict.edn") ;; 追加

(defn -main [& args]
  (loop [dictionary (load-dictionary dictionary-file)] ;; 変更
    (print "> ")
    (flush)
    (let [input (read-line)]
      (if (str/blank? input)
        (do (println "Saving dictionary: " dictionary-file)  ;; 追加
            (save-dictionary dictionary dictionary-file)
            (println "Quit."))
        (do (-> {:responder (rand-responder), :input input, :dictionary dictionary}
                (respond)
                (format-response)
                (println))
            (recur (study dictionary input))))))) ;; 変更

基礎ができあがっていたのと、 unmo.dictionary に処理を丸投げしているので、修正箇所はわずかで済みました。

辞書ファイルの名前は dict.edn としましょう。 fipp を使って PrettyPrint してあるため、好きなエディタでいつでも覗いたり、編集したりすることができます。

Note: これまではランダム辞書をベクタとしてきましたが、 {} から学習を始める今回の辞書では (conj-unique nil input) が呼び出されることになります。 nilconj したときの動作は先ほども確認した通りリストが返ってきますので、 dict.edn:random の値もリストになっているはずです。動作に影響はありません。たぶん。

次回は PatternResponder を実装するよ

辞書のことは考えなくても良くなったので、安心して respond 関数を実装していけます。次のレスポンダーは respond :pattern 。特定の言葉に反応して挨拶したりと、ランダムとは一味違う思考エンジンです。