正規表現でSudachiの結果を強制的に書き換えるプラグイン

## 誤解析修復プラグイン

[Sudachi](https://github.com/WorksApplications/Sudachi) の開発初期から検討されている機能のひとつにプラグインによる誤解析修復 (誤解析回復[^1]) があります。

[^1]: たぶんATOK用語。

以下のように意図しない解析になってしまったときにルールで強制的に結果を書き換えます。

```plaintext
にはにはにわにわとりがいる。
にわ    名詞,普通名詞,一般,*,*,*        庭
に      助詞,格助詞,*,*,*,*     に
はにわ  名詞,普通名詞,一般,*,*,*        埴輪
にわとり        名詞,普通名詞,一般,*,*,*        鶏
が      助詞,格助詞,*,*,*,*     が
いる    動詞,非自立可能,*,*,上一段-ア行,終止形-一般     居る
。      補助記号,句点,*,*,*,*   。
EOS
```

「はにわ」となっている部分をパターンマッチで「は/に/わ」と書き換えるイメージです。

```plaintext
(はにわ,*)/にわとり,* -> は,助詞,係助詞,*,*,*,*/に,名詞,数詞,*,*,*,*/わ,接尾辞,名詞的,助数詞,*,*,*
```

括弧内が書き換えたい箇所で、表記、品詞などは適宜ワイルドカードにできることを想定しています。

## 2時間ででっちあげる

実装の面倒さから長い間放置していたのですが、Java の regex をつかって効率度外視でさくっと実装してみることにします。

まず解析結果を以下のような文字列にエンコードします。

```
0:にわ,名詞,普通名詞,一般,*,*,*/1:に,助詞,格助詞,*,*,*,*/2:はにわ,名詞,普通名詞,一般,*,*,*/3:にわとり,名詞,普通名詞,一般,*,*,*/4:が,助詞,格助詞,*,*,*,*/5:いる,動詞,非自立可能,*,*,上一段-ア行,終止形-一般/6:。,補助記号,句点,*,*,*,*
```

形態素は'/'区切り、表記と品詞をカンマでつないで先頭にいくつ目の形態素なのかを入れておきます。これを正規表現でマッチさせます。

```
(\d+:はにわ,.+?)/\d:にわとり
```

前方参照部分を取り出して対応する形態素を書き換えればできあがりです。

## 実装

解析結果を書き換えるには `PathRewritePlugin` を継承して実装します。プラグインの起動時に `setUp()` が呼ばれるので、ここで設定の取得と必要な初期化をしておきます。

```Java
@Override
public void setUp(Grammar grammar) {
    this.grammar = grammar;
    pattern = Pattern.compile(settings.getString("pattern"));
    replace = settings.getString("replace");
}
```

sudachi.json や `-s` オプションでわたされた設定は `settings` に入っているので `getString()` でプロパティ名を指定して取り出します。また品詞名を解析内部でつかわれている品詞IDに変換するために `grammar` が必要なのでとっておきます。

最尤パスの推定後、登録された `PathRewritePlugin` の `rewrite()` が呼び出されます。

```Java
@Override
public void rewrite(InputText text, List<LatticeNode> path, Lattice lattice) {
    int offset = 0;
    Matcher m = pattern.matcher(encodePath(path));
    while (m.find()) {
        int[] range = getRange(m.group(1));
        int targetLength = range[1] - range[0];
        List<LatticeNode> target = path.subList(range[0] + offset, range[1] + offset);
        int begin = target.get(0).getBegin();
        String targetHw = getTargetHeadword(target);
        List<WordInfo> replaceWordInfo = getReplaceWordInfo(replace, targetHw);
        target.clear();
        for (WordInfo wi : replaceWordInfo) {
            LatticeNode node = lattice.createNode();
            node.setRange(begin, begin + wi.getLength());
            node.setWordInfo(wi);
            node.setOOV();
            target.add(node);
            begin += wi.getLength();
        }
        offset += replaceWordInfo.size() - targetLength;
    }
}
```

パスを文字列にエンコードして正規表現にマッチした部分を書き換えていきます。書き換えたあとの形態素の情報は `WordInfo` に入れます。さらに位置情報をつけて (`node.setRange()`) ラティスのノードとしてパスに追加します。このとき位置は `InputText` 内の位置なので UTF-8 でかぞえる必要があります (忘れがち)。ノードはとりあえず未知語にしておきます (`node.setOOv()`)。
`List` を破壊的に書き換えているのでインデックスがずれる分を `offset` に入れておきます (忘れがち)。

`WordInfo` をつくるところはこんな感じにします。

```Java
List<WordInfo> getReplaceWordInfo(String replace, String targetHw) {
    List<WordInfo> wis = new ArrayList<>();
    for (String r :replace.split("/")) {
        String[] cols = r.split(",");
        String hw = cols[0].equals("*") ? targetHw : cols[0];
        short length = (short)hw.getBytes(StandardCharsets.UTF_8).length;
        short posId = grammar.getPartOfSpeechId(Arrays.asList(cols).subList(1, cols.length));
        WordInfo wi = new WordInfo(hw, length, posId, hw, hw, "");
        wis.add(wi);
    }
    return wis;
}
```

表記は指定されたものか、マッチした部分をいれて、長さは `InputText` 内の単位なので UTF-8 でかぞえます (忘れがち)。`grammar.getPartOfSpeechId()` で品詞IDを取得します。手抜きをして正規化表記と辞書形は表記とおなじにしておきます。読みはなしで。(本来は `lattice` 内の該当するノードを探すべき)

さてこれでプラグインができました。適当に JAR ファイルにしてクラスパスを通せば Sudachi から利用できるようになります。

```bash
$ echo にわにはにわにわとりがいる。 | java -cp jdartsclone-1.2.0.jar:javax.json-1.1.jar:sudachi-0.5.3.jar:bogusrewrite-0.1.0.jar com.worksap.nlp.sudachi.SudachiCommandLine -s '{"pathRewritePlugin":[{"class":"com.worksap.nlp.sudachi.BogusRewritePlugin","pattern":"(\\d+:はにわ,.+?)/\\d:にわとり","replace":"は,助詞,係助詞,*,*,*,*/に,名詞,数詞,*,*,*,*/わ,接尾辞,名詞的,助数詞,*,*,*"}]}'
にわ    名詞,普通名詞,一般,*,*,*        庭
に      助詞,格助詞,*,*,*,*     に
は      助詞,係助詞,*,*,*,*     は
に      名詞,数詞,*,*,*,*       に
わ      接尾辞,名詞的,助数詞,*,*,*      わ
にわとり        名詞,普通名詞,一般,*,*,*        鶏
が      助詞,格助詞,*,*,*,*     が
いる    動詞,非自立可能,*,*,上一段-ア行,終止形-一般     居る
。      補助記号,句点,*,*,*,*   。
EOS
```

## 人名補正プラグイン

Sudachi 開発当初から後日公開予定といわれつづけていまだにリリースされていない機能に人名補正プラグインがあります。人名-姓と敬称にはさまれた形態素はまとめて人名-名に書き換える機能もつ予定です。

この誤解析補正プラグインがあればこの機能も実現できます。

```bash
$ echo 山田すだち太郎さん | java -jar sudachi-0.5.3.jar
山田    名詞,固有名詞,人名,姓,*,*       山田
すだち  名詞,普通名詞,一般,*,*,*        酢橘
太郎    名詞,固有名詞,人名,名,*,*       太郎
さん    接尾辞,名詞的,一般,*,*,*        さん
EOS
$ echo 山田すだち太郎さん | java -cp jdartsclone-1.2.0.jar:javax.json-1.1.jar:sudachi-0.5.3.jar:bogusrewrite-0.1.0.jar com.worksap.nlp.sudachi.SudachiCommandLine -s '{"pathRewritePlugin":[{"class":"com.worksap.nlp.sudachi.BogusRewritePlugin","pattern":"名詞,固有名詞,人名,姓,\\*,\\*/(.+)/\\d:さん,接尾辞,名詞的,一般,\\*,\\*,\\*","replace":"*,名詞,固有名詞,人名,名,*,*"}]}'
山田    名詞,固有名詞,人名,姓,*,*       山田
すだち太郎      名詞,固有名詞,人名,名,*,*       すだち太郎
さん    接尾辞,名詞的,一般,*,*,*        さん
EOS
```

もうこれでいいような気もしますが、正規表現を書くのが大変だし、ルールがひとつしかかけないし、何より実行効率がわるいので、このプラグインはこのままお蔵入りです。

ではよい Sudachi life を。

```BogusRewritePlugin.java
package com.worksap.nlp.sudachi;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import com.worksap.nlp.sudachi.dictionary.Grammar;
import com.worksap.nlp.sudachi.dictionary.WordInfo;

public class BogusRewritePlugin extends PathRewritePlugin {

    private Grammar grammar;
    private Pattern pattern;
    private String replace;

    @Override
    public void setUp(Grammar grammar) {
        this.grammar = grammar;
        pattern = Pattern.compile(settings.getString("pattern"));
        replace = settings.getString("replace");
    }

    @Override
    public void rewrite(InputText text, List<LatticeNode> path, Lattice lattice) {
        int offset = 0;
        Matcher m = pattern.matcher(encodePath(path));
        while (m.find()) {
            int[] range = getRange(m.group(1));
            int targetLength = range[1] - range[0];
            List<LatticeNode> target = path.subList(range[0] + offset, range[1] + offset);
            int begin = target.get(0).getBegin();
            String targetHw = getTargetHeadword(target);
            List<WordInfo> replaceWordInfo = getReplaceWordInfo(replace, targetHw);
            target.clear();
            for (WordInfo wi : replaceWordInfo) {
                LatticeNode node = lattice.createNode();
                node.setRange(begin, begin + wi.getLength());
                node.setWordInfo(wi);
                node.setOOV();
                target.add(node);
                begin += wi.getLength();
            }
            offset += replaceWordInfo.size() - targetLength;
        }
    }

    String encodePath(List<LatticeNode> path) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < path.size(); i++) {
            if (i != 0) {
                sb.append("/");
            }
            sb.append(i).append(":");
            encodeNode(sb, path.get(i));
        }
        return sb.toString();
    }

    StringBuilder encodeNode(StringBuilder sb, LatticeNode node) {
        WordInfo wi = node.getWordInfo();
        String surface = wi.getSurface();
        String pos = String.join(",", grammar.getPartOfSpeechString(wi.getPOSId()));
        sb.append(surface).append(",").append(pos);
        return sb;
    }

    int[] getRange(String encodedNodes) {
        int[] range = new int[2];
        String[] nodes = encodedNodes.split("/");
        range[0] = Integer.parseInt(nodes[0].split(":")[0]);
        range[1] = Integer.parseInt(nodes[nodes.length - 1].split(":")[0]) + 1;
        return range;
    }

    String getTargetHeadword(List<LatticeNode> path) {
        return path.stream().map(n -> n.getWordInfo().getSurface()).collect(Collectors.joining());
    }

    List<WordInfo> getReplaceWordInfo(String replace, String targetHw) {
        List<WordInfo> wis = new ArrayList<>();
        for (String r :replace.split("/")) {
            String[] cols = r.split(",");
            String hw = cols[0].equals("*") ? targetHw : cols[0];
            short length = (short)hw.getBytes(StandardCharsets.UTF_8).length;
            short posId = grammar.getPartOfSpeechId(Arrays.asList(cols).subList(1, cols.length));
            WordInfo wi = new WordInfo(hw, length, posId, hw, hw, "");
            wis.add(wi);
        }
        return wis;
    }
}
```