Lucene 6.0から、KuromojiにN-Best解を求めることができる機能が入っていたそうです。
[LUCENE-6837] Add N-best output capability to JapaneseTokenizer - ASF JIRA
moco(beta)'s backup: Hello Lucene 6.0! その1:PointValues を使ってみる
全然気付いていませんでした…。というか、N-Best解が求められるということが、どういうことか知りませんでした…。
Lucenen 5.5リリース前くらいに、パッチが投げられたということで資料などもあったようです。特に、最初のYahooさんの資料は必見ですね。
第17回Lucene/Solr勉強会 #SolrJP – Apache Lucene Solrによる形態素解析の課題とN-bestの提案
Solr の形態素解析の新機能 N-best 解を試す - おうちらぼ
N-Best解を求めるということ
N-Best解を求めるというのは、形態素解析で得られるひとつの結果ではなくて、その他にとり得るパターンも候補として取り出すことができるもののようです。で、どこまでのパターンを許容するかを、コストとして指定すると。
このあたりは、MeCabのコスト計算を見ることになりました…。
MeCab: Yet Another Japanese Dependency Structure Analyzer
日本テレビ東京で学ぶMeCabのコスト計算 | mwSoft
MeCab で N-Best 解の累積コストを出力する - あらびき日記
形態素解析での分割を行う時、MeCabのフォーマットでいくと「連接コスト + 単語生起コスト (文頭から累積) (%pc)」が最小となるものを選ぶようですが、ここでその他の解も出力しますよ、と。
Lucene KuromojiでN-Best解を求める
Lucene Kuromojiの場合はSEARCHモードやEXTENDEDモードなどありますが、N-Bestで複数の解を求めさせることで、SEARCHモードなどとは別のアプローチで適合率の低下を抑えつつ、再現率の向上を目指しているのだとか。
Apache Solrでは使えそうですね。ElasticsearchのKuromojiでは、まだ先のバージョンになりそうな感じです。
Lucene KuromojiでN-Best解を求めるには、JapaneseTokenizerを使います。JapaneseAnalyzerではありません。
また、JapaneseTokenizerのモードは、NORMALとすることになります。
※Atilika Kuromojiでは、N-Best解は求められなさそう?
コストはJapaneseTokenizer#setNBestCostで指定し、また適切なコスト差分を求めるためのJapaneseTokenizer#calcNBestCostも利用することができます。
今回は、単純にJapaneseTokenizer#setNBestCostでコストを指定してN-Best解を求めてみたいと思います。
とりあえず、結果を見ないとよくわからないので…。
準備
ビルド定義は、こんな感じ。
build.sbt
name := "lucene-kuromoji-n-best" version := "0.0.1-SNAPSHOT" organization := "org.littlewings" scalaVersion := "2.11.8" scalacOptions ++= Seq("-Xlint", "-deprecation", "-unchecked", "-feature") updateOptions := updateOptions.value.withCachedResolution(true) libraryDependencies += "org.apache.lucene" % "lucene-analyzers-kuromoji" % "6.1.0"
N-Best解を含む結果を出力するプログラムを書いてみる
それでは、Lucene Kuromojiを使ってN-Best解を含んだ形態素解析結果を出力するプログラムを作ってみます。
繰り返しますが、Lucene KuromojiでN-Best解を使うには、JapaneseTokenizerでNORMALモードとし、setNBestCostをintで指定する必要があります。
val tokenizer = new JapaneseTokenizer(null, true, JapaneseTokenizer.Mode.NORMAL) tokenizer.setNBestCost(nbestCost) // intで指定
NBestCostのデフォルト値は、0となります。
ですので、N-Best解を求めるにあたって指定するコストを0から変えつつ結果表示するプログラムをこんな感じに書いてみました。
src/main/scala/org/littlewings/lucene/kuromoji/KuromojiNBest.scala
package org.littlewings.lucene.kuromoji import scala.language.postfixOps import scala.sys.process._ import java.io.{ByteArrayInputStream, File, StringReader} import java.nio.charset.StandardCharsets import org.apache.lucene.analysis.ja.{GraphvizFormatter, JapaneseTokenizer} import org.apache.lucene.analysis.ja.dict.ConnectionCosts import org.apache.lucene.analysis.tokenattributes.CharTermAttribute import scala.language.postfixOps object KuromojiNBest { def main(args: Array[String]): Unit = { val targets = Array( /** ここで、形態素解析する文章とNBestCostを指定 */ ) targets.foreach { case (word, costs) => costs.foreach(cost => displayNBestAndViterbi(word, cost)) } } def displayNBestAndViterbi(target: String, nbestCost: Int): Unit = { val graphvizFormatter = new GraphvizFormatter(ConnectionCosts.getInstance) val tokenizer = new JapaneseTokenizer(null, true, JapaneseTokenizer.Mode.NORMAL) tokenizer.setReader(new StringReader(target)) tokenizer.setNBestCost(nbestCost) tokenizer.setGraphvizFormatter(graphvizFormatter) val charTermAttr = tokenizer.addAttribute(classOf[CharTermAttribute]) tokenizer.reset() val words = Iterator .continually(tokenizer.incrementToken()) .takeWhile(identity) .map(_ => charTermAttr.toString) .toArray tokenizer.end() tokenizer.close() println { s"""|Input = ${ target }, NBest = ${ nbestCost } |${ words.map(w => " " + w).mkString(System.lineSeparator()) }""".stripMargin } val dotOutput = graphvizFormatter.finish() "dot -Tgif" #< new ByteArrayInputStream(dotOutput.getBytes(StandardCharsets.UTF_8)) #> new File(s"target/${ target }.gif") ! } }
この部分で、文章とNBestCostを与えつつ結果を見ていこうと思います。
val targets = Array( /** ここで、形態素解析する文章とNBestCostを指定 */ ) targets.foreach { case (word, costs) => costs.foreach(cost => displayNBestAndViterbi(word, cost)) }
なお、最後にGIFファイルを作成していますが、これは以下のエントリの内容を使って、形態素解析時のトークナイズの様子をビジュアル化するためのものです。
Lucene Kuromojiのトークナイズを、Graphvizを使ってビジュアル化する - CLOVER
では、他のエントリに習って、最初はこれで試してみます。
val targets = Array( ("デジタル一眼レフ", Seq(0, 2000)) ) targets.foreach { case (word, costs) => costs.foreach(cost => displayNBestAndViterbi(word, cost)) }
結果は、このように。
Input = デジタル一眼レフ, NBest = 0 デジタル 一 眼 レフ Input = デジタル一眼レフ, NBest = 2000 デジタル 一 一眼 眼 レフ
NBestCostが0(デフォルト)の時は「デジタル」「一」「眼」「レフ」ですが、NBestCostを2000にすると、「デジタル」「一」「一眼」「眼」「レフ」が得られるようになります。
この時に得られたGraphvizでの画像は、こちら。
NBestCostが0の時に選ばれたのが緑の線でトレースされているものですが、NBestCostが2000の時に得られたものは…どれでしょう…。
とりあえずいったん置いておいて、次に「水性ボールペン」で試してみます。
val targets = Array( ("水性ボールペン", Seq(0, 2000, 5677)) ) targets.foreach { case (word, costs) => costs.foreach(cost => displayNBestAndViterbi(word, cost)) }
「水性ボールペン」の場合は、NBestCostが2000では解が変わらなかったので、もっと大きく上げると他の候補が得られました。
結果は、こちら。
Input = 水性ボールペン, NBest = 0 水性 ボールペン Input = 水性ボールペン, NBest = 2000 水性 ボールペン Input = 水性ボールペン, NBest = 5677 水 水性 性 ボール ボールペン ペン
どうもピンとこないので、もっと簡単な例で試してみましょう。
val targets = Array( ("ボールペン", Seq(0, 5677)) ) targets.foreach { case (word, costs) => costs.foreach(cost => displayNBestAndViterbi(word, cost)) }
「ボールペン」、です。こちらもNBestCostをけっこう大きく取らないと、他の候補が得られませんでした。
結果はこのように。
Input = ボールペン, NBest = 0 ボールペン Input = ボールペン, NBest = 5677 ボール ボールペン ペン
ここまでくると、なんとか読めるようになります。
NBestCostが0の時に選ばれているのが
3593 - 283 = 3310
そして、NBestCostを5677と指定した時に得られたものが
(4214 - 283) + (4994 + 62) = 8987
のパターンですね。
つまり、
8987 - 3310 = 5677
というわけで、5677をNBestCostに指定すると、他の候補が得られたわけですね。
ここで足しているコストが、MeCabのフォーマットでいう「連接コスト + 単語生起コスト (文頭から累積) (%pc)」というわけです。
このあたりは、ホントにここを見るとよいです。
MeCabでN-Best解を求める
で、これをMeCabでも求められるようなので、合わせて見てみました。
「ボールペン」。
$ echo ボールペン | mecab -F"%m,%phl,%phr,%pb,%pw,%pc,%pn\n" -N2 ボールペン,1285,1285,*,3593,3310,3310 EOS ボール,1285,1285, ,4214,3931,3931 ペン,1285,1285, ,4994,8987,5056 EOS
「-N2」で、N-Best解を2つ求める、と。
出力する内容を「-F」でフォーマット指定するのですが、「%pw」が単語生起コスト、「%pc」が連接コスト + 単語生起コスト (文頭から累積)です。
この出力では連結コストは見えませんが、単語生起コストに連結コストを加えたものが「%pc」なので、右から2番目の値を見ればよいということになります。
また、累積なので複数単語の場合は最後のものを見ればよいわけですね。
つまり、「ボールペン」なら3310、「ボール」と「ペン」なら8987です。
先ほど試してみた、他の言葉でも試してみましょう。
「デジタル一眼レフ」。ここでは、求めるN-Best解を3にしました。
$ echo デジタル一眼レフ | mecab -F"%m,%phl,%phr,%pb,%pw,%pc,%pn\n" -N3 デジタル,1285,1285,*,3083,2800,2800 一,1295,1295,*,3485,4660,1860 眼,1285,1285,*,4540,7621,2961 レフ,1285,1285,*,3657,11340,3719 EOS デジタル,1285,1285,*,3083,2800,2800 一眼,1285,1285, ,5117,7979,5179 レフ,1285,1285,*,3657,11340,3361 EOS デジタル,1292,1292, ,6228,5250,5250 一眼,1285,1285, ,5117,7979,2729 レフ,1285,1285,*,3657,11340,3361 EOS
この場合は、先ほどのKuromojiの例と同じ結果を求めるには、眼と一眼の差、7979と7621の差、358をコスト指定すればよいみたいです。
最後に、「水性ボールペン」。
$ echo 水性ボールペン | mecab -F"%m,%phl,%phr,%pb,%pw,%pc,%pn\n" -N3 水性,1285,1285,*,5599,5316,5316 ボールペン,1285,1285,*,3593,8971,3655 EOS 水,1285,1285, ,7385,7102,7102 性,1298,1298, ,7390,9402,2300 ボールペン,1285,1285,*,3593,8971,-431 EOS 水性,1285,1285,*,5599,5316,5316 ボール,1285,1285, ,4214,9592,4276 ペン,1285,1285, ,4994,14648,5056 EOS
この場合、Kuromojiの例と同じ結果となるコストは、14648と8971の差っぽいですねぇ…。
まとめ
Lucene Kuromojiに追加されたN-Best解を求める方法で、NORMALモードでもSEARCHモードとは異なる方法で、複数の候補が出せるようになったことがわかりました。
コスト指定は悩みどころのような気がしますが、なかなか面白そうです。
ただ、JapaneseAnalyzerではNBestCostは指定できないので、実際にAnalyzerとして使う場合には各Filterを自分で組み上げたAnalyzerを書かないとダメそうですね(Solrを使う場合は、この限りではなさそうですが)。
今回作成したコードは、こちらに置いています。
https://github.com/kazuhira-r/lucene-examples/tree/master/lucene-kuromoji-n-best