Clojure 入門者による【チャットボットづくり】 Part4
前回は respond :random
を実装しました。辞書も Map にしようというところまでは決めましたが、それをどう使っていくかに関しては説明していません。
ということで今回は『ランダム辞書の学習』『辞書ファイルの読み書き』をコーディングしていきます。同時にコード量も増えていくことが予想されるので、『ソースファイルの分割』についても。
- 元の実装(Python): sandmark/unmo
- 新しい実装(Clojure): sandmark/unmo-clojure
目次
これまですべて core.clj
に記述してきましたが、コードが縦に長くなると見通しが悪くなって大変です。今後 respond
関数の実装はもう少し増えますし、新たに辞書機能を実装する必要性も出てきました。そこで、 core.clj
, responder.clj
, dictionary.clj
というファイルに分けて記述することにします。
responder.clj - AI の思考エンジン
これまでに実装した respond
関数は、 :what
, :random
の2つです。これらに加えて、あと3つのレスポンダーを追加する予定です。
- WhatResponder: 聞き返す
- RandomResponder: ランダムで適当に言う
- PatternResponder: 正規表現で関係ありそうな発言を探す
- TemplateResponder: 形態素解析結果をもとにテンプレート通りに発言する
- MarkovResponder: マルコフ連鎖アルゴリズムで文を生成する
ひとつずつ作っていくとして、まずはこれまでの実装をまとめましょう。
(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 かな?」と思うかもしれません(私のことです)。 cons
と conj
の違いについては深く掘り下げません。よくわかんないからです。
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.core
に in?
といった名前で定義されていても良さそうなものですが、私にはわからない理由で存在しないようです。 contains?
という紛らわしい名前の Map 用の関数 があって、世界中の Clojure 入門者の頭を悩ませているとかいないとか。
セット - 重複のないコレクション
Map, Vector, List と紹介してきましたが、Clojure にはもうひとつコレクションがあります。 #{1 2 3}
という形式で表現される Set は『集合』であり、 clojure.set
には project
, intersection
, union
など集合演算関数が用意されています。「キーだけの Map」と言ってもいいかもしれません。上記 conj-unique
の some
の引数としてもちょっとだけ登場していますね。
本質的に重複が許されないので、 (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-pattern
や study-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-string
で Clojure の Map オブジェクトに戻しています。
Java の知識
io/as-file
はファイルが存在した場合にその中身を返すので、この実装だとファイルの読み込みが2回発生していることになります。 as-file
と slurp
です。
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)
が呼び出されることになります。 nil
に conj
したときの動作は先ほども確認した通りリストが返ってきますので、 dict.edn
の :random
の値もリストになっているはずです。動作に影響はありません。たぶん。
次回は PatternResponder を実装するよ
辞書のことは考えなくても良くなったので、安心して respond
関数を実装していけます。次のレスポンダーは respond :pattern
。特定の言葉に反応して挨拶したりと、ランダムとは一味違う思考エンジンです。