🎓

Lisp系のHylangで実装する簡易的なチャットボット

2021/08/11に公開

概要

前回紹介した
https://zenn.dev/meimyo/articles/2c92b708350821
で登場した。エキスパートシステムを利用して、簡易的なチャットボットを作ろうという試みになります。
ここでは、 Hylang というPython仮想マシン上で動くCloujureの影響を多大に受けたLispを使って、単純なチャットボットを作っていきながら、仕組みを解説していきます(あとZennのLisp系の記事を増やしたい)。

やりたいこと

単純にいうと、話しかけて、何かしらの返答をするだけのボットを作るというだけの話です。
もう少し具体的に説明すると、有名な哲学的な問題で中国語の部屋というのに近く、ある文字列を受け取ったら、その文字列に対応した文字列を返すという仕組みです。

例に出すと、下記のようなものを実装していきます。

例:単純な応答

私: 今日の天気はなんですか。
ボット: 今日は晴れると良いですね。

例: 質問を記憶して、より条件の細かい回答をする

私: 何か、おすすめのスポーツを教えて下さい。
ボット: FPSですかね。
私: あ、体も動かしたいんです。
ボット: それでしたら、リングフィットですね。
私: wiiしか無いんです…。
ボット: でしたら、Wii Fitですね。

Hylangについて

使う言語が、ややマイナーなので、説明しておきます。冒頭でも述べましたが、Hylang というPython仮想マシン上で動き、またCloujureというLispの影響を多大に受けた言語の1つです。
きっとPython仮想マシン上で動く言語なら、もうPython使えば?みたいな意見はあることでしょう。もちろん、その意見はきっとあると思いますし、多数の方はそうするべきだと感じることでしょう。ただし、それでもでもなお、Lispとしての魅力が、確かにあること忘れないでください。

え、それでもPythonがいい?ご安心を、HylangはPythonにコンパイルすることが可能ですので、最終的にPythonのコードを手に入れることができます!

1.構文が単純

Pythonと比べて、Lispなので、基本的な構文が単純となりました。
従来のPythonのパワーを使いつつ、Lisp特有のS式としてプログラミングすることが可能となります。

以下は、極端な例ですが、見ていきましょう。一般的な中間配置を採用する言語には、演算子の優先順位があります。
pythonだと、ぱっとみ評価順序がわかりにくいですが、下記の式は正しいです(下とか演算子の優先順位を知ってないと辛い)。

value = 1 + 2 * 3 + 4
value = True and False
        or not 
	False | 
	False & False

一方、Lispは構文が単純なので、そもそも演算子の優先順位みたいな概念がないです(カッコが増えるのがあれですが…)。また、インデントを工夫すれば、ちゃんと読めるコードにはなります。

(setv value (+ 1 (* 2 3) 4)
(setv value (or (and True False) 
                (not (| (& False False) 
		        False))))

2.強力なマクロ

いやいや、カッコだけなら要らないじゃんとなりそうですよね。それでもLispが生き残ってこれた理由がLispのマクロというものです。これは強力な機能で、カッコの嵐なんか気にならなくなるほどです(今回は単純なものなので、自作マクロは作りませんが)。

これは、よくあるコードを一定のテンプレートに含めて、コードを単純化するとかで使います。C言語とかにあるマクロとは違い、かなり強力な機能です。
その威力は、Lisp自身の文法すら拡張できるもので、このおかげでLisp自体の仕様が小さくなっているというメリットも提供しています。

具体例として -> というマクロがあります。これは本来

(get (.split word-info ",") 6)

としなければいけないところを

(-> word-info (.split ",") (get 6))

みたいに、従来の親しみある言語のように、評価する順番で書くことができます。この程度なら、まだ大丈夫ですが、階層が深くなるようなコードの場合に威力を発揮します。

3.Pythonとの親和性の高さ

Lisp単体だと、ライブラリの充実具合で、不利な立場に立たされることがあります。
それに比べ、Python環境はライブラリが豊富な言語です。Hylangは、Pythonの仮想マシン上で動作可能なため、Pythonのライブラリを使うことが可能です(多分、Lisp系がしぶとく生き残れている理由だと思います)。今回は、それを生かして、Mecabなどを利用します。

単純なエキスパートシステム

さて、実装していきます。仕組みとしては、先日の記事でも表示した図の通り
エキスパートシステム概要
の機構を利用し、現在の会話状況をある程度記憶させつつ、ボットにFAQの案内をさせてもらうような仕組みを作ります。

知識の表現

エキスパートシステムにおける問題解決の肝となる部分で、今回は会話の文章の受け答え的なものを登録する箇所になります。
ここでは、一連の会話の流れに対して解釈した文章解釈し文章に対する応答と分けて考えてあげることで、あたかも質問にも対応できるような形式を採用します。

すなわち、知識を

  1. 聞かれた事を解釈したもの(質疑応答における質疑部分)
  2. 聞かれた事に対する文(質疑応答における応答部分)

の2種類に分けて考えることにします。

こうしてあげることで、先の例の「質問を記憶して、より条件の細かい回答をする」に出したように、質疑に対して応答部分に幅をもたせることが可能となります。具体的には、文とそれに関連するキーワードの集合を関連づけて、辞書で登録します。

すなわち

  1. 聞かれた事を解釈したもの
{"天気はなんですか?": set(["天気" "晴れ" "雨"])}

例えば、「天気が悪くなってきましたね。」みたいな文は、全て「天気はなんですか?」と解釈するようにし、質問されている文に変換します。

  1. 聞かれた事に対する文(質疑応答における応答部分)
{"晴れが良いですね": set(["天気はなんですか?" "天気" "晴れ" "雨"])}

こちらは、1で導出されたものに対して、更に続けて答えるので、1で導出されたものを登録しておきます。

推論エンジン

こちらは、単純に受け取った文章をMecabで分割し、そこから検出できたキーワードが、どれだけ登録されているキーワードにマッチしているのかで、使うべき知識を選びます。

どの程度マッチしているかは、F値で算出します。
つまり

F値 = \frac{一致したキーワードの数}{送られてきたキーワードの数} \times \frac{一致したキーワードの数}{登録されていたキーワード数}

で算出します。

実装

さて、実装になります。環境は

  • Mecab, neologd
  • Python 3.7.3
  • Hylang 1.0a3
    で確認しています。
(import MeCab)


(defclass Knowledges []
"知識を登録するクラス"
    (defn __init__ [self [rules set]]
         (setv self.rules rules))

    (defn calc-match [self words]
        (defn get-f-score [x]
        "F値を算出する関数"
            (* (/ (len (& (get self.rules x) words)) 
                  (len words))
               (/ (len (& (get self.rules x) words))
                  (len (get self.rules x)))))

        (list (map (fn [x] 
                        (tuple [x (get-f-score x)]))
                    self.rules))))

(defn get-words-from-text [text]
"文章をキーワードに分割する関数"
    (defn pickup-word [word-info]
        "Mecabの出力から単語を取り出す関数"
        (if (!= "*" (get (.split word-info ",") 6))
            (-> word-info (.split ",") (get 6))
            (-> word-info (.split ",") (get 0) (.split "\t") (get 0))))

    (setv mecab (MeCab.Tagger "-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd"))
    (setv words-info (filter (fn [word] 
                                (if (or (in "名詞" word)
                                        (and (in "動詞" word) 
                                             (not (in "助動詞" word))))
                                    True
                                    False))
                                (-> text (mecab.parse) (.split "\n"))))
    (->  (map pickup-word words-info) list set))


(defn pickup-text [faq-sets]
    [(get (max faq-sets :key (fn [x] (get x 1))) 0)])


(if (= __name__ "__main__")
    (do 
        ;; 状態(会話内容)保存用の変数
        (setv situation [])
        (setv value-1 (Knowledges {"天気はなんですか?" (set ["天気" "晴れ" "雨" "天候"])
                                   "おすすめのスポーツはありますか。" (set ["スポーツ" "運動"])
                                   "Excelの使い方がわからないです…。"   (set ["Excel" "エクセル" "関数"])
                                   }))
        (setv value-2 (Knowledges {"晴れると良いですよね。" (set ["天気はなんですか?" "天気" "晴れ" "雨" "天候"])
                                    "あ、FPSがおすすめです。" (set ["スポーツ" "運動" "おすすめのスポーツを教えて下さい。" "FPS"])
                                    "でしたら、リングフィットがおすすめです。" (set ["スポーツ" "運動" "室内" "家" "おすすめのスポーツを教えて下さい。" "Switch"])
                                    "それでしたら、Wii Fitがおすすめです。"  (set ["スポーツ" "運動" "室内" "家" "おすすめのスポーツを教えて下さい。" "Wii"])
                                    "難しいですよね、それ…"   (set ["Excel" "エクセル" "関数" "Excelの使い方がわからないです…。"])
                                    }))
        
        (setv text "何か、おすすめのスポーツ教えて下さい。")
        (print text)
        (setv situation (+ (list (get-words-from-text text)) 
                           (pickup-text (.calc-match value-1 (get-words-from-text text)))))
        (print "bot:" (get (pickup-text (.calc-match value-2 (set situation))) 0))

        (setv text "えーと、室内で運動をやりたいです。")
        (print text)
        (setv situation (+ (list (get-words-from-text text))
                           (pickup-text (.calc-match value-1 (get-words-from-text text)))))
        (print "bot:" (get (pickup-text (.calc-match value-2 (set situation))) 0))

        (setv text "家でWiiで運動したいですよー")
        (print text)
        (setv situation (+ (list (get-words-from-text text)) 
                           (pickup-text (.calc-match value-1 (get-words-from-text text)))))
        (print "bot:" (get (pickup-text (.calc-match value-2 (set situation))) 0))


        (setv situation [])
        (setv text "天候が悪くなってきましたねぇ")
        (print text)
        (setv situation (+ (list (get-words-from-text text)) 
                           (pickup-text (.calc-match value-1 (get-words-from-text text)))))
        (print "bot:" (get (pickup-text (.calc-match value-2 (set situation))) 0))))

コード解説

filterやmapを多用してますが、やっていること自体は、先の文章で説明したとおりです。やや長くなった部分もあるので、そのあたりを補足していきます。

知識の算出

推論エンジン用のクラスも作ろうと思いましたが、知識を登録するKnowledgeに統合しています。
self.rules という辞書型のオブジェクトを用意し、get-f-score関数をmapに通すことで各文のF値を算出しています。

知識の登録自体は、mainでやっており、それぞれvalue-1,value-2という名前で登録しております。

文字分割分割

文のキーワード分割は、今回は、クラス化せずにget-words-from-text関数という名前で定義してます。

キーワードの抽出は、Mecabで分割すると

echo wiiしたい|mecab -d /usr/local/lib/mecab/dic/mecab-ipadic-neologd
wii\t名詞,固有名詞,一般,*,*,*,Wii,ウィー,ウィー
し\t動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
たい\t助動詞,*,*,*,特殊・タイ,基本形,たい,タイ,タイ

というように出力されるので、名詞、または動詞の基本形をとるとして

    (defn pickup-word [word-info]
        "Mecabの出力から単語を取り出す関数"
        (if (!= "*" (get (.split word-info ",") 6))
            (-> word-info (.split ",") (get 6))
            (-> word-info (.split ",") (get 0) (.split "\t") (get 0))))

みたいに、最終的にしています。

会話の記憶保存

会話の記憶の保存、すなわち、上の画像でいう状況の保存・更新部分はmainでやっています。
situationという変数を用意して、会話や推論した内容を

(setv situation (+ (list (get-words-from-text text)) 
		   (pickup-faq (.calc-match value-1 (get-words-from-text text)))))

で、記憶するようにしています。

終わりに

比較的に単純なデータ構造で、簡単な会話をできるようなチャットボットを作ってみました。
そこまでLispで本格的なコードを書いたことがなかったのですが、Hylangは、Pythonの機能に透過的にアクセスできるので、Lispの魅力を感じつつ、コーディング出来たかと思います。
今後、これを発展して、より複雑な会話などをできるように改良していけたらなと思います(天気聞かれたら、天気予報の情報を取得したり等)。

参考

F値(F-score) wikipedia
エキスパートシステム
新世代工学シリーズ 人工知能
Hy Docs

Discussion