日本語を理解するチャットボットに挑戦した話

お久しぶりです。プレイドの@otoanです。

前回はService Workerについての記事を書きましたが、今回はガラッと変わって、春の開発合宿で実験的に作成したチャットボット系の基盤ツール(Docker環境+ライブラリ)を公開しつつ、得た知見などを書いていこうと思います。

今回はデモありです。(追記2020/2/18: デモは終了しています)

作成したツールをページ内から呼び出せるようにしてあるので、ぜひ試してみて下さい。PCなら左上・モバイルなら右下に出ている「はるみさんに休暇申請!」から動かせます。(少し待っても出てこない場合はリロードしてください)

まずはまとめ

  • 言語分析の世界は深く、思っている以上に進んでいる。そしてまだ足りない
  • ともあれ限界があることを受け入れれば、面白いことは十分できる
  • 日付の処理はきつい
  • デモもあるよ!

なにを作ったか

https://github.com/otolab/halumi-core です。

プレイドではSlack botの開発・利用が盛んで、日常的に使われるものがいくつかあります。(この記事でも紹介されています)

毎日お世話になるのが「DeployBot」。ノリの軽いボディビルダーです。git-flowのブランチ管理、検証・実環境へのデプロイなどSlackから実行することができ、作業工数を削減するのに大きく貢献しています。

面白いところでは「lunchくま」。くまです。昼の時間に連れ立ってランチに出発するメンバーを募ったり、オススメを教えてくれるいいヤツです。

それから、今回の機能拡張ターゲットである「はる子さん」。コードネーム: hal1008。休暇申請・リモート作業申請・勤怠簿提出ほか、いろいろな機能を持つSlack botです。

春合宿では「はる子」の後継キャラクタ「はるみ」を開発するチームが立ち上がり、キャラクタ設定からさらなる機能拡張まで、様々に作業が行われました。

そして、自分が担当したのは「日本語を理解する」機能です。

とはいえ

「日本語を理解する」をテーマにしたのは、プレイドには社内に機械学習を専門としていたメンバーも多く、なんとなく羨ましかったというのが大きな理由なのですが...たかだか2日の合宿です。直接機械学習にトライするのではなく、既存の言語理解や機械学習研究の成果を活用することにしました。

目指すのは「役に立つ機関車チャットボット」。

日本語を解釈して勤怠管理系へのコマンドを組み立てます。いままでは正規表現で文言を分解して内部コマンドを発行していたため、定型から少しでも外れると入力が受け付けられない問題がありました。

現行の使われ方であれば、サジェストやヘルプ機能でも十分役に立つはずです。むりなら世間話系のbot apiにつなぐという遊びも思いつきました。

このあたりは汎用的な機能なので、今後にも役立ちそうです。

どう作る?

ディープラーニングが流行りです。

しかし、いろいろ見ていると『「んて」と入力されるとトラブルの話だと思う』系の早とちりbotが意外と多いことがわかります。少ないパターンの文字列を機械学習すると過学習に陥り、部分最適な挙動をとってしまうケースが多いのだろうと理解しました。

問い合わせ自動応答をディープラーニングで!系は、現時点ではかなり難しいところはありそうかな、というのが自分の判断です。

「役に立つ機関車チャットボット」には実績のあるヒューリスティックな手法での解析が適切と考えました。

とは言え、ヒューリスティックな方法と学習を組み合わせた解析機が主流のようです。今回最終的に採用したjuman++も機械学習の一種である「RNNLM」を採用して高い精度を出しているとのこと。機械学習の技法もモデル次第・使い方によって意味合いはまったく変わってくるということでしょう。

形態素解析・係り受け解析

品詞タイプの判定などを行う「形態素解析」と、語句のつながりを解析する「係り受け解析」があることがわかりました。コマンドを作成するには「何をどうする」というつながりが重要なので、係り受け解析まで行う必要があります。

日本語の形態素解析・係り受け解析にはいくつかの有名なライブラリがあり、それぞれ出力の形式や内容によって系列があるようです。

形態素解析 係り受け解析 雑感
ChaSen, MeCab CaboCha パッケージに一日の長。関連パッケージが開発しやすく、優秀な辞書が多い
juman, juman++ knp 機能的に尖っているイメージ?
kuromoji ? 純jsの実装があるのは便利そうだが、係り受け解析の実績が見つからなかった

他には形態素解析だとKAKASI, KyTeaなどが有名なようですが、係り受け解析を見つけることができませんでした。(見つけられなかっただけかもしれません)

今回想定するのは短い命令文なので、入力として想定する文字列をいくつか作って、分析結果をがんばって判定しました。

jumanとMeCabの環境を作って比較したのですが、幾つかのパターンでjuman++ & knpが優秀でした。

また、最終段階で出力される情報量としてもknpが一番優秀でした。今回は利用できていませんが、格解析や『それ』とはなにかを推定する照応解析の機能も組み込まれているとのこと。

今回はjuman++ & knpを使います。

実行環境のDocker Image化

ツールのインストールが結構面倒なので、Dockerの開発環境から作ることにしました。(MeCab & CaboChaもインストールできるDockerfileも同梱しています)

docker-compose pull
docker-compose up -d
docker-compose exec halumi bash

コンテナにbashで入って直接叩こうというわけです。このあたりは弊社@algas作の「karte-io開発環境」のノウハウとなります。

開発合宿一日目はこのあたりで力尽きました。

係り受け解析結果の解釈をどうするか

係り受けの構造までは分析してくれるので、あとは構造を読み説き、なにを意図しているのかを分析する必要があります。

ここでディープラーニングを出してくるのが面白いのかなとも思ったのですが、おそらくアカデミックに本気で研究するべき分野であり、もちろん今回はそこまでやりません。(できません)

今回はシンプルに考え、係り受けの木構造にパターンがマッチするか判定し、それにまつわる情報を取得して、後段にコマンドとして渡せればよさそうです。

どういう風に記述すれば簡単だろうか...ツリーにマッチ...ん? CSSセレクタ...

理解の方針

日本語の場合、基本的には逆ツリーになっているので、ツリー構造をひっくり返して解釈するイメージになります。

コマンドか否かは単語と単語のつながりをヒューリスティックに記述します。

休み > する:not(否定) {
  command: 'rest'
}

イメージとしてはこんな感じです。

例文を適当にいろいろ入れてみると「休む」のか「休みをする」のどちらかが基本になるようです。表記・表現ゆれには十分強いです。

「やすみたい」は「休む」の「モダリティ」として検出されました。否定型に関しても、「する(否定)」「休む(否定)」「休む > する(否定)」など複数のパターンがありました。

否定や曖昧な条件については聞き返せば良いでしょう。多くの場合、人間の機能をうまく活かす解決方法が最適な気がします。なので方針は「完全にわかるものは命令として読む」「曖昧なものは聞き返す」。コミュニケーションである強みですね。

なお、knpの格解析はかなり強力で、日本語として十分正しければ簡単にまとめて情報が取得できそうな気配はあったのですが、今回は利用を見送りました。

...以上を簡単に書きましたが、juman, knpの出力結果を理解するのに合宿二日目のほぼすべての時間を掛けました。

ちなみに、knpの詳細出力モードでの結果表示は↓の感じになります。アツいです。

$ echo '8月1日まで旅行です。' | jumanpp | knp -tab -anaphora
# S-ID:1 KNP:4.17-CF1.1 DATE:2017/07/27 SCORE:-17.70536
* 1D <文頭><カウンタ:月><時間><強時間><数量><体言><準用言><受:隣のみ><係:隣><時数裸><区切:0-0><正規化代表表記:8/8+月/がつ><主辞代表表記:8/8+月/がつ>
+ 1D <文頭><カウンタ:月><時間><強時間><数量><体言><準用言><受:隣のみ><係:隣><時数裸><区切:0-0><省略解析なし><正規化代表表記:8/8+月/がつ><NE内:DATE><照応詞候補:8月><EID:0>
8 8 8 名詞 6 数詞 7 * 0 * 0 "カテゴリ:数量 疑似代表表記 代表表記:8/8" <カテゴリ:数量><疑似代表表記><代表表記:8/8><正規化代表表記:8/8><文頭><記英数カ><数字><英記号><記号><名詞相当語><自立><内容語><タグ単位始><文節始><NE:DATE:B>
月 がつ 月 接尾辞 14 名詞性名詞助数辞 3 * 0 * 0 "代表表記:月/がつ 準内容語 カテゴリ:時間" <代表表記:月/がつ><準内容語><カテゴリ:時間><正規化代表表記:月/がつ><時間辞><カウンタ><漢字><かな漢字><付属><文節主辞><NE:DATE:I>
* 2D <カウンタ:日><時間><強時間><数量><マデ><助詞><体言><準用言><受:隣のみ><係:マデ格><区切:0-0><格要素><連用要素><正規化代表表記:1/1+日/にち><主辞代表表記:1/1+日/にち>
+ 2D <カウンタ:日><時間><強時間><数量><マデ><助詞><体言><準用言><受:隣のみ><係:マデ格><区切:0-0><格要素><連用要素><省略解析なし><正規化代表表記:1/1+日/にち><NE:DATE:8月1日><照応詞候補:8月1日><解析格:時間><EID:1>
1 1 1 名詞 6 数詞 7 * 0 * 0 "カテゴリ:数量 疑似代表表記 代表表記:1/1" <カテゴリ:数量><疑似代表表記><代表表記:1/1><正規化代表表記:1/1><記英数カ><数字><英記号><記号><名詞相当語><自立><内容語><タグ単位始><文節始><NE:DATE:I>
日 にち 日 接尾辞 14 名詞性名詞助数辞 3 * 0 * 0 "代表表記:日/にち 準内容語 カテゴリ:時間" <代表表記:日/にち><準内容語><カテゴリ:時間><正規化代表表記:日/にち><時間辞><カウンタ><漢字><かな漢字><付属><文節主辞><NE:DATE:E>
まで まで まで 助詞 9 格助詞 1 * 0 * 0 NIL <かな漢字><ひらがな><付属>
* -1D <文末><サ変><句点><体言><用言:判><レベル:C><区切:5-5><ID:(文末)><裸名詞><係:文末><提題受:30><主節><格要素><連用要素><動態述語><敬語:丁寧表現><正規化代表表記:旅行/りょこう><主辞代表表記:旅行/りょこう>
+ -1D <文末><句点><体言><用言:判><レベル:C><区切:5-5><ID:(文末)><裸名詞><係:文末><提題受:30><主節><格要素><連用要素><動態述語><敬語:丁寧表現><サ変><判定詞><名詞項候補><先行詞候補><正規化代表表記:旅行/りょこう><用言代表表記:旅行/りょこう><時制-無時制><照応詞候補:旅行><格関係1:時間:日><格解析結果::旅行/りょこう:判7ガ/U/-/-/-/-;時間/C/日/1/0/1;ガ2/U/-/-/-/-;外の関係/U/-/-/-/-><EID:2><項構造:旅行/りょこう:判7:時間/C/8月1日/1>
旅行 りょこう 旅行 名詞 6 サ変名詞 2 * 0 * 0 "代表表記:旅行/りょこう カテゴリ:抽象物 ドメイン:レクリエーション" <代表表記:旅行/りょこう><カテゴリ:抽象物><ドメイン:レクリエーション><正規化代表表記:旅行/りょこう><漢字><かな漢字><名詞相当語><サ変><自立><内容語><タグ単位始><文節始><文節主辞>
です です だ 判定詞 4 * 0 判定詞 25 デス列基本形 27 NIL <表現文末><かな漢字><ひらがな><活用語><付属>
。 。 。 特殊 1 句点 1 * 0 * 0 NIL <文末><英記号><記号><付属>
EOS

時間理解は難しい

休暇申請なので、時間についても理解する必要があります。これはコマンドとして検出された語句に掛かる時間の要素を抜き出して判定すれば良い...と考えたのですが。

時間理解は難しいポイントが結構ありました。困難だったパターンをちょっとだけ挙げてみます。

  • 明日
    • juman++が「あすか(地名)」と読んでくれるので、filterをかました
    • 文脈として「今」という日付がある。これを基準に解釈する
  • 8月の1日から3日まで
    • "8月"を文脈上の”今”と考えて、1日, 3日は8月の日付と解釈する
    • 「から」や「まで」は分析で読み取ってくれるので、その間の日付も生成
  • 7/26-28, 8/1, 2
    • "7/26"は"7月26日"に変換
    • 28は種類なしの数字と判定されるが、日付として無理やり列挙する
    • 日付に関係ありそうな", "は"と"に変換(並列の係り受けになる)
    • 日付に関係ありそうな"-"は"から"に変換
    • "8/1"の後の"2"は8月2日を表す。文脈上の基準が変わる
  • 明日と明後日
    • "8/1, 2"とは違い、どちらも現在を基準とする

などなどなど。多くは力技で解消しました。

なお、後述しますが未対応のパターンも大量に考えられます。

そして、つくったもの

まあ、だいたい↓の感じです。

実行例

検出パターンは下記の通り。リモート作業申請、休暇申請、旅行良いですね判定です。(CSSセレクタ風にするのはとりあえずやめときました)

const patterns = {
  remote: [
    ['リモート', 'する'],
    ['リモート']
  ],
  rest: [
    ['休み', 'する'],
    ['休み', '思う'],
    ['休む', '予定'],
    ['休む', '思う'],
    ['休む'],
    ['休み']
  ],
  voyage: [
    ['旅行', '*', '行く'],
    ['旅行', '*', '予定']
  ]
}

はるみさんのキャラクタ設定を参考にそれっぽく喋らせてみます。(実行結果はいずれも2017/7/26に実行した場合)

8月1日まで旅行です。その間休みます

休暇申請デスネ。了解デス!
  2017年07月26日
  2017年07月27日
  2017年07月28日
  2017年07月29日
  2017年07月30日
  2017年07月31日
  2017年08月01日
に休暇を入れますデスヨ
おお、いいですね。いってらっしゃいマシ!!

暗黙的に「今日から8月1日まで」と読み替えているパターンです。

明日と、8/1, 2018年7月20-23日と、2017年7月の30, 31日はリモート作業です

了解デス!
  2017年07月27日
  2017年08月01日
  2018年07月20日
  2018年07月21日
  2018年07月22日
  2018年07月23日
  2017年07月30日
  2017年07月31日
にリモート作業日を入れますデスヨ

複雑ですが、ちゃんと読み取れています。

明日は休みたい。

休暇申請デスカ?ちょっと理解できなかったので、もう一度言ってくださいデスネ。

休むかどうか不明なパターン。「モダリティ」が検出されたので聞き返します。

やっぱり休みます

休暇申請デスネ。了解デス!
いつデスか!?解らなかったデス!

「明日は休みたい。やっぱり休みます」と文脈をつなげて読んだほうがいいのかもしれませんが、文中に日付がないので聞き返します。

できないこと

もちろん、できないことはたくさんあります。

時間の理解

時間の理解をキッチリ行うことは、相当に困難であるようです。

  • 明日から3日間休みます
  • 来週の水曜は休みます
  • 来週いっぱいリモートです
  • 再来週は旅行なので休みです
  • 来月の月曜と水曜以外はリモートです
  • 来週の前半2日は休みます
  • 30日、31にアイスを買いに行くので12:00まで外出です
  • 1/2で割り切れる日はリモートです
  • etc...

もう少し頑張れば判定できそうなものもありますが、もう専用の解析機能をつくるしかないような...

8月1日はリモートです。8月8日はお休みします。

了解デス!
  2017年08月01日
にリモート作業日を入れますデスヨ
休暇申請デスネ。了解デス!
  2017年08月01日
  2017年08月08日
に休暇を入れますデスヨ

今回は複数の命令間の関係を考慮していないので、特に日付の関係で問題が起きることがあります。これは格解析結果と並列タイプの判定をちゃんと行えば解決できそうですが、未対応です。

判定した日付の候補を出して、確認後に登録する方法にしたほうが良いかもしれません。そうなると、前に入力した文章を覚えておく機能なども必要になりそうです。

お休みします。8月1日に。

休暇申請デスネ。了解デス!
いつデスか!?解らなかったデス!

倒置法も悩ましいところですね。コマンドに関連する時間表記の抽出方法を改善する必要がありそうです。

etc, etc...

あとはtypoなどを含む、日本語としてちゃんと成立していないパターンで、かつコマンドとして成立してしまうケースをどうするか。

などなどなど。

他にはどんなパターンがあるのでしょうね?

挑戦した感想

一言で言うと、奥が深い...。

考えるほどに対応できないケースがどんどん出てくる状態で、完璧には程遠いものです。また、言語解析に詳しい方が見ると、解析結果をうまく扱えていないことがすぐバレる実装になっていると思います。

ただ、出発点となった課題の解決としては、このあたりでも十分かなと思いました。「役に立つチャットボット」には一歩近づけたのではないでしょうか。

人間の複雑さの一端に触れた開発合宿(+その後のまとめ時間)でした。

さて。

ウェブ接客プラットフォーム「KARTE」を運営するプレイドでは、KARTEを使ってこんなアプリケーションが作りたい! KARTE自体の開発に興味がある!というエンジニア(インターンも!)を募集しています。

詳しくは弊社採用ページ
またはWantedly
をご覧ください。 もしくはお気軽に、下記の「話を聞きに行きたい」ボタンを押してください!

追記

その後の動きをPLAID Advent Calendar 2017で記事にしました。こちらもぜひ。