CLOVER🍀

That was when it all began.

Lucene KuromojiでN-Best解を求める

Lucene 6.0から、KuromojiにN-Best解を求めることができる機能が入っていたそうです。

Lucene Change Log

[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: Yet Another Japanese Dependency Structure Analyzer

日本テレビ東京で学ぶMeCabのコスト計算 | mwSoft

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