MeCab + UniDic 利用時にユーザ辞書を追加する (コスト推定が動かないのを動くようにする)

MeCab でユーザ辞書を作成したいとき、新規登録単語に「左文脈ID」「右文脈ID」「単語生起コスト」を割り当てる必要がありますが、UniDic 辞書でそれらの値を自動推定しようとすると<font color="#fd5956">「... right-id.def may be broken: ...」</font>などといわれてうまく動かないことがあります [1]。が、これは以下の手順で回避できます。

:::note
作業環境は Windows 上の Git Bash であり、mecab のバージョンは 0.996、システム辞書は unidic-csj-3.1.1-full です <font color="#fd5956">(コスト推定するにはフル版の辞書が必要です)</font>。
:::

:::note warn
<strong>この記事はほぼ参考文献 [1] の手順の書き直しです。</strong>[1] の手順が Windows 上の Git Bash でも実行できることを確認したのと、Windows では【手順1】が必要だったという微細な貢献しかないです。加えて、この対処法で問題ないのか、不具合が出るケースがないのかちゃんと確認していません。matrix.def の内容を調べて任意の 右文脈ID - 左文脈ID が網羅されていれば問題ないとは思います。
:::

:::note
特に mecab-python3 からの利用であって、辞書がフル版ではないとか、この記事の手順を取る時間がない (?) 場合は、以下の記事のアプローチ (似ている単語のコストで代用する) もとれると思います。 [mecab-python3 + UniDic 利用時にユーザ辞書を追加する (類似単語のコストで代用する)](https://qiita.com/CookieBox26/items/48abbbbe6885ca502ae6)
:::

### 参考文献
1. [Mecab のコスト推定自動機能を使って UniDic のユーザ辞書をビルドする](https://zenn.dev/zagvym/articles/28056236903369)
1. [Git for Windowsでシンボリックリンクを扱えるようにする - Qiita](https://qiita.com/ucho/items/c5ea0beb8acf2f1e4772#%E7%92%B0%E5%A2%83%E5%A4%89%E6%95%B0msys%E3%81%ABwinsymlinksnativestrict%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%99%E3%82%8B)
1. [MeCab の辞書構造と汎用テキスト変換ツールとしての利用](https://taku910.github.io/mecab/dic-detail.html)
    - MeCab の構造についてのドキュメントです。matrix.def について<font color="#fd5956">「最初の行に連接表のサイズ(前件サイズ, 後件サイズ)を書きます」 </font>とありますが、unidic-csj-3.1.1-full ではこの前件サイズ, 後件サイズが left-id.def の行数, right-id.def の行数と食い違っているために[この箇所](https://github.com/taku910/mecab/blob/05481e751dd5aa536a2bace46715ce54568b972a/mecab/src/dictionary.cpp#L182-L189)のバリデーションでエラーとなりコスト推定ができません (さしあたり[Issue](https://github.com/taku910/mecab/issues/76)を立てておきました)。
    - なので、最初の行を書き直して突破するというのが [1] 及びこの記事のアプローチです。このアプローチをとっても問題ないかは、matrix.def にちゃんとすべての 右文脈ID - 左文脈ID 対が含まれているかによると思います。

---

以下、手順です。一言でいうと、コスト推定用のダミー辞書フォルダを用意し、ダミー側では matrix.def の 1 行目を書き換えます。[^1] [^2]

[^1]: 参考文献 [1] だと逆にオリジナル版を退避させて、ユーザ辞書ビルド時のみオリジナル版を参照するというようになっています。そちらの方がシンボリックリンクの数が少なく済むかもしれませんが、私は何となくオリジナル版を変更したくなかったので、この記事では「ダミー辞書フォルダを作成し、コスト推定時のみこれを参照する」というようにしています。

[^2]: そもそもオリジナル版の辞書フォルダ内の matrix.def を直接編集するのではいけないのかという話ですが、それについては参考文献 [1] に詳しいです。私も試しましたが、そのアプローチではユーザ辞書のビルドまでは一見できるものの、形態素解析時に incompatible dictionary だと怒られます。matrix.def で書き換えた (左文脈IDサイズ, 右文脈IDサイズ) がユーザ辞書 hoge.dic に刻まれてしまうが、sys.dic に刻まれている (左文脈IDサイズ, 右文脈IDサイズ) と食い違うのでこうなっています ([ソース1](https://github.com/taku910/mecab/blob/master/mecab/src/tokenizer.cpp#L130-L131), [ソース2](https://github.com/taku910/mecab/blob/master/mecab/src/dictionary.h))。これについて、参考文献 [1] にも仮説としてユーザ辞書バイナリを編集すればいいのではとありますが、個人的には sys.dic の方を編集してしまえば解決しないか試してみたいです (時間があれば) → [こちらの記事](https://qiita.com/CookieBox26/items/a607d9e25f3b18d209ea) でユーザ辞書バイナリの編集を試したところ、ちゃんと動作し、[1] の仮説は正しかったです。なお、sys.dic 側を編集してしまう作戦では、sys.dic にとどまらず unk.dic や model.bin も編集する必要があり、ただそれでも解決しなかったので、バイナリたちには正しい「右文脈ID数 左文脈ID数」が刻まれていなければならないのだと思います。

### 【手順1】 (Windows の場合は) シンボリックリンクを作成できるようにする

Windows ではデフォルトでシンボリックリンクを作成できるようになっておらず、```ln -s``` コマンドを実行するとコピーが作成されてしまうので、作成できるようにしておきます [2]。Git Bash を管理者権限で実行して以下を実行し、1回閉じます。
```
setx MSYS winsymlinks:nativestrict
```

### 【手順2】 コスト推定用ダミー辞書フォルダを用意する

コスト推定用にダミー辞書フォルダを用意します。Git Bash を再び管理者権限で起動し、以下を実行します。まずはフォルダをつくって、コスト推定に必要なファイルはオリジナルへのシンボリックリンクとして用意します。ただし、matrix.def は次のステップで用意します。

```sh
dic_origin="/c/Program Files/MeCab/dic/unidic-csj-3.1.1-full"
dic_dummy="/c/Program Files/MeCab/dic/unidic-csj-3.1.1-dummy"

mkdir "${dic_dummy}"

ln -s "${dic_origin}/dicrc" "${dic_dummy}/dicrc"
ln -s "${dic_origin}/rewrite.def" "${dic_dummy}/rewrite.def"
ln -s "${dic_origin}/model.bin" "${dic_dummy}/model.bin"
ln -s "${dic_origin}/feature.def" "${dic_dummy}/feature.def"
ln -s "${dic_origin}/sys.dic" "${dic_dummy}/sys.dic"
ln -s "${dic_origin}/unk.dic" "${dic_dummy}/unk.dic"
ln -s "${dic_origin}/char.bin" "${dic_dummy}/char.bin"
ln -s "${dic_origin}/left-id.def" "${dic_dummy}/left-id.def"
ln -s "${dic_origin}/right-id.def" "${dic_dummy}/right-id.def"
```

### 【手順3】 matrix.def をコピーして修正する

matrix.def については、オリジナルをコピーして 1 行目を書き換えます。オリジナル辞書が unidic-csj-3.1.1-full の場合は、以下を実行することになります。

```sh
cp "${dic_origin}/matrix.def" "${dic_dummy}/matrix.def"
head -n 1 "${dic_dummy}/matrix.def"  # 1行目が 15629 15389 になっている
sed -i -e "1c18552 20859" "${dic_dummy}/matrix.def"  # 18552 20859 に書き換える
```

上記で書き換えるべき数字は、コスト推定実行時のエラーメッセージからわかります。
```
dictionary.cpp(184) [cid.left_size() == matrix.left_size() && cid.right_size() ==
matrix.right_size()] Context ID files(C:/Program Files/MeCab/dic/unidic-csj-3.1.1-
full\left-id.def or C:/Program Files/MeCab/dic/unidic-csj-3.1.1-full\right-id.def
may be broken: 18552 15629 20859 15389
```
これは、「matrix 側としては右左文脈ファイル行数が 15629 15389 であることを期待しているのに、18552 20859 になっている」ということですが、ファイル行数を参照されることは避けられないので、matrix.def 側の記述を 18552 20859 に合わせてやればよいということになります。あるいは、left-id.def と right-id.def の行数を直接調べてもわかります <font color="#aaaaaa">(本当は右 → 左文脈ファイル行数は 20859 → 18552 なんですが、書き換え時は 左 → 右 ファイル行数の順で書かなければならないです。これもバリデーション側が変なポイントです。推測ですが、matrix.def 内の左列は右文脈 ID、右列は左文脈 ID なのでバリデーション時は左右をねじらなければならないのですが、単に右同士と左同士のバリデーションとしてコーディングしてしまったというミスだと思います)</font>。

### 【手順4】 コスト推定を実行する
これでコスト推定が実行できるようになります。用意したダミー辞書フォルダを参照し、コストを空欄にした hoge.csv を指定して以下を実行すると、コストが補完された hoge2.csv が出力されます (管理者権限で実行しなくてもよいです)。 
```sh:run.sh
#!/bin/bash
mdi="/c/Program Files/MeCab/bin/mecab-dict-index.exe"
dic_dummy="/c/Program Files/MeCab/dic/unidic-csj-3.1.1-dummy"
"$mdi" -m "${dic_dummy}/model.bin" -d "$dic_dummy" -u hoge2.csv -f utf-jp -t utf-8 -a hoge.csv
```

```:hoge.csv
フリーレン,,,,名詞,固有名詞,人名,一般,*,*,フリーレン,フリーレン,フリーレン,フリーレン,フリーレン,フリーレン,固,*,*,*,*,*,*,人名,フリーレン,フリーレン,フリーレン,フリーレン,1,*,*,-1,-1
```
```:hoge2.csv
フリーレン,3664,13452,802,名詞,固有名詞,人名,一般,*,*,フリーレン,フリーレン,フリーレン,フリーレン,フリーレン,フリーレン,固,*,*,*,*,*,*,人名,フリーレン,フリーレン,フリーレン,フリーレン,1,*,*,-1,-1
```

そのまま辞書へのコンパイルと形態素解析の動作確認までやってしまうと以下です。

```sh:run2.sh
#!/bin/bash
mdi="/c/Program Files/MeCab/bin/mecab-dict-index.exe"
dic_origin="/c/Program Files/MeCab/dic/unidic-csj-3.1.1-full"
dic_dummy="/c/Program Files/MeCab/dic/unidic-csj-3.1.1-dummy"
"$mdi" -m "${dic_dummy}/model.bin" -d "$dic_dummy" -u hoge2.csv -f utf-jp -t utf-8 -a hoge.csv
"$mdi" -d "$dic_origin" -u hoge.dic -f utf-jp -t utf-8 hoge2.csv
echo 葬送のフリーレン | mecab -d "$dic_origin" > out.txt
echo 葬送のフリーレン | mecab -d "$dic_origin" -u hoge.dic >> out.txt
```

```:out.txt
葬送	名詞,普通名詞,一般,,,,ソウソウ,葬送,葬送,ソーソー,葬送,ソーソー,漢,"","","","","","",体,ソウソウ,ソウソウ,ソウソウ,ソウソウ,"0","C2","",16853597732086272,61313
の	助詞,格助詞,,,,,ノ,の,の,ノ,の,ノ,和,"","","","","","",格助,ノ,ノ,ノ,ノ,"","名詞%F1","",7968444268028416,28989
フリー	名詞,普通名詞,形状詞可能,,,,フリー,フリー-free,フリー,フリー,フリー,フリー,外,"","","","","","",体,フリー,フリー,フリー,フリー,"2","C2","",9107813192311296,33134
レン	名詞,普通名詞,一般,,,,レン,レン,レン,レン,レン,レン,外,"","","","","","",体,レン,レン,レン,レン,"1","C3","",72669480915968512,264370
EOS
葬送	名詞,普通名詞,一般,,,,ソウソウ,葬送,葬送,ソーソー,葬送,ソーソー,漢,"","","","","","",体,ソウソウ,ソウソウ,ソウソウ,ソウソウ,"0","C2","",16853597732086272,61313
の	助詞,格助詞,,,,,ノ,の,の,ノ,の,ノ,和,"","","","","","",格助,ノ,ノ,ノ,ノ,"","名詞%F1","",7968444268028416,28989
フリーレン	名詞,固有名詞,人名,一般,,,フリーレン,フリーレン,フリーレン,フリーレン,フリーレン,フリーレン,固,"","","","","","",人名,フリーレン,フリーレン,フリーレン,フリーレン,"1","","",-1,-1
EOS
```
ユーザ辞書を追加しないと「フリー」「レン」と分割されていまっていたのが、ユーザ辞書を追加すると「フリーレン」になっています。

---

### 【おまけ】 mecab-python3 からの利用例

上記のようにコスト推定用ダミー辞書フォルダを一度用意すれば、Python から気軽に (?) 新規単語を追加できるようになります。[^3]

[^3]: ただ、新規単語のコストをよく推定してもらうには結局単語特徴を正しく与える必要があり、少なくとも unidic-csj-3.1.1-full の場合はアクセント型なんかを正しく与えなければならず、個人的には新規登録しようとする単語のアクセント型が全然わかりません。全然わからないので既存単語の特徴をコピーすると、その既存単語のコストとほぼ同じ値が推定されてくるので、自動推定の意味があまりなさそうです。アクセント型系の特徴はわからなければ空欄にすればよいのかも全然わかりません。

```py
import MeCab
import subprocess
import os

dic_origin = 'C:/Program Files/MeCab/dic/unidic-csj-3.1.1-full'
dic_dummy = 'C:/Program Files/MeCab/dic/unidic-csj-3.1.1-dummy'
mdi = 'C:/Program Files/MeCab/bin/mecab-dict-index.exe'

def create_dummy_feature(
    new_surf, new_surf_katakana,
    pos='名詞,普通名詞,一般', goshu='和', goisorui='体',
):
    feature = pos.split(',')
    feature = feature + ['*'] * (29 - len(feature))
    for i in [7, 8, 10]:  
        feature[i] = new_surf
    for i in [6, 9, 11, 20, 21, 22, 23]:
        feature[i] = new_surf_katakana
    feature[12] = goshu
    feature[19] = goisorui
    feature[27] = '-1'  # 空にすると parse() できなくなるので -1 を入れる
    feature[28] = '-1'  # 空にすると parse() できなくなるので -1 を入れる
    return ','.join(feature)

new_surf = 'フリーレン'
new_surf_katakana = 'フリーレン'
feature = create_dummy_feature(
    new_surf, new_surf_katakana, '名詞,固有名詞,人名,一般', '固', '人名')

csv_name = 'hogehoge.csv'
csv_filled_name = 'hogehoge_filled.csv'
dic_name = 'hogehoge.dic'
with open(csv_name, mode='w', encoding='utf8', newline='\n') as ofile:
    ofile.write(f'{new_surf},,,,{feature}\n')
subprocess.run([
    mdi, '-m', f'{dic_dummy}/model.bin',
    '-d', dic_dummy, '-u', csv_filled_name,
    '-f', 'utf-8', '-t', 'utf-8', '-a', csv_name])
subprocess.run([
    mdi, '-d', dic_origin, '-u', dic_name,
    '-f', 'utf-8', '-t', 'utf-8', csv_filled_name])

print('◆ パース結果 (ユーザ辞書なし)')
tagger = MeCab.Tagger(f'-d "{dic_origin}"')
print(tagger.parse('葬送のフリーレン'))

print('◆ パース結果 (ユーザ辞書あり)')
tagger = MeCab.Tagger(f'-d "{dic_origin}" -u "{os.getcwd()}/{dic_name}"')
print(tagger.parse('葬送のフリーレン'))
```
```
◆ パース結果 (ユーザ辞書なし)
葬送	名詞,普通名詞,一般,,,,ソウソウ,葬送,葬送,ソーソー,葬送,ソーソー,漢,"","","","","","",体,ソウソウ,ソウソウ,ソウソウ,ソウソウ,"0","C2","",16853597732086272,61313
の	助詞,格助詞,,,,,ノ,の,の,ノ,の,ノ,和,"","","","","","",格助,ノ,ノ,ノ,ノ,"","名詞%F1","",7968444268028416,28989
フリー	名詞,普通名詞,形状詞可能,,,,フリー,フリー-free,フリー,フリー,フリー,フリー,外,"","","","","","",体,フリー,フリー,フリー,フリー,"2","C2","",9107813192311296,33134
レン	名詞,普通名詞,一般,,,,レン,レン,レン,レン,レン,レン,外,"","","","","","",体,レン,レン,レン,レン,"1","C3","",72669480915968512,264370
EOS

◆ パース結果 (ユーザ辞書あり)
葬送	名詞,普通名詞,一般,,,,ソウソウ,葬送,葬送,ソーソー,葬送,ソーソー,漢,"","","","","","",体,ソウソウ,ソウソウ,ソウソウ,ソウソウ,"0","C2","",16853597732086272,61313
の	助詞,格助詞,,,,,ノ,の,の,ノ,の,ノ,和,"","","","","","",格助,ノ,ノ,ノ,ノ,"","名詞%F1","",7968444268028416,28989
フリーレン	名詞,固有名詞,人名,一般,,,フリーレン,フリーレン,フリーレン,フリーレン,フリーレン,フリーレン,固,"","","","","","",人名,フリーレン,フリーレン,フリーレン,フリーレン,"","","",-1,-1
EOS
```