Rustで自然言語解析っぽいことをする


Peridot EngineのCI結果をメールからSlackに移行して、同時に全部Slackに集約しようと思い立って、GitHubのIssuesから今日のタスクなどを引っ張ってきて毎日リマインドしてくれるお手伝いさんbot(愛称「こゆきちゃんbot」)を作成しています。
レスポンス部分だけある程度形になってきたのでちょっと解説みたいなことをしてみたいと思います。

ちなみに完全に余談ですが、こゆきちゃんとはFantasyLifeOnlineで作成したアバターが元の独自キャラクターです。銀髪に近いショートヘアと猫耳が特徴的な子です。

基本的にRustではあまり凝ったことをしていないので、安定版であればバージョンは問わないはずです。

自然言語(日本語)解析の大雑把な手順

(助詞分離は厳密に捉えれば構文解析フェーズと言えそうですが、ここでは意味解析フェーズ(ツリー項目同士の関係性判定の一種)として扱っています)
今回は制作の対象がSlackbotのため、狭義の言語処理の他に「呼び出し句の認識」というものが先立って行われるようになっています。

呼び出し句の認識

Botは無用な処理や暴走を阻止するため、ある種の呼び出し句を定義してそれに反応することが基本的な構成になっています(代表的なものでは、メンション様構文(<@UserId> Message...)に反応するbotなどが該当します)。固定文字列に反応するbotであればSlackbotのCustom ResponseやOutgoing Webhook(ただし廃止予定)で十分ですが、こゆきちゃんbotでは柔軟な指定をさせたいのでRealTimeMessaging APIを用いて正規表現での解析を行なっています。

Rustでは正規表現は標準ライブラリに入っておらず、準標準ライブラリ(rust-lang-nursery)にあるregexクレートを使用するのが事実上の標準となっています。

let name_rx = regex::Regex::new(r#"^(こ[\-ー~〜]*ゆ[\-ー~〜]*き[\-ー~〜]*(ち[\-ー~〜]*ゃ[\-ー~〜]*ん[\-ー~〜]*|さ[\-ー~〜]*ん[\-ー~〜]*)[!!、,  ??]?)+"#).unwrap();

長いですが、なんてことはない、長音など無意味な文字を弾いて「こゆきちゃん」または「こゆきさん」にマッチする表現というだけです。

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

日本語を解析する上で、まず行うこととして代表的な処理が「形態素解析」となります。真面目な自然言語処理を行わないにしろ、日本語を分解する場合はこちらから始める場合がほとんどだと思います。

こちらは外部ライブラリを利用します。形態素解析と言えば有名なのは「Juman」とか「MeCab」とかですが、今回は係り受け解析を同時に行いたいため、MeCabの作者さんが作成した「CaboCha」というライブラリを利用します。この手のものだとJ.DepPが最速という話ですが、コマンドラインインターフェイスしかないためプログラムからの扱いは若干手間となってしまいます。

係り受け解析?

自然言語処理をかじった程度の方だと、形態素解析は知ってるけど係り受け解析は知らない、という方も多いのではないかと思います。
係り受け解析は上図で構文解析と意味解析フェーズの中間であるとしている通りの処理を行います。日本語において、形態素をある程度まとめたもの(文節)どうしがどのような関係性を持っているかを解析するのが係り受け解析と言えます。

例文
犬が前を歩く

この例文は、

解析結果
犬が -> 歩く
前を -> 歩く

といったような修飾関係を持ちます。これをあらゆる文に対して解析することを係り受け解析と呼びます。

日本語を解析する

ところで、皆さんは「日本語プログラミング言語」に触れたことがありますでしょうか?
大抵は胡散臭くて「名前は知ってるけど使ったことはない」か「なでしこ/ひまわり程度なら学校とかで...」という方がほとんどではないかと思います。
日本語プログラミング言語はその名の通り日本語でプログラムを組むための言語のことを言います。プログラミング作業におけるほとんどのことを日本語としてある程度自然に書くことが可能で、そのための特徴的な機能として「引数に助詞を指定することができる」というところがあります(近いところでいうと、Objective-CやSwiftの名前付き引数を思い浮かべていただければわかりやすいかと思います)。
こゆきちゃんbotの最終的な日本語解釈モジュールはこれと似たような構成を取っていて、助詞と動詞(または名詞+?)をベースとした解析を行なっています。例えば

〇〇について教えて

といった文が入力された場合、こゆきちゃんbot的には

教えて <~ について: 〇〇

といった様に、「基幹となる動詞」+「Option<助詞>+それ以外の形態素」 といった構成に分解してから解釈を行う様にしています。この文では

〇〇-について -> 教えて

といった係り受け関係が解析結果として出てくるため、「末尾の助詞を分離して依存グラフを構築」といった手順で構築することが可能です。
依存グラフは単純にいうと「CaboChaの出力を逆方向にしたもの」のことです。CaboChaでは「チャンク(いくつかの形態素のかたまり)がどのチャンクを修飾しているか」のみ取得できるため、動作そのものを取得するのもそこそこ手間ですし、そこから動作の主体や対象を探すのもまた面倒です。

動作と引数の関係がわかればここからは簡単で、例えば「教える」であれば

fn 教える([教えられるもの]-について | [教えられるもの]-を) -> [アイテム]
enum 教えられるもの = 献立([いつ]-の) | タスク([いつ]-の | 残る-てる) | 数(Option<[教えられるもの]-の>)
enum いつ = 今日 | 明日 | 昨日 | 今 | 次

こんな感じに定義することができます。あとはこれらの定義に従ってひたすらパターンマッチングです。

今日のタスクを教えて => 今日-の-タスク-を-教えて<動詞: 教える>
  => 教えて<動詞: 教える> ~> (タスク-を ~> 今日-の) -- ここまでがCaboChaの役割
  => 教える(タスク(今日-の)-を)

Rustで命令木を定義する

割と純粋にenumで定義することができます。注意点としては、上記の教えられるもの::数教えられるものを含んだ構造をとりますので引数をBoxなどで適宜囲む必要があります。Box などというのは、再帰構造は型のサイズが有限時間内に決定できないことが問題なので、型によらず一定のサイズをとる型であればどれで置き換えても問題ないです(*mut Tで置き換えても問題ないです。推奨は絶対しませんが)。

で、Boxがあると実行コードが少し厄介になって、現在のRustではまだbox_patternsがstabilizeされていないためBoxedなデータを含むマッチングはmatchを二重にする必要があります。

match x {
  Some(b) => match *b {
    FooEnum::Hoge(...) => ...,
    FooEnum::Fuga(...) => ...,
  }
}

box_patternsでは次の様に書けます。

match x {
  Some(box FooEnum::Hoge(...)) => ...,
  Some(box FooEnum::Fuga(...)) => ...,
}

ソースコード(おまけ)

CaboChaのラップ部分などは特に書いていませんが、上記リポジトリを見ればわかるように普通にFFIで書いています。