MeCab + UniDic でユーザ辞書を追加するのにコスト自動推定できない問題 (まとめ, Appendix)

:::note
この記事は、先の 2 つの関連記事の対処法について、そもそもどなぜこの問題が起きているのかと、先の 2 つの対処法のメリット・デメリットをまとめたものです。
:::

### 関連記事
1. <a href="https://qiita.com/CookieBox26/items/48abbbbe6885ca502ae6" id="reference-396227bb90c3faa8a736">mecab-python3 + UniDic 利用時にユーザ辞書を追加する (類似単語のコストで代用する)</a>
   - こちらの方法で形態素解析が思い通りにいけばこちらの方法がよいとは思います。
1. <a href="https://qiita.com/CookieBox26/items/7958a30def95f1667da5" id="reference-d256b53e7eb18b43fcad">MeCab + UniDic 利用時にユーザ辞書を追加する (コスト推定が動かないのを動くようにする) - Qiita</a>

### 参考文献
1. [Mecab のコスト推定自動機能を使って UniDic のユーザ辞書をビルドする](https://zenn.dev/zagvym/articles/28056236903369)
    - 関連記事 [2] 及びこの記事はこちらの参考文献の内容を全面的に参考にしています。また、この記事の Appendix はこちらの参考文献の「未確認な仮説」を確認したものになっています (正しかったです)。
1. [pythonでバイナリファイルを16進ダンプ ＆ バイナリファイルを画像化 - Qiita](https://qiita.com/taka_baya/items/c22bd5f5e7cb3de90988)
    - Python でバイナリファイルを16進ダンプするのに参考にしました。

## まとめ

### 前提
- MeCab は「各単語の生起しにくさ」と「隣接する単語の特徴間のくっつにくさ」にコストを課し、総コストが小さくなる切れ目で文章を単語列に区切ってくれます。
- もっと具体的に、右側へのくっつきにくさが同一視される単語特徴たちには同じ「右文脈ID」、左側へのくっつきにくさが同一視される単語特徴たちには同じ「左文脈ID」が割り当てられ、すべての「右文脈ID - 左文脈ID」対に対して隣接コストが定義されています (連接表 matrix.def に定義されています)。
- そういうわけで、MeCab の辞書フォルダ下のバイナリファイルや連接表には「右文脈ID数 左文脈ID数」が刻まれていて、実行時には互いに矛盾していないかのバリデーションが走ります。

### 本題 (MeCab + UniDic でユーザ辞書を追加するのにコスト自動推定できない)
- MeCab には新規単語へのコスト自動推定機能がありますが、MeCab + Unidic3 で新規単語のコスト自動推定が上手くできないです。これは、コスト自動推定時に、なぜか、連接表に記録されている「右文脈ID数 左文脈ID数」が「左文脈特徴数 右文脈特徴数」に一致することを期待するバリデーションがなされてしまうためです (そもそも特徴に対して ID が 1 対 1 対応であって右文脈 ID も左文脈 ID も同じ IPAdic 向けなのかもしれません)。
- バリデーションエラー自体を回避するには、連接表に記録されている「右文脈ID数 左文脈ID数」を「左文脈特徴数 右文脈特徴数」に書き換えればよいです。ただ、この方法でコスト自動推定付きのユーザ辞書ビルドまでしてしまうと、ユーザ辞書バイナリに「左文脈特徴数 右文脈特徴数」が刻まれてしまうので、このユーザ辞書を使用するときにシステム辞書たちとの矛盾チェックに引っかかり、せっかく作成したユーザ辞書が使用できません。

この問題には、いくつかの対策が考えられます。【1】【2】については、既に書いた別の記事に詳しいです。【3】については、この記事の Appendix が一応の手順です。

<table>
  <tr>
    <th>対策</th>
    <th>メリット</th>
    <th>デメリット</th>
  </tr>
  <tr>
    <td>【1】 新規単語のコスト自動推定をあきらめて、類似単語の設定値を拝借する。コストを既に埋めた状態でユーザ辞書をビルドする (コストを先に埋めておけばエラーにならない)。<br/><a href="https://qiita.com/CookieBox26/items/48abbbbe6885ca502ae6" id="reference-396227bb90c3faa8a736">mecab-python3 + UniDic 利用時にユーザ辞書を追加する (類似単語のコストで代用する)</a></td>
    <td>簡便であるし、これで実用上問題なければこれでよいはずである。</td>
    <td>新規単語と似ている単語が上手く思いつかないかもしれない。</td>
  </tr>
  <tr>
    <td>【2】 新規単語のコスト自動推定とユーザ辞書ビルドは別個に実行できるので、連接表を書き換えたダミー辞書ファイル一式で自動推定のみ行いコストを埋め、オリジナル辞書ファイル一式でユーザ辞書をビルドする。<br/><a href="https://qiita.com/CookieBox26/items/7958a30def95f1667da5" id="reference-d256b53e7eb18b43fcad">MeCab + UniDic 利用時にユーザ辞書を追加する (コスト推定が動かないのを動くようにする) - Qiita</a></td>
    <td>新規登録単語の特徴が既知であれば、よい方法のはずである。</td>
    <td>コスト自動推定のみに使用するダミー辞書フォルダの準備などが必要になる。<br/><br/>よい推定をさせるにはちゃんと特徴を与える必要があるが、新規単語の品詞や活用型等やアクセント型等をちゃんと特定するのはそれなりに難しい気がする。<br/><br/>これらの手間に見合うほど、対策【1】と解析性能に差が出るのかもわからない気がする。</td>
  </tr>
  <tr>
    <td>【3】 連接表を書き換えた状態でコスト自動推定付きのユーザ辞書ビルドまでしてしまった後、ユーザ辞書のバイナリを編集して「右文脈ID数 左文脈ID数」に書き換えて矛盾チェックを回避する。</td>
    <td></td>
    <td>やってみたらできたが、これをやるなら【2】【4】でよいのではと思う。</td>
  </tr>
  <tr>
    <td>【4】 辞書フォルダ以下の right-id.def, left-id.def を眺め、新規単語の右左文脈 ID はどれなのか自分で考える ＆ 単語自体の生起コストも自分で考える。それらを既に埋めた状態でユーザ辞書をビルドする。</td>
    <td colspan="2">【2】をやるにしてもある程度これをやらなければならなくなるとは思われるので、それならもう右 (左) 文脈IDを自分で埋めておけばダミー辞書は要らないというものである。</td>
  </tr>
  <tr>
    <td>【根本対応】MeCab ソースコードのバリデーション箇所を修正してビルドする。</td>
    <td colspan="2"></td>
  </tr>
</table>

## Appendix

以下の Appendix は全体的に、【3】の方法が取れることの確認になります。

#### unidic-csj-3.1.1-full の右 (左) 文脈ID数、右 (左) 文脈特徴数の確認

unidic-csj-3.1.1-full の右 (左) 文脈ID数、右 (左) 文脈特徴数は以下のようになっています。

```py
dic_origin = 'C:/Program Files/MeCab/dic/unidic-csj-3.1.1-full'
for lr in ['right', 'left']:
    set_cid = set()
    set_cfeat = set()
    with open(dic_origin + f'/{lr}-id.def', encoding='utf8') as ifile:
        for line in ifile:
            v = line.strip().split(' ', 1)
            set_cid.add(v[0])
            set_cfeat.add(v[1])
    print(f'size of {lr} context id: {len(set_cid)}')
    print(f'size of {lr} context feature: {len(set_cfeat)}')
```
```
size of right context id: 15629
size of right context feature: 20859
size of left context id: 15389
size of left context feature: 18552
```

#### システム辞書 (バイナリ)、ユーザ辞書 (バイナリ) の冒頭箇所の確認

辞書バイナリのどこに「右文脈ID数 左文脈ID数」が刻まれているかを確認します。

```py
def read_bytes(file_path, chunksize=8192):
    with open(file_path, 'rb') as ifile:
        while True:
            chunk = ifile.read(chunksize)
            if chunk:
                for b in chunk:
                    yield b
            else:
                break

def print_bytes(file_path):
    max_bytes = 64
    print(f'----- first {max_bytes} bytes of {file_path} ----')
    memory_address = 0
    for byte in read_bytes(file_path):
        print(' ' + hex(byte)[2:].zfill(2), end='')
        if memory_address % 16 == 15:
            print()
        memory_address += 1
        if memory_address == max_bytes:
            break
    print()

print_bytes('sys.dic')  # unidic-csj-3.1.1-full 以下のシステム辞書
print_bytes('fuga.dic')  # 1行目を 18552 20859 に書き換えた連接表でビルドしたユーザ辞書
```
```
----- first 64 bytes of sys.dic ----
 b3 cc f3 e1 66 00 00 00 00 00 00 00 75 6a 0d 00
 0d 3d 00 00 1d 3c 00 00 a8 23 3b 01 50 a7 d6 00
 84 78 70 0c 00 00 00 00 55 54 46 2d 38 00 00 00
 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

----- first 64 bytes of fuga.dic ----
 86 9f 71 ef 66 00 00 00 01 00 00 00 01 00 00 00
 78 48 00 00 7b 51 00 00 b0 0f 00 00 10 00 00 00
 e9 00 00 00 00 00 00 00 75 74 66 2d 38 00 00 00
 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
```
以下と照らし合わせると、16, 17, 20, 21 バイト目が、システム辞書では「右文脈ID数 左文脈ID数」になっているが、ユーザ辞書では「左文脈特徴数 右文脈特徴数」になっていることがわかります。
```py
print(hex(15629), hex(15389))  # 「右文脈ID数 左文脈ID数」
print(hex(18552), hex(20859))  # 「左文脈特徴数 右文脈特徴数」
```
```
0x3d0d 0x3c1d
0x4878 0x517b
```

#### ユーザ辞書バイナリを編集する関数

ユーザ辞書に「左文脈特徴数 右文脈特徴数」が刻まれているせいで矛盾チェックに引っかかってしまうので、この部分を「右文脈ID数 左文脈ID数」に書き換えたバイナリを出力する関数をつくります。

```py
def override_and_clone(file_path, file_path_clone):
    """ バイナリの 16, 17, 20, 21 バイト目を「15629 15389」に捏造する関数
    """
    memory_address = 0
    override = {16: b'\x0d', 17: b'\x3d', 20: b'\x1d', 21: b'\x3c'}
    ifile = open(file_path, 'rb')
    ofile = open(file_path_clone, 'wb')
    while True:
        chunk = ifile.read(8192)
        if not chunk:
            break
        for byte in chunk:
            if memory_address in override:
                ofile.write(override[memory_address])
            else:
                ofile.write(byte.to_bytes())
            memory_address += 1
    ofile.close()
    ifile.close()
```

#### ユーザ辞書バイナリを編集して矛盾チェックを回避

さっきの関数を使うと、書き換えた連接表でビルドしたユーザ辞書を、元のシステム辞書と矛盾しないように書き換えられます。それをやったのが以下です。

```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 = 'hoge0321.csv'
dic_name = 'hoge0321.dic'
dic_mod_name = 'hoge0321_mod.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', dic_name,
    '-f', 'utf-8', '-t', 'utf-8', csv_name])
override_and_clone(dic_name, dic_mod_name)

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

print('◆ パース結果 (ユーザ辞書あり)')
tagger = MeCab.Tagger(f'-d "{dic_origin}" -u "{os.getcwd()}/{dic_mod_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
```