3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

自然言語処理 #2Advent Calendar 2019

Day 10

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

Last updated at Posted at 2019-12-09

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にはサーバモードが存在しますが、こちらは現在未対応です(今後対応予定)。

解析失敗時の再実行

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

結果の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遅いという話があるので他の環境だともう少し速くなるかも?)

  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

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?