JavaからKNPを呼び出すWrapperを作った

# 3行でわかる記事の内容
* JavaからJuman++, KNPを呼び出すWrapperを作った
* マルチプロセスで走らせるので~~マシンパワーがあれば~~複数文を高速に処理できる
* 結果のparseは自前で実装したParserを用意すれば任意のクラスに変換可能

# 概要

最近はNLP界隈にもDeepの波が押し寄せてきているので「今どきKNPとか使わないよ」という方もいるかもしれません。
とはいえ、まともなデータが無くてよくある手法が使えない、とかDeepな手法を試す前にとりあえずルールベースでどれくらいの結果になるのか試したい、みたいなときには格解析結果や各種Featureなどの豊富な情報を得られるKNPは未だに重宝する存在です。

その一方で、Sudachiのようにライブラリとして使えるわけではないので(pyKNPがあるPython以外の言語では特に)プログラム内で使うのが地味に手間だったりします。

というわけで今回はJavaからKNP(とJuman++)を呼び出すWrapperライブラリを作りました。
(https://github.com/Natsuume/knp4j)
~~Mavenリポジトリに公開するところまでやっておきたかったのですが、間に合わなかったのでMavenリポジトリ上への公開は近日中に行います。~~

~~なお、ある程度は動作確認していますがちゃんとしたテストは書いていないので不具合出る可能性があります~~

## 追記
Maven Centralへ公開しました。
Maven, Gradle等で利用できるようになりました。

```pom.xml
<dependency>
  <groupId>dev.natsuume.knp4j</groupId>
  <artifactId>knp4j</artifactId>
  <version>1.1.3</version>
</dependency>
```

```build.gradle
implementation 'dev.natsuume.knp4j:knp4j:1.1.3'
```

## 使い方
だいたいgithubのREADME.mdの通りです。

```Sample.java
//KNPWrapperを作成するためのBuilder
ResultParser<KnpResult> knpResultParser = new KnpResultParser();
KnpWrapperBuilder<KnpResult> knpWrapperBuilder = new KnpWrapperBuilder<>();
KnpWrapper<KnpResult> wrapper = knpWrapperBuilder
    .setJumanCommand(List.of("bash", "-c", "jumanpp")) //Jumanの実行コマンド
    .setKnpCommand(List.of("bash", "-c", "knp -tab -print-num -anaphora")) //KNPの実行コマンド(現在は「-tab」「-print-num」「-anaphora」オプション必須)
    .setJumanMaxNum(1) //同時に起動するJumanの最大プロセス数
    .setJumanStartNum(1) //初期化時に起動するJumanのプロセス数
    .setKnpMaxNum(1) //同時に起動するKNPの最大プロセス数
    .setKnpStartNum(1) //初期化時に起動するKNPのプロセス数
    .setRetryNum(0) //結果の取得に失敗した場合にリトライする回数
    .setResultParser(knpResultParser) //出力結果のList<String>を任意のクラスに変換するParserを設定する
    .start();
var texts = List.of(
    "テストテキスト1です",
    "テストテキスト2です",
    "テストテキスト3です"
);
texts.parallelStream().map(wrapper::analyze)
    .flatMap(List::stream)
    .map(KnpResult::getSurfaceForm)
    .forEach(System.out::println);
```
`KnpWrapperBuilder`で各種設定を与えて`start()`でKnpWrapperの生成&初回起動を行います。
`setJumanCommand`, `setKnpCommand`には`ProcessBuilder`に与えるコマンドと同じものを与えます。
環境によってはJUMAN, KNPのパスだけで実行できるかもしれません。
(私の環境ではWSL上のJUMANPP, KNPを呼び出す必要があったため上記例のようなコマンドを与えています)

`setResultParser()`以外の設定については上記例の内容がデフォルト値となります。
## 機能  
### マルチプロセスで実行
複数プロセスを立てて使い回します。
同時に立てるプロセス数もJUMAN, KNPそれぞれ自由に設定することができます。

JUMAN, KNPにはサーバモードが存在しますが、こちらは現在未対応です(今後対応予定)。

### 解析失敗時の再実行
基本的には使用されない想定ですが、一連の処理の中で`IOException`や`InterruptedException`が発生した際に例外が発生したプロセスを終了させ、別のプロセスで再度解析を試みます。

### 結果のParser
`ResultParser`インタフェースを実装した任意のParserを出力結果のParserに使用することができます。
`ResultParser`は下記の2種類のメソッドが定義されています。

```ResultParser.java
public interface ResultParser<OutputT> {

  /**
   * Knpの解析結果を入力として任意のインスタンスを返す.
   *
   * @param list Knp解析結果
   * @return 解析結果を表すインスタンス
   */
  OutputT parse(List<String> list);

  /**
   * 解析に失敗した際に使用するインスタンスを返す.
   *
   * @return 解析に失敗した際に返すインスタンス
   */
  OutputT getInvalidResult();
}

```
`getInvalidResult()`は正常な解析結果が得られない場合のインスタンスを返すメソッドです。
上記の例外時再実行を行っても失敗した場合や、KNPが解析に失敗した場合(KNPは半角`+`, `*`が含まれると解析に失敗する)に使用します。

## 一応ちゃんとシングルプロセスより速くなってるか確認
下記のコードで`jumanMaxNum`, `knpMaxNum`を変化させ、実行時間(ms)を比較します。
また実験環境はCPUがRyzen7 3700x, ヒープサイズは32GBで試します。

なお実験環境ではWSLのJUMAN, KNPを呼び出しています。
(WSLはIO遅いという話があるので他の環境だともう少し速くなるかも？)

```java
  public static void main(String[] args) {
    long time = System.currentTimeMillis();

    KnpWrapperBuilder<KnpResult> knpWrapperBuilder = new KnpWrapperBuilder<>();
    int jumanMaxNum = 1;
    int knpMaxNum = 1;
    int textSize = 100;
    KnpWrapper<KnpResult> wrapper =
        knpWrapperBuilder
            .setJumanMaxNum(jumanMaxNum)
            .setKnpMaxNum(knpMaxNum)
            .setResultParser(new KnpResultParser())
            .start();
    var sampleText = "ノリでアドベントカレンダーに登録したけど、" 
        + "間に合う気配がないので今日は%d時間作業をしてからでないと寝ることができない。";
    var texts =
        IntStream.range(0, textSize)
            .mapToObj(i -> String.format(sampleText, i))
            .collect(Collectors.toList());
    var results =
        texts
            .parallelStream()
            .map(wrapper::analyze)
            .flatMap(List::stream)
            .collect(Collectors.toList());

    System.out.println("time: " + (System.currentTimeMillis() - time));
    System.exit(0);
  }
```

### 結果
|jumanMaxNum|knpMaxNum|1回目|2回目|3回目|4回目|5回目|平均|
|-----|-----|-----|-----|-----|-----|-----|-----|
|1|1|17297|17320|17241|17159|17421|17287.6|
|1|5|2808|2764|2858|2791|2789|2802|
|5|1|20334|20211|19974|20037|20189|20149|
とりあえずJUMAN, KNPどちらもシングルプロセスで実行した場合よりもKNPをマルチプロセスで実行した場合の方が速い事がわかりました。
その一方で、ボトルネックになっているKNPと異なりJUMAN側は増やしすぎると逆に遅くなるようです。

JUMANとKNPのプロセス数でどれくらい結果に差が出るか確認するために、テキスト数を100から500に増やして下記の組み合わせを追加で計測しました。

|jumanMaxNum|knpMaxNum|1回目|2回目|3回目|4回目|5回目|平均|
|-----|-----|-----|-----|-----|-----|-----|-----|
|1|5|27953|27590|27674|27999|27669|27777|
|1|10|15825|16366|15118|15632|14931|15574.4|
|5|10|18704|17778|17355|16134|17254|17445|
|10|10|19514|19265|20459|19891|19233|19672.4|
|1|15|14533|22271|14187|21838|19794|18524.6|
|5|15|14149|14584|14929|15709|15228|14919.8|
|10|15|19313|17903|15478|18219|16740|17530.6|
|1|20|21620|14489|21960|20456|15671|18839.2|
|5|20|15899|15820|15713|14720|17053|15841|
|10|20|18850|15850|18461|18200|16357|17543.6|

~~なるほどわからん。~~
一応今回の環境では平均が最も速い組み合わせはJUMANが5プロセス, KNPが15プロセスの組み合わせでした。
とはいえ、[1, 10], [5, 15], [5, 20]あたりの組み合わせは誤差の範囲な気がします。

ついでにWSLのターミナル上で下記コマンドを打った結果も試しておきます。

```
time echo "ノリでアドベントカレンダーに登録したけど、間に合う気配がないので今日は1時間作業をしてからでないと寝ることができない。" | jumanpp | knp -tab -print-num -anaphora
```
|結果|1回目|2回目|3回目|4回目|5回目|平均|
|---|---|---|---|---|---|---|
|real|220|223|234|219|234|226|
|user|78|78|63|109|94|84.4|
|sys|125|125|141|78|125|118.8|

# おまけ
CPUがブンブンまわっているのをみるのはたのしい
![cpu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34789/6d19a967-18d0-bf0e-a141-23399e170b75.png)