すなぶろ

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

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

大幅に更新が遅れてしまいました。実は腰痛で入院していまして、無事手術を終えて先日退院してきたところです。

入院中あまりにも暇なのでずっと Programming Clojure (第三版)を読んでいたんですが、 Clojure の奥義をそんな簡単に伝えていいの? と思うくらい濃い内容です。

ネット上の(日本語の)情報が少ない、あるいは古いのが難点だと言われがちな Clojure ですが、そもそも何かを勉強したいと思ったら、どんな言語であれ本を読んだほうがいいと痛感しました。例えば mapfilter, reduce は他の言語でも実装されていることが多いですが、Clojure の場合は戻り値を遅延させたり、即時適用してパフォーマンスを上げたりと、使い方が複数あります。その根底にあるのはいわゆる「Clojure らしさ」とでも言うべきもので、全体を通じて設計思想を学べる本は一度目を通しておいたほうが良さそうです。

ちなみに Programming Clojure の第三版は英語ですが、いまは自動翻訳がそれなりに賢いので、技術用語がわかれば苦労せず読めるはずです。第二版は通称「孔雀本」と呼ばれる日本語訳がありますが、Clojure 1.3 ベースであるため、最近追加された機能には言及していないという弱点があります。とはいえそれほど激しく仕様が変わる言語ではないため、第二版の内容がまったく通用しないということはないと思います。

さて、かくいう私はまだ半分も読んでいませんが、ひとつ言えることがあります。 Clojure 初心者はここに書いてある内容とコードを信用してはいけない、と。 本を読んだ結果、とてもお手本になるようなコードではないということが判明してしまったため、「オブジェクト指向から来た人間が関数型をなんとなくで書くとこうなる」というケーススタディとして、ニヤつきながら眺めることをおすすめします。

というわけで、今回は正規表現形態素解析を使って PatternResponder を実装……する予定でしたが、長くなってしまったので先んじて 形態素解析 を実装します。

目次

Sudachi - Java で書かれた今どきの形態素解析ライブラリ

Unmo では形態素解析ライブラリ Sudachi を採用することにしました。比較的新しく、活発に開発されています。

Releases から sudachi-0.1.1-dictionary-full.zip をダウンロード・展開して、 system_full.dicproject.clj と同じディレクトリに置きましょう。

さらに、この辞書ファイルを Sudachi がロードするための設定ファイルが必要なので、リポジトリから sudachi_fulldict.json をダウンロードし、やはり同じディレクトリに置きます。以下のようなディレクトリ構成になるはずです。

unmo/
├── CHANGELOG.md
├── LICENSE
├── README.md
├── project.clj
├── sudachi_fulldict.json
├── system_full.dic
├──...

これで準備完了です。コーディングに移りましょう。

morph.clj - 形態素解析

Sudachi は Java で書かれているため、Clojure の関数として呼び出すことはできません。しかし Clojure <-> Java 間で連携するのは珍しいことではなく、Java から Clojure を呼び出したり、またその反対も手軽に行うことができます。Clojure の目的は「Java 言語の置き換え」ではなく「JVM に対する関数的なアプローチ」なので、直接 Java を呼び出すのは日常茶飯事だったりします。

import - Java との連携

Unmo から Sudachi を呼び出すため morph.clj を用意しましょう。まずは必要なクラスと定数を import します。

(ns unmo.morph
  (:import [com.worksap.nlp.sudachi DictionaryFactory Tokenizer$SplitMode])
  (:require [clojure.string :as str]))

Java と連携する場合、 require ではなく import を用います。この場合はパッケージ com.worksap.nlp.sudachi から DictionaryFactory, Tokenizer$SplitMode をインポートしています。

同時に clojure.stringstr として require していますが、これは後に登場する関数で使います。

Sudachi の形態素解析

というプロセスになっています。 SplitMode を指定できるのが Sudachi の特徴で、 A, B, C の 3 種類があり、 A でもっとも細かく、 C でもっとも長い形態素に分解することができます。詳しくはリポジトリを参照してください。

Tokenizer インスタンスは Unmo 実行中常に参照することになり、生成のオーバーヘッドも高いので、一度だけ定義するようにします。

(def ^:private split-mode
  {:a Tokenizer$SplitMode/A
   :b Tokenizer$SplitMode/B
   :c Tokenizer$SplitMode/C})

(def ^:private tokenizer
  (try
    (let [settings (slurp "sudachi_fulldict.json" :encoding "UTF-8")]
      (.. (DictionaryFactory.)
          (create settings)
          create))
    (catch java.io.FileNotFoundException e
      (println (. e getMessage))
      (println "形態素解析ライブラリ Sudachi の設定ファイルと辞書を用意してください。")
      (System/exit 1))))

^:private は他の名前空間から呼ばれないようにするための宣言です。これらのシンボルはいずれも morph.clj 内部でのみ使われるべき Java オブジェクトで、外部との通信は関数によって行います。

..Java オブジェクトに対してメソッドチェインのような呼び出し方を提供してくれるマクロです。Java コードをかなり簡略化して再現すると、 (new DictionaryFactory).create(settings).create(); という感じでしょうか。

展開すると以下のようになります。

(. (. (new DictionaryFactory) (create settings)) create)

括弧のネストに加え、対になる閉じ括弧が遠くなって、ちょっといじるのが難しそうです。しかし new.Java と連携する上では基本となるものなので、覚えておくと良いでしょう。この辺りの解説は Java Interop (有志による日本語訳予定)や Clojure の「..」「doto」「->」「->>」マクロの使い方覚え書き | Qiita が参考になるはずです。

また、JSON ファイルまたは辞書が読み込めなかった場合は雑に catch して終了するようになっていますが、我ながらこの辺に妥協が見え隠れしていますね……。

analyze 関数 - Clojure から形態素解析を行う

では実際に形態素解析してみましょう。 analyze 関数を定義して、そこから tokenizer オブジェクトを利用します。

(defn analyze
  ([text] (analyze text :c))
  ([text mode]
   (into [] (comp (map #(vector (. % surface)
                                (str/join \, (. % partOfSpeech))))
                  (filter (comp seq first %)))
         (. tokenizer tokenize (mode split-mode) text))))

(analyze "あたしはプログラムの女の子です") のように呼び出すと、

[["あたし" "代名詞,*,*,*,*,*"]
 ["は" "助詞,係助詞,*,*,*,*"]
 ["プログラム" "名詞,普通名詞,サ変可能,*,*,*"]
 ["の" "助詞,格助詞,*,*,*,*"]
 ["女の子" "名詞,普通名詞,一般,*,*,*"]
 ["です" "助動詞,*,*,*,助動詞-デス,終止形-一般"]]

のような、 [表層形 品詞]ベクターとして返ってきます。詳しく処理を追ってみましょう。

analyze 関数は引数の数ごとに定義を変えています。 text のみ与えられた場合は (analyze text :c) と自分自身を呼び出すようになっており、 mode:c を指定しています。

本体である [text mode] で呼び出された場合ですが、まずは実際の形態素解析から。

tokenize - ClojureJava の融合

(. tokenizer tokenize (mode split-mode) text) という式で形態素解析をしていて、残りのコードは Clojure オブジェクトへの変換です。 . 記法で tokenizer.tokenize メソッドを呼び出しています。

(mode split-mode)(:c split-mode) のように展開されるため、実際には Java オブジェクトである Tokenizer$SplitMode/C になります。

tokenize メソッドの戻り値は本来 List<Morpheme> という Java のコレクションですが、Clojure が抽象化してくれるため、 Morpheme オブジェクトのシーケンスに変換されます。

欲しい情報は『表層形』『品詞』ですので、 Morpheme に対して surface, partOfSpeech メソッドを呼び出し、さらにその結果をベクターに変換する必要があります。これは mapv と無名関数で実現できますが、ひとつ問題点がありました。

map, filter - シーケンスの変換

以下のコードを見てください。

(->> (. tokenizer tokenize (:c split-mode) "…")
     (map #(vector (. % surface)
                   (str/join \, (. % partOfSpeech)))))

=> (["" "補助記号,句点,*,*,*,*"] ["" "補助記号,句点,*,*,*,*"] ["…" "補助記号,句点,*,*,*,*"])

三点リーダーをひとつ渡しただけなのに、要素数が3つのシーケンスが返ってきています。これが Sudachi の仕様なのかどうかは(調べてないので)わかりませんが、よく見ると2つは表層形部分が "" になっています。これを取り除くためのうってつけの関数が filter です。

「要素の first が空でないこと」という条件で要素を集めてみましょう。

(->> (. tokenizer tokenize (:c split-mode) "…")
     (map #(vector (. % surface)
                   (str/join \, (. % partOfSpeech))))
     (filter #(seq (first %))))

=> (["…" "補助記号,句点,*,*,*,*"])

seq はシーケンスを作成するための関数ですが、空のコレクションを渡すと nil を返すという特徴があります。文字列 "" は厳密にはコレクションではありません( (coll? "") => false )が、 (seqable? "") => true となるため、 seq を適用できます。

この特徴を利用して empty? の反対、すなわち「空でなければ真」を実現できます(カマイルカさんありがとう!)。

comp, into - トランスデューサーによる最適化

tokenize で入力シーケンスを生成し、 map で中間シーケンスを生成し、 filter で出力シーケンスを得る。スレッドマクロ ->> を使うと処理がとてもわかりやすくなりますが、中間シーケンスを生成しなければならないため、ややコストがかかるのも事実です。

Clojure ではデータがイミュータブルであるため、これらのシーケンスはすべてメモリ上にコピーされます。実際はもちろん最適化が行われますが、もっと最適化してもらうためにコードをリファクタリングできます。

例えば (map inc) という式を評価してみてください。 clojure.lang.ArityException ではなく、関数が返ってくるはずです。これが トランスデューサー と呼ばれるもので、これを合成してひとつの関数にまとめ、「複数の変換処理」を「一回の変換処理」にすることができます。

関数合成には compose を意味する comp 関数を用います。これによりコレクションの走査が一度だけになり、オーバーヘッドが大幅に低減されます。

また、 into を使って「出力先のコレクション」を指定することができます。今回は既存のコレクションに追加するわけではないので [] を指定しますが、ベクターに変換することを明示することで最適化が期待できます。シーケンスが lazy であるのに対してベクターは eager であるため、処理系に知らせるだけでパフォーマンスが上がるのです。

と、文字だらけになりましたが、コードを見れば一目瞭然です。スレッドマクロからトランスデューサーへの変換は、想像以上に簡単です。

;; スレッドマクロ版
(->> (. tokenizer tokenize (:c split-mode) "あたしはプログラムの女の子です")
     (map #(vector (. % surface)
                   (str/join \, (. % partOfSpeech))))
     (filter #(seq (first %))))

;; トランスデューサー版
(into [] (comp (map #(vector (. % surface)
                             (str/join \, (. % partOfSpeech))))
               (filter #(seq (first %))))
         (. tokenizer tokenize (mode split-mode) "あたしはプログラムの女の子です"))

入力元シーケンスと出力先コレクションを明示的に指定するようになっただけで、 map, filtercomp に渡すようになっただけです。順番も変わっていません。

本来 comp は渡された関数を右から左へ適用していくものですが、 トランスデューサーが合成される場合は左から右へ スタックのように合成されます。「スレッドマクロからトランスデューサーに書き直す場合、順序は変わらない」と覚えておくといいでしょう。(参考:Clojure 公式ドキュメント日本語訳「トランスデューサー」

今回の例では変換処理がそれほど複雑ではないためパフォーマンス面での恩恵は少ないかもしれません。しかし、

  • 遅延シーケンスが必要ではない場合はベクターを使ったほうがいい
  • シーケンスの変換でスレッドマクロを使っている場合、 into, comp の採用を検討するべき

この 2 点は覚えておいて損はないでしょう。

リファクタリング - ClojureStyleGuide

上記の例では、 filter 関数に #(seq (first %)) を渡しています。しかし先に述べた analyze の実装では (comp seq first) を渡していました。

これは ClojureStyleGuide に記載されていて、「そういう無名関数は comp でシンプルに記述したほうがいいよ」と推奨されています。実際 ()% がなくなってすっきりしますし、 なんかスタイリッシュなところが一番重要です。

なお、こちらはトランスデューサーではなく普通の関数を渡しているため、右から左へ処理が行われます。まず first が呼び出され、次に seq が呼び出されることになりますので、無名関数とまったく同じ処理をしているわけですね。

noun? 関数 - 名詞判定

日本語にはさまざまな品詞がありますが、Unmo で使うのは名詞のみです。そのうち代名詞は除外するとして、大体次のような名詞が必要になります。(※私も専門家ではないのでよくわかりません)

  • 一般名詞
  • 通名
  • 固有名詞
  • サ変接続
  • 形容動詞語幹

形態素を引数に取り、名詞であれば true 、それ以外は false を返す関数 noun? を定義します。

(defn noun? [[_ part]]
  (-> #"名詞,(一般|普通名詞|固有名詞|サ変接続|形容動詞語幹)"
      (re-find part)
      boolean))

(noun? ["単語" "品詞情報,*,*,*,*"]) のような形式で呼び出すため、引数を分配束縛してわかりやすくしています。単語部分に _ を指定しているのは、必要のない値を捨てると明示するためです。

#""Clojure における正規表現リテラルで、内部的には java.util.regex.Pattern オブジェクトです。個人的に「言語が正規表現リテラルをサポートしているかどうか」はとても重要で、CommonLisp や elispエスケープ記号 \\\\ だらけになったときは頭を抱えました。

あとは見たまま、品詞情報 part"名詞" が含まれているかどうかを正規表現で判定( re-find )し、その結果を boolean に渡して真偽値にしています。

re-find正規表現オブジェクトが文字列に含まれていれば、合致した文字列を、含まれていなければ nil を返します。その後の boolean を呼び出さなくても条件式には使えますが、文字列を使うことはないので明示的に変換しています。

まとめ

実装したのは analyzenoun? だけでしたが、やたら長くなってしまいました。 実際 2 日がかりで書いたので、 いまようやく終えることができると思うと感無量です。次からはもっと力を抜いて書きたい……というようなことを毎回言ってる気がしますが、毎回本気でそう思っているんです。

今回は Java との連携、トランスデューサーが主でした。私は Java に明るくないのですが、経験者は「こんなに手軽に Java が呼び出せるのか」と感じることが多いようです。事実 List<Morpheme>Clojure から見れば「何かが詰まったシーケンス」でしかなく、非常に抽象度の高いオブジェクトとして扱うことができます。

パフォーマンス面でも、Java から返ってきたオブジェクトをトランスデューサーで効率的に Clojure の型に変換することができました。Clojure のシーケンス操作は基本的に何に対しても適用できるため、Java と連携するというよりは、JVM 世界全体を包むもうひとつの世界、といった趣きがあります。

さて、Part1 を書いてから 1 ヶ月も経ってしまいましたが、これからも(入院や手術をしない限り)のんびり続きを書いていきます。次こそは PatternResponder を実装したいところですねぇ……。