mecab-python3 + UniDic 利用時にユーザ辞書を追加する (類似単語のコストで代用する)

:::note warn
この記事は Python から MeCab を使用する想定で、mecab-python3==1.0.8 を使用しています。また、この記事の方法は簡単に類似単語のコストで代用するものであって、根本対応 (コスト自動推定が動くようにする) ではありません <font color="#aaaaaa">~~(根本対応は参考文献 [1] にありますが Windows 端末ではやや面倒そうなため実施していません)~~</font>。<font color="#50a747">→ [2024-03-20 追記] コスト自動推定を動かす方法も別途実施しました (以下)。</font>
[MeCab + UniDic 利用時にユーザ辞書を追加する (コスト推定が動かないのを動くようにする) - Qiita](https://qiita.com/CookieBox26/items/7958a30def95f1667da5)
<font color="#50a747">[2024-03-21 追記] ただこちらの方法のほうが便利と思うなど以下にまとめました。</font>
[MeCab + UniDic でユーザ辞書を追加するのにコスト自動推定できない問題 (まとめ, Appendix) - Qiita](https://qiita.com/CookieBox26/items/a607d9e25f3b18d209ea)
:::

### 要旨

MeCab でユーザ辞書を作成したいとき、新規登録単語に「左文脈ID」「右文脈ID」「単語生起コスト」を割り当てる必要がありますが、UniDic 辞書でそれらの値を自動推定しようとすると<font color="#fd5956">「... right-id.def may be broken: ...」</font>などといわれてうまく動かないことがあります [1]。一つの簡単な回避策として、自動推定をあきらめて登録済みの類似単語の設定値で代用する手があります。そのようにする場合、「左文脈ID」「右文脈ID」「単語生起コスト」は MeCab.Tagger.parseToNode() で取り出せます <font color="#50a747">(こちらの関数で取り出すなら MeCab の設定ファイルなどをいじる必要はないです)</font>。

ただ、ユーザ辞書を追加したとき、新規追加単語の単語特徴のフォーマットが揃っていないと、MeCab.Tagger.parse() が None を返すようになってしまうことがあります。そうなってしまっても、MeCab.Tagger.parseToNode() のほうを使えば形態素解析できるようです <font color="#fd5956">(※)</font>。

<font color="#fd5956">※ なぜそうなってしまうのかを MeCab.Tagger.parse() と MeCab.Tagger.parseToNode() のソースコードから読むべきですが読めていません。常に MeCab.Tagger.parseToNode() を使えばよさそうな気もしますがこちらの方が処理速度が遅いようです [2]。</font>


### 参考文献

1. [Mecab のコスト推定自動機能を使って UniDic のユーザ辞書をビルドする](https://zenn.dev/zagvym/articles/28056236903369)
    - こちらの記事が同じ課題に取り組まれていて、根本対応です。
1. [PythonでのMeCabを速くするtips - Qiita](https://qiita.com/yukinoi/items/81a707c1317c97f5fdf9)
    - 辞書登録時に単語特徴のフォーマットが揃っていないと parse() は動かなくなるが parseToNode() は動きます。ただ、こちらの記事によると parseToNode() は parse() より遅いとのことなので基本的にはフォーマットを揃えた方がよいと思います。
1. [MeCab: 単語の追加方法](https://taku910.github.io/mecab/dic.html)
    - MeCab の単語の追加方法のドキュメントです。
1. [「UniDic」国語研短単位自動解析用辞書|バックナンバー](https://clrd.ninjal.ac.jp/unidic/back_number.html)
    - この記事ではシステム辞書に unidic-csj-3.1.1-full.zip を使用しています。ただ、この記事の方法ならばフル版の辞書でなくても構いません (コスト自動推定しないので)。 
1. [MeCabのコスト計算について - Qiita](https://qiita.com/Taka_input/items/ae500a5c4440fa2e091e)
    - 最初 parseToNode() でコストが取れることに気付かず、dicrc に %pc などをかいて parse() で出力するようにしていて、そのとき参考になりました (以下のコード)。

```py
# parse() で左右文脈IDや生起コストや累計コストを出すようにするには
# 辞書フォルダ以下の dicrc に例えば以下を追記した上でキーを指定して実行
# (以下ではキーは panda だが好きな文字列でよい)
# node-format-panda = %m\t%phl,%phr,%pw,%pc\n
# eos-format-panda = EOS\t%phl,%phr,%pw,%pc\n
tagger = MeCab.Tagger('-d "C:/Program Files/MeCab/dic/unidic-csj-3.1.1-full" -Opanda')
print(tagger.parse('葬送のフリーレン'))
```


---

方法だけ知りたい場合は「ユーザ辞書を追加する」セクションの Python コードをみてください。

### そもそも左 (右) 文脈 ID とは何か

MeCab.Tagger.parseToNode() で形態素解析すると、node.wcost にその単語自体の生起コスト、node.cost にその単語までの累計コストが格納されています。

```py
import MeCab
import pandas as pd
tagger = MeCab.Tagger('-d "C:/Program Files/MeCab/dic/unidic-csj-3.1.1-full"')
node = tagger.parseToNode('葬送のフリーレン')
df = pd.DataFrame(columns=['surface', 'wcost', 'cost'])
while node:
    df.loc[len(df)] = [node.surface, node.wcost, node.cost]
    node = node.next
df
```

上のコードの出力は以下になります。「葬送のフリーレン」の累計コストは 20028 です。

|surface|wcost|cost|
|---|---|---|
||0|0|
|葬送|5241|10174|
|の|-176|10848|
|フリー|-3207|11252|
|レン|268|15778|
||0|20028|

 ただ、よくみると wcost 列を累計しても cost 列になっていないことがわかります。これは単語間の連接コストがあるからで、それを明示的に補ってかくならば以下になります。

|surface|生起/連接 cost|累計 cost|
|---|---|---|
|( BOS - 葬送 )|4933|4933|
|葬送|5241|10174|
|( 葬送 - の )|850|11024|
|の|-176|10848|
|( の - フリー )|3611|14459|
|フリー|-3207|11252|
|( フリー - レン )|4258|15510|
|レン|268|15778|
|( レン - EOS )|4250|20028|

なるほど、単語の分け方がよい分け方になっているかを判断するには、各単語の生起コストだけでなく、単語間の連接コストも考えるべきだと思います。しかし、任意の単語ペアの連接コストをもつと単語数の 2 乗になってしまうのではと思ってしまいますが、この連接コストは単語ペアではなく品詞ペア <font color="#aaaaaa">(※)</font> に対して決まっています。

<font color="#aaaaaa">※ 厳密には、品詞だけではなく活用型やアクセント型なども含めたその単語の特徴で決まっています。辞書フォルダ以下の left-id.def, right-id.def を参照してください。</font>

実際には、連接コストは「 左側の単語の<strong>右文脈ID</strong> - 右側の単語の<strong>左文脈ID</strong> 」に対して定義されています。「左 (右) 文脈IDとは何なのか。品詞ID では駄目なのか」と思ってしまいますが、辞書フォルダ以下の left-id.def, right-id.def をみてみると、左 (右) 文脈IDは品詞に対して一意ではないことがわかります (異なる品詞に同じ ID の割り当てがありえます)。このことから察するに、左 (右) 文脈IDは、「品詞 A と品詞 B は右側への各品詞のくっつきやすさの点で同一視される」「品詞 B と品詞 C は左側への各品詞のくっつきやすさの点で同一視される」などを反映した、「左 (右) へのくっつきやすさに振られた ID」になっているようです。

ともあれ、左 (右) 文脈IDは node.lcAttr, node.rcAttr に格納されています。「葬送のフリーレン」の各形態素の左 (右) 文脈IDは以下で表示できます。

```py
import MeCab
import pandas as pd
tagger = MeCab.Tagger('-d "C:/Program Files/MeCab/dic/unidic-csj-3.1.1-full"')
node = tagger.parseToNode('葬送のフリーレン')
df = pd.DataFrame(columns=['surface', '左文脈ID', '右文脈ID', 'feature', 'wcost', 'cost'])
while node:
    df.loc[len(df)] = [node.surface, node.lcAttr, node.rcAttr, node.feature[:20], node.wcost, node.cost]
    node = node.next
df
```
|surface|左文脈ID|右文脈ID|feature|wcost|cost|
|---|---|---|---|---|---|
||0|0|BOS/EOS,*,*,*,*,*,*,|0|0|
|葬送|4267|10526|名詞,普通名詞,一般,*,*,*,ソウソ|5241|10174|
|の|4008|11818|助詞,格助詞,*,*,*,*,ノ,の,の|-176|10848|
|フリー|13113|3035|名詞,普通名詞,形状詞可能,*,*,*,|-3207|11252|
|レン|9501|2852|名詞,普通名詞,一般,*,*,*,レン,|268|15778
||0|0|BOS/EOS,*,*,*,*,*,*,|0|20028|

他方、「灼眼のシャナ」の各形態素の左 (右) 文脈IDは以下です。

|surface|左文脈ID|右文脈ID|feature|wcost|cost|
|---|---|---|---|---|---|
||0|0|BOS/EOS,*,*,*,*,*,*,|0|0|
|灼眼|4267|10526|名詞,普通名詞,一般,*,*,*,シャク|5274|10207|
|の|4008|11818|助詞,格助詞,*,*,*,*,ノ,の,の|-176|10881|
|シャナ|3664|13452|名詞,固有名詞,人名,一般,*,*,シャ|803|15136|
||0|0|BOS/EOS,*,*,*,*,*,*,|0|19189|

「葬送のフリーレン」の「の」と「灼眼のシャナ」の「の」は品詞レベルで同じ「の」なので当然同じ左右文脈 ID をもっていますが、「葬送」と「灼眼」も左右文脈 ID が一致していることがわかります。

そうなると、「葬送 - の」 と 「灼眼 - の」 の連接コストは同一になるはずですが、実際同じ 850 になっています。

```
10848 - 10174 - (-176) = 850
10881 - 10207 - (-176) = 850
```

### ユーザ辞書を追加する

今更ですが unidic-csj-3.1.1-full では「フリーレン」が「フリー」「レン」に分割されてしまっていることがわかります。分割してほしくないので「フリーレン」を辞書登録することにします。

MeCab で新規単語を追加するには以下のフォーマットが要ります [3]。ただ、表層形「フリーレン」以外は何もわかりません。
```
表層形,左文脈ID,右文脈ID,コスト,単語特徴 ...
```

そんなときのためにドキュメント [3] の最下部に「コストの自動推定機能」とあるのですが、少なくとも unidic-csj-3.1.1-full ではこの通りに実行しても「... right-id.def may be broken:」などといって怒られます ([1] でも同じ現象が報告されています)。

なので、今回は「灼眼のシャナ」の「シャナ」からすべて拝借します。「フリーレン」も人名なので (ドイツ語ではそうでないなどという話はさておき)、品詞としてのくっつきやすさは同じでよいでしょうし <font color="#aaaaaa">(実際には left-id.def, right-id.def をみれば人名にも細かい区分がたくさんありますが)</font>、「シャナ」と「フリーレン」の文章中での生起しやすさは違うかもしれませんが、もし「フリーレン」がパースされないようならコストを下げ、別の適切な区切り方より優先されてパースされてしまうようならコストを上げればよいと思います。

ユーザ辞書を追加する Python コードは以下です。どうせなので辞書のコンパイルも Python から呼び出していますが別にすべて Python 上でやる必要はありません。

```py
import MeCab
import re
import subprocess
import os

# ダブルクオートで囲まれていないカンマのみにマッチする正規表現 (単語特徴がダブルクオートに囲まれたカンマを含むため)
split_pattern = re.compile(r',(?=[^"]*(?:"[^"]*"[^"]*)*$)')
dic_dir = 'C:/Program Files/MeCab/dic/unidic-csj-3.1.1-full'


def create_feature(feature_org, new_surf, new_surf_katakana):
    ''' 類似単語の特徴をもとに新規単語の特徴を生成する関数 (適宜カスタマイズする)
        特徴名のリストは以下のファイルにある
        C:/Program Files/MeCab/dic/unidic-csj-3.1.1-full/dicrc
        特徴名の日本語訳は以下のサイトにある
        https://clrd.ninjal.ac.jp/unidic/faq.html, https://hayashibe.jp/tr/mecab/dictionary/unidic/field
    '''
    feature = split_pattern.split(feature_org)
    # lemma, orth, orthBase (語彙素, 書字形出現形, 書字形出現形) 
    for i in [7, 8, 10]:  
        feature[i] = new_surf
    # lForm, pron, pronBase, kana, kanaBase, form, formBase	
    # (語彙素読み, 発音形出現形, 発音形基本形, 仮名形出現形, 仮名形基本形, 語形出現形, 語形基本形)
    for i in [6, 9, 11, 20, 21, 22, 23]:
        feature[i] = new_surf_katakana
    feature[27] = '-1'  # 語彙表ID はわからないが空にすると parse() できなくなるので -1 を入れる
    feature[28] = '-1'  # 語彙素ID はわからないが空にすると parse() できなくなるので -1 を入れる
    return ','.join(feature)


# (1) 「灼眼のシャナ」の「シャナ」から 左文脈ID, 右文脈ID, 生起コスト, 単語特徴を得る
tagger = MeCab.Tagger(f'-d "{dic_dir}"')
node = tagger.parseToNode('灼眼のシャナ')
while node:
    if node.surface == 'シャナ':
        break
    node = node.next
lcid, rcid, wcost = node.lcAttr, node.rcAttr, node.wcost  # 左右文脈ID と生起コストはそのまま拝借
feature_new = create_feature(node.feature, 'フリーレン', 'フリーレン')  # 単語特徴は明らかな特徴だけ上書き

# (2) ユーザ辞書を生成する (CSV生成 → コンパイル)
csv_name = 'hoge.csv'
dic_name = 'hoge.dic'
with open(csv_name, mode='w', encoding='utf8', newline='\n') as ofile:
    row = f'フリーレン,{lcid},{rcid},{wcost},{feature_new}\n'
    print('◆ ユーザ辞書登録内容\n' + row)
    ofile.write(row)
ret = subprocess.run(
    ['C:/Program Files/MeCab/bin/mecab-dict-index.exe', '-d', dic_dir,
     '-u', dic_name, '-f', 'utf-8', '-t', 'utf-8', csv_name],
    capture_output=True, text=True)
print(ret.stdout)
print(ret.stderr)

# (3) ユーザ辞書を適用しない場合とする場合の違いをみる
print('◆ パース結果 (ユーザ辞書なし)')
tagger = MeCab.Tagger(f'-d "{dic_dir}"')
print(tagger.parse('葬送のフリーレン'))

print('◆ パース結果 (ユーザ辞書あり)')
tagger = MeCab.Tagger(f'-d "{dic_dir}" -u "{os.getcwd()}/{dic_name}"')
print(tagger.parse('葬送のフリーレン'))
print(tagger.parse('フリーレント'))
```
上記のコードの実行結果は以下になります。ユーザ辞書を追加した場合は「フリーレン」がパースされるようになり、といって「フリーレント」から「フリーレン」がパースされてしまうようなことはありません (まあ「ト」という単語もないですが)。
```
◆ ユーザ辞書登録内容
フリーレン,3664,13452,803,名詞,固有名詞,人名,一般,*,*,フリーレン,フリーレン,フリーレン,フリーレン,フリーレン,フリーレン,固,*,*,*,*,*,*,人名,フリーレン,フリーレン,フリーレン,フリーレン,1,*,*,-1,-1

reading hoge.csv ... 1
emitting double-array: 100% |###########################################| 

done!

C:/Program Files/MeCab/dic/unidic-csj-3.1.1-full\pos-id.def is not found. minimum setting is used

◆ パース結果 (ユーザ辞書なし)
葬送	名詞,普通名詞,一般,,,,ソウソウ,葬送,葬送,ソーソー,葬送,ソーソー,漢,"","","","","","",体,ソウソウ,ソウソウ,ソウソウ,ソウソウ,"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

フリー	名詞,普通名詞,形状詞可能,,,,フリー,フリー-free,フリー,フリー,フリー,フリー,外,"","","","","","",体,フリー,フリー,フリー,フリー,"2","C2","",9107813192311296,33134
レント	名詞,普通名詞,一般,,,,レント,レント-lento,レント,レント,レント,レント,外,"","","","","","",体,レント,レント,レント,レント,"1","C1","",37201984549429760,135340
EOS
```

上記のコードで create_feature() 関数は、「シャナ」の単語特徴をベースにしつつ、「語彙素」「書字形」「発音形」など、明らかに上書きすべき項目だけを「フリーレン」で上書きしています。この辺は別に上書きしなくても動くのですが、単語特徴のフォーマット自体は登録済み単語と揃っていないと parse() が None を返してきます。ただ、そのときでも parseToNode() ならば形態素解析してくれます。とはいえ、parse() の方が速いですし parse() が動くようにしておくべきだと思います。

---

[2024-03-20 追記] この記事のように類似単語の設定値で代用するアプローチではなく、コスト自動推定がちゃんと動くようにするアプローチも別途実施しました ([こちらの記事](https://qiita.com/CookieBox26/items/7958a30def95f1667da5))。結果、たまたまですが「フリーレン」に対する「左文脈ID」「右文脈ID」は代用アプローチと同じで、「単語生起コスト」もほぼ同じでした。→ と思ったのですが、「シャナ」の単語特徴を与えているので当然なのかもしれません。→ 実際、アクセント型を空欄にしたら異なる「左文脈ID」「右文脈ID」が推定されました。
- フリーレン,3664,13452,803 ( 「シャナ」の設定値で代用 )
- フリーレン,3664,13452,802 ( このページのように「シャナ」の単語特徴を与えたときの自動推定値 )
- フリーレン,2101,2110,802 ( 上記からアクセント型を空欄にしたときの自動推定値 )