Play Framework と Stanfordの固有表現抽出器を用いた NER API サーバー

## 概要

[Play Framework](https://www.playframework.com/) と [Stanfordの固有表現抽出器](http://nlp.stanford.edu/software/CRF-NER.shtml)を使って、固有表現抽出してくれる JSON の API サーバーを作りました。

コードは以下に公開しています。
https://github.com/sosuke-k/ner-play-server

### 動機

Python で自然言語処理をしていて、英語の固有表現抽出をする際に[Stanfordの固有表現抽出器](http://nlp.stanford.edu/software/CRF-NER.shtml)を使う機会がありました。

[nltkのラッパー](http://www.nltk.org/api/nltk.tag.html#module-nltk.tag.stanford) があるのですが、文毎に [java が実行されていて](https://github.com/nltk/nltk/blob/develop/nltk/tag/stanford.py#L93-L94)、おそらくその度に crf のモデルがロードがされているっぽくて、とても遅かったです。
実際に、ウィキペディアの長めの記事のものに **１０分** 近くかかっていました。

そこで、 java のウェブサーバーで固有表現抽出し、結果を返すことにしました。

## バージョン

![You’re using Play 2.5.4](https://i.gyazo.com/df8afc0de46373a419790b1554742de5.png)

## ルーティング

`/ner` に `GET` で モデルを初期化します。（ `classifier` をパラメータで受け取りモデルを変更できます。）
`/ner` に `POST` で固有表現抽出した結果を返してくれます。

```:routes
GET     /ner                        controllers.NERController.index(classifier : String ?= "english.all.3class.distsim.crf.ser.gz")
POST    /ner                        controllers.NERController.tag
```

## 実装

### 依存ライブラリ

`build.sbt` の `libraryDependencies` で指定しています。

http://mvnrepository.com/artifact/edu.stanford.nlp

```build.sbt
libraryDependencies ++= Seq(
  ...
  "edu.stanford.nlp" % "stanford-corenlp" % "3.6.0",
  "edu.stanford.nlp" % "stanford-parser" % "3.6.0"
)
```

### モデルのロード （固有表現抽出器の初期化）

`public` フォルダ以下に置くことで、URL指定で読み込んでいます。
`gzip` されたものを読み込むのに、単純な `InputStream` でエラーが出てしまって、つまづきました。

```console
$ wget http://nlp.stanford.edu/software/stanford-ner-2015-12-09.zip
$ unzip stanford-ner-2015-12-09.zip
$ cp -r ./stanford-ner-2015-12-09/classifiers/ ./public/classifiers/
```

```:routes
GET     /assets/*file               controllers.Assets.versioned(path="/public", file: Asset)
```

```NERController.java
public class NERController extends Controller {
  ...
  private boolean initialize(String classifier) {
    try {      
      URL url = new URL("http://" + this.request().host() + "/assets/classifiers/" + classifier);
      HttpURLConnection connect = (HttpURLConnection) url.openConnection();
      connect.setRequestMethod("GET");
      connect.setRequestProperty("Accept-Encoding", "gzip");
      GZIPInputStream gzin = new GZIPInputStream(connect.getInputStream());

      this.classifier = CRFClassifier.getClassifier(gzin);

```

### ポストのデータをJSONで受け取る

ここ調べ不足なんですが、強引に文字列に直した後、 `ObjectMapper` で `text` の値を取得しています。

```NERController.java
  public Result tag() {
    ...
    JsonNode json = request().body().asJson();
    // String text = json.findPath("text").getTextValue();
    String jsonstring = Json.stringify(json);

    ObjectMapper mapper = new ObjectMapper();
    Doc doc = null;
    try {
      doc  = mapper.readValue(jsonstring, Doc.class);
    } catch ( IOException e) {
      System.out.println( "IOException: could not mapper.readValue" );
    }

    return ok(Json.toJson(this.tag_sent(doc.text)));
  }
```

```Doc.java
public class Doc {
  public String text;
}
```

### 固有表現抽出

アンサーラベルを取得するのに、適当なメソッドが見つからず、ソースコードを見て強引に取得しています。
[CoreLabel (Stanford JavaNLP API)](http://nlp.stanford.edu/nlp/javadoc/javanlp/edu/stanford/nlp/ling/CoreLabel.html)

```NERController.java
  private Tokens tag_sent(String text) {
    Tokens tokens = new Tokens();
    for (List<CoreLabel> lcl : this.classifier.classify(text)) {
      for (CoreLabel cl : lcl) {
        tokens.add(new Token(cl.originalText(), cl.get(AnswerAnnotation.class)));
      }
    }
    return tokens;
  }
```

`classify` するコード自体は `stanford-ner-2015-12-09.zip` の `NERDemo.java` をコピペしました。

### JSONを返す

```NERController.java
...
  private Tokens tag_sent(String text) {
    tokens tokens = new Tokens();
    ...
    return tokens;
  }
  ...
  public Result tag() {
    ...
    return ok(Json.toJson(this.tag_sent(doc.text)));
  }
...
```

```Tokens.java
public class Tokens {
  public List<Token> tokens;

  public Tokens() {
    this.tokens = new ArrayList<Token>();
  }

  public void add(Token token) {
    this.tokens.add(token);
  }
}
```

```Token.java
public class Token {
  public String text;
  public String label;

  public Token(String text, String label) {
    this.text = text;
    this.label = label;
  }
}
```

## こんな感じ

初期化して（ここはすこじ時間がかかります）

```
$ curl http://localhost:9000/ner | jq
{
  "status": "available",
  "current": "english.all.3class.distsim.crf.ser.gz",
  "message": ""
}
```

文をポスト

```
$ curl --header "Content-type: application/json" --request POST --data '{"text":"I live in Japan."}' http://localhost:9000/ner | jq
{
  "tokens": [
    {
      "text": "I",
      "label": "O"
    },
    {
      "text": "live",
      "label": "O"
    },
    {
      "text": "in",
      "label": "O"
    },
    {
      "text": "Japan",
      "label": "LOCATION"
    },
    {
      "text": ".",
      "label": "O"
    }
  ]
}
```

`jq` コマンドは linux系とかだと使いかた違うかもしれないです。

## 結果

python からこのサーバーを叩くようにした結果、 **１０分** 近くかかっていた記事の固有表現抽出が、 **5秒** で終わりました。
元の python のコードが良くなかったのかな？と思っていますが、とりあえず、よかったです。

※おまけ
テストなどしてないですが、ビルドしたものをあげておきました。。汗
https://github.com/sosuke-k/ner-play-server/releases

```console
$ wget https://github.com/sosuke-k/ner-play-server/releases/download/v_1.0.0/ner-play-server-1.0-SNAPSHOT.zip
$ unzip ner-play-server-1.0-SNAPSHOT.zip
$ chmod +x ner-play-server-1.0-SNAPSHOT/bin/ner-play-server
$ ner-play-server-1.0-SNAPSHOT/bin/ner-play-server
...
```