astamuse Lab

astamuse Labとは、アスタミューゼのエンジニアとデザイナーのブログです。アスタミューゼの事業・サービスを支えている知識と舞台裏の今を発信しています。

CoreNLPを使ってみる(2) API編

山縣です。

新年明けましておめでとうございます。

弊社の年末年始休暇は例年になく長く11連休となりました。おかげでかなりリフレッシュできました。 まだちょっと休みボケも残っていますが頑張っていきたいと思います。

本年も弊社と当ブログをよろしくお願いします。

今回も CoreNLP について書きたいと思います。 前回 CoreNLP を CLI から使う方法やサーバとして起動してAPI経由で使う方法について書きました。 今回は API の方を見ていきたいと思います。 CoreNLP では Java の API が提供されているので、これを Scala から使ってみます。

1. Stanford CoreNLP API

サンプルコードとその実行

CoreNLP のサイトでAPI についてはこちらで説明されています。 このサイトを参考に Scala から API を使ってみます。

Scala の環境ということでビルドツールに SBT (1.0.4) を使います。

$ cat project/build.properties
sbt.version=1.0.4

build.sbt は以下のとおりです。

lazy val root = (project in file(".")).
  settings(
    organization := "example",
    scalaVersion := "2.11.12",
    version      := "0.1.0-SNAPSHOT",
    name := "Example1",
    libraryDependencies ++= Seq(
      "edu.stanford.nlp" % "stanford-corenlp" % "3.8.0",
      "edu.stanford.nlp" % "stanford-corenlp" % "3.8.0" classifier "models-english",
      "org.slf4j" % "slf4j-simple" % "1.7.12"
    ),
    fork in run := true,
    outputStrategy := Some(StdoutOutput),
    javaOptions in run += "-Xmx8G"
  )

corenlp は maven 上にパッケージがあるのでそれを利用します。 英語のモデルデータを取得するため、追加で classifier "models-english" を指定した依存関係も記述します。 また slf4j-simple を追加しています。このパッケージがないとcorenlp のログメッセージが出力されません。

サンプルのプログラムをサイトのサンプルを元に書いてみました。

package example1
import java.util

import scala.collection.JavaConverters._
import edu.stanford.nlp.ling.CoreAnnotations._
import edu.stanford.nlp.ling.CoreLabel
import edu.stanford.nlp.pipeline._
import edu.stanford.nlp.util.{CoreMap, PropertiesUtils}

import scala.collection.mutable


object Example1 {
  def main(args: Array[String]): Unit = {
    val props = PropertiesUtils.asProperties(
      "annotators", "tokenize, ssplit, pos, lemma, ner",
      "tokenize.language", "en"
    )
    val pipeline = new StanfordCoreNLP(props) //定義したプロパティ propsで Annotator である StanfordCoreNLP を生成

    val text = "Stanford University is located in California. It is a great university."
    val document: Annotation = new Annotation(text) //サンプルテキスト(String) で Annotation を生成

    pipeline.annotate(document) // アノテートする

    printResult(document)
  }

  def printResult(document:Annotation):Unit = {
    val sentences: mutable.Seq[CoreMap] = document.get(classOf[SentencesAnnotation]).asScala
    val tokens: mutable.Seq[CoreLabel] = sentences.flatMap(s => s.get(classOf[TokensAnnotation]).asScala)
    tokens.foreach{ t =>
      val txt: String = t.get(classOf[TextAnnotation])
      val pos: String = t.get(classOf[PartOfSpeechAnnotation])
      val lemma: String = t.get(classOf[LemmaAnnotation])
      val ner: String = t.get(classOf[NamedEntityTagAnnotation])
      println((txt,pos,lemma, ner))
    }
  }

実行すると以下のような出力が表示されます。

sbt:Example1> run
...
[info] Running (fork) example1.Example1
[main] INFO edu.stanford.nlp.pipeline.StanfordCoreNLP - Adding annotator tokenize
[main] INFO edu.stanford.nlp.pipeline.StanfordCoreNLP - Adding annotator ssplit
[main] INFO edu.stanford.nlp.pipeline.StanfordCoreNLP - Adding annotator pos
[main] INFO edu.stanford.nlp.tagger.maxent.MaxentTagger - Loading POS tagger from edu/stanford/nlp/models/pos-tagger/english-left3words/english-left3words-distsim.tagger ... done [0.8 sec].
...
(Stanford,NNP,Stanford,ORGANIZATION)
(University,NNP,University,ORGANIZATION)
(is,VBZ,be,O)
(located,JJ,located,O)
(in,IN,in,O)
(California,NNP,California,LOCATION)
(.,.,.,O)
(It,PRP,it,O)
(is,VBZ,be,O)
(a,DT,a,O)
(great,JJ,great,O)
(university,NN,university,O)
(.,.,.,O)
[success] Total time: 18 s, completed Dec 26, 2017 3:59:03 AM```
アノテーションの実行

上記のサンプルでトークナイズなどの一通りの処理(アノテーシ ョン)を実行する部分は以下になります。

    val props = PropertiesUtils.asProperties(
      "annotators", "tokenize, ssplit, pos, lemma, ner, parse, dcoref",
      "tokenize.language", "en"
    )
    val pipeline = new StanfordCoreNLP(props)

    val text = "Stanford University is located in California. It is a great university."
    val document: Annotation = new Annotation(text)

    pipeline.annotate(document)

コードを少し細かく見ていきます。

    val props = PropertiesUtils.asProperties(
      "annotators", "tokenize, ssplit, pos, lemma, ner, parse, dcoref",
      "tokenize.language", "en"
    )
    val pipeline = new StanfordCoreNLP(props) 

上記の部分では props に処理内容を記述しアノテータである StanfordCoreNLP を生成しています。

    val text = "Stanford University is located in California. It is a great university."
    val document: Annotation = new Annotation(text) 

次に処理したいテキストを用意します。 Annotation クラスのインスタンスdocumentとして定義します。

以上で準備が整ったので実際にアノテーションを実行します。

    pipeline.annotate(document)

annotate を呼ぶことでアノテーションの処理が実行され処理結果が document に保存されます。

処理結果の取得

次に処理された結果の表示について見ていきます。表示は printResult(..) メソッドにまとめています。

  def printResult(document:Annotation):Unit = {
    val sentences: mutable.Seq[CoreMap] = document.get(classOf[SentencesAnnotation]).asScala
    val tokens: mutable.Seq[CoreLabel] = sentences.flatMap(s => s.get(classOf[TokensAnnotation]).asScala)
    tokens.foreach{ t =>
      val txt: String = t.get(classOf[TextAnnotation])
      val pos: String = t.get(classOf[PartOfSpeechAnnotation])
      val lemma: String = t.get(classOf[LemmaAnnotation])
      val ner: String = t.get(classOf[NamedEntityTagAnnotation])
      println((txt,pos,lemma, ner))
    }
  }

まず下記のコードでセンテンスのリストを取得しています。

    val sentences: mutable.Seq[CoreMap] = document.get(classOf[SentencesAnnotation]).asScala

document.get() でアノテーションクラスのClass型インスタンスを渡すことで対応するデータを取得しています。 SentencesAnnotation を渡すと List[CoreMap] が返されます。(上記の場合 asScala で Scala のコレクション mutable.Seq に変換されています。)

各センテンスの情報は CoreMap インタフェースの実装クラスに保存されています。CoreMapはCoreNLP ライブラリの独自の Map インタフェースを定義しています。

次にセンテンスからトークンを取得します。

    val tokens: mutable.Seq[CoreLabel] = sentences.flatMap(s => s.get(classOf[TokensAnnotation]).asScala)

センテンスからトークンのリストを取得しているのは "s.get(classOf[TokensAnnotation])" です。 こちらも get() でトークンのアノテーションクラスのClass型インスタンスを渡すことで結果を取得しています。 get() で返ってくるのは List[CoreLabel] です。(例によって asScala で Scala のコレクション mutable.Seq に変換されています。) flatMap 使うことで Seq のネストを解消して mutable.Seq[CoreLabel] にしています。

各トークンに対してトークンのテキスト、品詞、レンマ、固有表現抽出の結果を出力しているのが下記の部分になります。

    tokens.foreach{ t =>
      val txt: String = t.get(classOf[TextAnnotation])
      val pos: String = t.get(classOf[PartOfSpeechAnnotation])
      val lemma: String = t.get(classOf[LemmaAnnotation])
      val ner: String = t.get(classOf[NamedEntityTagAnnotation])
      println((txt,pos,lemma, ner))
    }

CoreLabel は一つのトークンおよびアノテータにより付加されたアノテーション情報を保存するクラスです。 CoreLabel.get() において引数で指定されたClass型インスタンスに対応した値を返します。上記では各 トークン(CoreLabel) に対して TextAnnotation, PartOfSpeechAnnotation, LemmaAnnotation, NamedEntityTagAnnotation のそれぞれのアノテーションの情報(この場合はいずれもString型)を取得して表示しています。 以上のようにデータを取得するには各段階で get(classOf[...] ) というメソッドを呼び出しますが、コードが見づらい印象を受けます。 CoreLabel では代わりにそれぞれのアノテーションを簡易に取得するメソッドも提供されており上記の部分は、下記のように書くこともできます。

    tokens.foreach{ t =>
      val txt: String = t.word()
      val pos: String = t.tag()
      val lemma:String = t.lemma()
      val ner: String = t.ner()
      println((txt,pos,lemma, ner))
    }

こちらのほうが簡潔に書けて良いですね。

2. Simple CoreNLP API

以上のように Stanford CoreNLP API の使用方法を見てきましたが、少々まどろっこしい感じも受けますね。CoreNLP の API には前述の API とは別に Simple CoreNLP API というものも提供されており、より簡潔な記述で CoreNLP を利用する方法もありますので、ここではそれを見ていきたいと思います。

package example2

import scala.collection.JavaConversions._
import edu.stanford.nlp.simple._

import scala.collection.mutable


object Example2{
  def main(args: Array[String]): Unit = {
    val text = "Stanford University is located in California. It is a great university."
    val doc = new Document(text)
    val tokens: mutable.Seq[Token] = doc.sentences.flatMap(_.tokens())
    tokens.foreach{ t =>
      val txt: String = t.word()
      val pos: String = t.tag()
      val lemma:String = t.lemma()
      val ner: String = t.ner()
      println((txt,pos,lemma, ner))
    }
  }
}

上記は先程の Example1 と同じ内容の処理になります。 実際に実行して確認してみます。

sbt:Example1> runMain example2.Example2
[warn] Multiple main classes detected.  Run 'show discoveredMainClasses' to see the list
[info] Running example2.Example2
[run-main-0] INFO edu.stanford.nlp.tagger.maxent.MaxentTagger - Loading POS tagger from edu/stanford/nlp/models/pos-tagger/english-left3words/english-left3words-distsim.tagger ... done [1.0 sec].
...
(Stanford,NNP,Stanford,ORGANIZATION)
(University,NNP,University,ORGANIZATION)
(is,VBZ,be,O)
(located,JJ,located,O)
(in,IN,in,O)
(California,NNP,California,LOCATION)
(.,.,.,O)
(It,PRP,it,O)
(is,VBZ,be,O)
(a,DT,a,O)
(great,JJ,great,O)
(university,NN,university,O)
(.,.,.,O)
[success] Total time: 17 s, completed Dec 25, 2017 3:34:25 AM

同じ結果が返ってきています。

Simple API を使用するには "edu.stanford.nlp.simple._" を import します。

コードの書き方について Stanford API と比較すると、一番大きな違いは Stanford API で行っていた、アノテータ(StanfordCoreNLP) の生成と annotate() の実行が無いことです。これらの処理は隠蔽されています。

    val doc = new Document(text)
    val tokens: mutable.Seq[Token] = doc.sentences.flatMap(_.tokens())

処理したいテキストで クラスDocument を生成します。上記の "doc.sentences はメソッドでこのメソッドの中で tokenize, ssplit に対応するアノテータが実行されています。

    tokens.foreach{ t =>
      val txt: String = t.word()
      val pos: String = t.tag()
      val lemma:String = t.lemma()
      val ner: String = t.ner()
      println((txt,pos,lemma, ner))
    }

こちらのコードでは、各アノテーションを取得しています。 例えば POS(PartOfSpeach) 情報を取得する tag() メソッドでは内部で pos に対応するAnnotator を実行します。このように Simple API ではデータが実際に必要になったときにはじめて処理が呼び出される遅延実行をすることで無駄な処理が実行されないようにしています。

ドキュメントでは Simple API のメリットとして以下が上げられています。

  • 直感的なシンタックス
  • Lazy computation (必要になるまで処理が実行されない)
  • ヌルポが起きない(nullを返さない)
  • 高速で頑健なシリアライゼーション(protocol buffers を使用)
  • スレッドセーフ

一方で、カスタマイズがしづらいこと、処理が決定的でない(呼ばれる処理の順番によって使用されるアルゴリズムが異なったりすることで結果が常に同じにはならない)などをデメリットとして上げています。

3. Stanford CoreNLP API と Simple CoreNLP APIの性能比較

Stanford API と Simple API について性能面で違いがあるのか比較してみました。 基本的には上記までの Example1, Example2 と同じ処理(pos, lemma, ner) を大きなテキストデータについて実行し、実行時間を計測してみました。 データは社内にあるデータ (英文)1000件、約2MBのデータになります。 環境は 私の作業用のノートPC(Win10, WSL) 上です。

sbt:Example1> runMain example3.Example3 simple
...
[success] Total time: 154 s, completed Dec 26, 2017 7:35:20 AM

上記のように引数 simple をつけた場合 Simple API をそうでない場合は Stanford API を実行するようにし、3回実行して平均を取りました。

Stanford API Simple API
1回目  168 160
2回目  154 154
3回目  155 150
平均  159.0 154.7

結果を見ると Simple API のほうが少し速いようですが、あまり厳密なテストでもないですし、ほとんど変わらないと考えて良いのでは無いかと思います。

次に少し処理を変えてみます。

Stanford API/ Simple API それぞれのデータを取得するメソッドは以下のようになっています。

Standford API:

  def getResult(document:Annotation):Seq[Result] = {
    val sentences: mutable.Seq[CoreMap] = document.get(classOf[SentencesAnnotation]).asScala
    val tokens: mutable.Seq[CoreLabel] = sentences.flatMap(s => s.get(classOf[TokensAnnotation]).asScala)
    tokens.map(t => Result(t.word(), t.tag(), t.lemma(), t.ner())).toSeq
  }

Simple API:

  def getResult(document:Document):Seq[Result] = {
    val tokens = document.sentences.asScala.flatMap(x => x.tokens().asScala)
    tokens.map(t => Result(t.word(), t.tag(), t.lemma(), t.ner())).toSeq
  }

Result はアノテーションの結果を保存する case class で、以下のように定義されています。

case class Result(txt:String, pos:String, lemma:String = "", ner:String = "")

先程までは pos, lemma, ner という3つのアノテーション結果を取得していましたが、lemmaとnerは使わなかったので pos だけを取得しようとコードを変えたとします。 Stanford API/ Simple API それぞれ

    tokens.map(t => Result(t.word(), t.tag(), t.lemma(), t.ner())).toSeq

となっている部分を

    tokens.map(t => Result(t.word(), t.tag())).toSeq

として 品詞の結果だけを取得するように変更します。修正点はここだけになります。 このように変更したコードを使って再度処理を実行してみます。

Stanford API Simple API
1回目  155 17
2回目  160 16
3回目  157 16
平均  157.3 16.3

今回は結果が大きく異なり Simple API は大幅に速くなりました。 一方 Stanford API は、前とほとんど変わりません。

これは Stanford API の場合 プロパティ "annotators" で処理内容を決めており、データを取得する、取得しないにかかわらず annotate() を実行した時点でこれらの処理がすべて実行されてしまうからです。一方で Simple API ではアノテーション取得時に対応するアノテータがオンデマンドで実行されるので呼び出さなければその処理が実行されないため大幅に処理時間を短縮できています。

もちろん Stanford API でも必要としていない lemma, ner を annotators から抜けば同じような処理時間で処理をすることが可能です。ですが、ついうっかり忘れてしまうと無駄に処理時間がかかってしまいます。

おわりに

以上、Stanford API と Simple API について見てみました。 どちらを使うのかは好みの問題とは思いますが、CLI と同じように使いたい、細かいカスタマイズがしたいなら Stanford API を、あまり細かいところは良いのでとにかく手軽に処理がしたいのなら Simple API という感じでしょうか。また NLP の処理は重いので不必要な処理は実行しないようにしないと無駄に処理時間がかかってしまうので気をつけたほうが良いと思います。

ちょっと時間がなくなってしまったので Spark での CoreNLP の使用については次回に書きたいと思います。

Copyright © astamuse company, ltd. all rights reserved.