概要
駅名を Mecab で構文解析をした際に、以下のようにうまく地名として分ち書きできないケースがありました。
島氏永
島氏永 名詞,固有名詞,一般,*,*,*,島氏永,シマウジナガ,シマウジナガ
EOS
島 名詞,一般,*,*,*,*,島,シマ,シマ
氏永 名詞,固有名詞,人名,姓,*,*,氏永,ウジナガ,ウジナガ
EOS
# 以下略
www.meitetsu.co.jp
この問題の解消のため、郵便局データから地名データを独自に生成して、辞書として読み込むことで、正しく構文解析できるようにしました。
手順
大まかに手順は以下の通りになっています。
- 地名と読みの一覧を作成する
- 1 を使って MeCab 用のユーザー辞書を生成する
また、前回記事を踏まえてより検討し、ABR をベースにデータを加工し、郵便番号データで補正していくことにしました。
1. 地名と読みの一覧を作成する
まずは、アドレス・ベース・レジストリ(以下 ABR)から「全国 町字マスター(フルセット)」の CSV ファイルを取得しました。
dataset.address-br.digital.go.jp
過去の情報配下のデータフォーマット(仕様確定版)と実際のデータを確認すると、以下をのことがわかりました。
- 都道府県・郡・市・区・大字町・小字のように分割されている
- 「大字」、「字」、「小字」は省略なしで表記
- カナ・ローマ字に表記揺れや誤りと思われる表記が散見される(拗音・促音の大文字小文字表記混在、濁りがローマ字と一致しない、明らかに異なる読みが格納されている、等)
一方、郵便番号データは以下のようになっていました。
- 郡・市・区がまとめて格納される
- 「大字」、「字」、「小字」は基本省略
- 括弧書きで小字が含まれるデータを含むことがある
- 読みの精度は ABR と比べると高そう
そのため、まずは正確な読みを入れるために、以下の処理を行うことにしました。
- ABR のデータを整形する
- 小字、もしくは大字・町まで連結した ABR 漢字名を郵便番号データの漢字名と照合し、結果に応じた処理をする
- 郵便番号データに一致する漢字名がある場合
- 読みが一致した場合、正しいデータとしてそのまま扱う
- 読みが一致しない場合、ABR のカナを郵便番号データの読みに差し替える
- 郵便番号データに一致する漢字名がない場合
- 大字まで連結した漢字名で郵便番号データの漢字名と照合する
- 照合できた場合、小字まで連結したデータと同じ処理を施す
- 照合できない場合、該当データを用いない
- 結果を元に辞書用 CSV ファイルを作成する
1. ABR のデータを整形する
ABR のデータの整形は以下のように行いました。
- 「1号」、「1番地」など、地名として意味の薄い文字列を削除する
- 「大字」、「字」、「小字」の表記を削除する
- ローマ字を参照しつつ、漢字・カナの対応する位置に空白を入れる
3.の分割処理は、VS Code 上で以下の正規表現を使って目視で確認しながら進めました。
,(<名前>)([^, ]+?),(<名前カナ>)([^ ,]+?),(<名前ローマ> [A-Z][a-z]+),
,$1 $2,$3 $4,$5,
2. 郵便番号データの漢字名と照合する
ABR のデータを整形したあと、以下のコードを使って郵便番号データとの照合しました。照合は以下のスクリプトを用いました。
import csv
import os
import re
import shutil
import traceback
from yubin.yubin_data import YubinData
from abr.toponym_container import ToponymContainer
yubin_file = '../data/KEN_ALL.CSV'
invalid_file = '../output/invalid_data.csv'
valid_file = '../output/valid_data.csv'
backup_file = '../output/invalid_data.csv.bak'
keys = ['pref', 'county', 'city', 'ward', 'oaza_cho', 'koaza']
def isMatched(toponym):
toponym_name = toponym.name.replace(' ', '')
if toponym.kana.replace(' ', '') == yubin_data.get(toponym_name, None):
return {
'judge': True,
'data': yubin_data.get(toponym_name, None),
'exclude_koaza': False
}
new_attributes = {}
for attribute in toponym.toponym_attributes()[:-1]:
new_attributes[attribute.__class__.key] = str(attribute.name)
new_attributes[attribute.__class__.kana_key] = str(attribute.kana)
new_toponym = ToponymContainer(new_attributes)
new_toponym_name = new_toponym.name.replace(' ', '')
if new_toponym.kana.replace(' ', '') == yubin_data.get(new_toponym_name, None):
if toponym.koaza.name == toponym.koaza.kana:
return {
'judge': True,
'data': yubin_data.get(new_toponym_name, None),
'exclude_koaza': False
}
return {
'judge': True,
'data': yubin_data.get(new_toponym_name, None),
'exclude_koaza': True
}
return {
'judge': False,
'data': '',
'exclude_koaza': False
}
try:
shutil.copy2(invalid_file, backup_file)
shutil.copy2(valid_file, backup_file)
valid_data = []
with open(valid_file, encoding='utf-8', newline='') as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames or ''
valid_data = list(reader)
yubin_data = YubinData(yubin_file).load()
invalid_data = []
with open(invalid_file, encoding='utf-8', newline='') as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames or ''
for row in reader:
toponym = ToponymContainer(row)
if len(toponym.names) != len(toponym.kanas):
continue
found = isMatched(toponym)
if not found['judge']:
row['remarks'] = found['data']
invalid_data.append(row)
continue
if not found['exclude_koaza']:
row['remarks'] = ''
valid_data.append(row)
continue
invalid_data.append(row)
cloned = row.copy()
cloned['koaza'] = ''
cloned['koaza_kana'] = ''
cloned['koaza_roma'] = ''
valid_data.append(cloned)
with open(invalid_file, 'w', encoding='utf-8', newline='') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(invalid_data)
with open(valid_file, 'w', encoding='utf-8', newline='') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(valid_data)
print(f'valid_data: {len(valid_data)}, invalid_data: {len(invalid_data)}')
except Exception as e:
print(f'エラーが発生しました: {e}')
traceback.print_exc()
print('ファイルを復元します')
if os.path.exists(backup_file):
shutil.copy2(backup_file, invalid_file)
os.remove(backup_file)
import re
import unicodedata
class BaseToponymName:
def __init__(self, val: str) -> None:
if not isinstance(val, str):
raise ValueError(f"{val!r} is invalid class")
self._val = unicodedata.normalize('NFKC', val)
from collections import defaultdict
from .prefecture.prefecture import Prefecture
from .city.city import City
from .town.town import Town
class ToponymContainer:
@property
def name(self) -> str:
name = ''
for attribute in self.toponym_attributes():
if attribute:
name = ''.join([name, str(attribute.name)])
return name
@property
def names(self) -> list[str]:
names = []
for attribute in self.toponym_attributes():
if str(attribute.name) != '':
names.append(str(attribute.name))
return names
@property
def kana(self) -> str:
kana = ''
for attribute in self.toponym_attributes():
if attribute:
kana = ''.join([kana, str(attribute.kana)])
return kana
@property
def kanas(self) -> list[str]:
kanas = []
for attribute in self.toponym_attributes():
if str(attribute.kana) != '':
kanas.append(str(attribute.kana))
return kanas
def __init__(self, data):
data = defaultdict(str, data)
self._prefecture = Prefecture(
data['pref'], data['pref_kana']
)
self._city = City(
data['city'], data['city_kana']
)
self._town = Town(
data['town'], data['town_kana']
)
def toponym_attributes(self):
return [
self.prefecture,
self.city,
self.town
]
def is_continue(self):
return '(' in self.town.name.val and not self.town.name.val.endswith(')')
def has_koaza(self):
return '(' in self.town.name.val and '(' in self.town.kana.val
def append_town(self, row):
d = {
'pref': str(self.prefecture.name),
'pref_kana': str(self.prefecture.kana),
'city': str(self.city.name),
'city_kana': str(self.city.kana),
'town': str(self.town.name) + row['town'],
'town_kana': str(self.town.kana) + row['town_kana'],
}
return self.__class__(d)
import csv
import re
from yubin.toponym_container import ToponymContainer
class YubinData:
COL_KANA_PREF = 3
COL_KANA_CITY = 4
COL_KANA_TOWN = 5
COL_PREF = 6
COL_CITY = 7
COL_TOWN = 8
def __init__(self, file: str):
self._file = file
self._data: dict[str, str] = {}
def load(self) -> dict[str, str]:
with open(self._file, encoding='utf-8', newline='') as f:
rows = self._merge_continued_rows(csv.reader(f))
for toponym in rows:
for t in self._split_koaza(toponym):
self._data[str(t.name)] = str(t.kana)
return self._data
def _merge_continued_rows(self, reader) -> list[ToponymContainer]:
result = []
toponym = None
for row in reader:
row = {
'pref': row[self.COL_PREF],
'pref_kana': row[self.COL_KANA_PREF],
'city': row[self.COL_CITY],
'city_kana': row[self.COL_KANA_CITY],
'town': row[self.COL_TOWN],
'town_kana': row[self.COL_KANA_TOWN],
}
toponym = ToponymContainer(
row
) if not toponym or not toponym.is_continue() else toponym.append_town(row)
if not toponym.is_continue():
result.append(toponym)
toponym = None
if toponym is not None:
result.append(toponym)
return result
def _split_koaza(self, toponym: ToponymContainer) -> list[ToponymContainer]:
town_name = str(toponym.town.name)
town_kana = str(toponym.town.kana)
base = {
'pref': str(toponym.prefecture.name),
'pref_kana': str(toponym.prefecture.kana),
'city': str(toponym.city.name),
'city_kana': str(toponym.city.kana),
}
if not toponym.has_koaza():
result = [toponym]
if town_name.endswith('町'):
result.append(
ToponymContainer(
{
**base,
'town': re.sub(r'町$', '', town_name),
'town_kana': re.sub(r'(マチ|チョウ)$', '', town_kana)
}
)
)
return result
match_name = re.match(
r'^(?P<base_name>.+?)\((?P<koaza_names>.+)\)$', town_name
)
base_name = match_name.group('base_name')
koaza_names = match_name.group('koaza_names').split('、')
match_kana = re.search(
r'^(?P<base_kana>.+?)\((?P<koaza_kanas>.+)\)$', town_kana
)
base_kana = match_kana.group('base_kana')
koaza_kanas = match_kana.group('koaza_kanas').split('、')
result = [
ToponymContainer(
{**base, 'town': base_name, 'town_kana': base_kana}
)
]
for name, kana in zip(koaza_names, koaza_kanas):
full_town = base_name + name
full_kana = base_kana + kana
result.append(
ToponymContainer(
{**base, 'town': full_town, 'town_kana': full_kana}
)
)
if full_town.endswith('町'):
result.append(
ToponymContainer(
{
**base,
'town': re.sub(r'町$', '', full_town),
'town_kana': re.sub(
r'(マチ|チョウ)$',
'',
full_kana
)
}
)
)
return result
漢字のみ一致した場合、郵便番号データのデータの区切り方の判断が必要なため、郵便番号データのデータを備考欄に入れて、別途目で確認・修正としました。
3. 辞書用 CSV ファイルを作成する
辞書の元ファイルとなる CSV ファイルの形式は以下のようになっているようです。
表層形,左文脈ID,右文脈ID,コスト,品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用形,活用型,原形,読み,発音
shogo82148.github.io
辞書用 CSV ファイルの作成には以下のスクリプトを用いました。
import csv
import traceback
from toponym_dict.prefecture.prefecture import Prefecture
from toponym_dict.county.county import County
from toponym_dict.city.city import City
from toponym_dict.ward.ward import Ward
from toponym_dict.oaza_cho.oaza_cho import OazaCho
from toponym_dict.koaza.koaza import Koaza
toponyms = [Prefecture, County, City, Ward, OazaCho, Koaza]
input_file = '../output/valid_data.csv'
output_file = '../output/dict_source.csv'
try:
data = set()
with open(input_file, encoding='utf-8', newline='') as f:
reader = csv.DictReader(f)
for row in reader:
for klass in toponyms:
toponym = klass(row[klass.key], row[klass.kana_key])
data.update(toponym.build_dict_entry())
with open(output_file, 'w', encoding='utf-8', newline='') as f:
writer = csv.writer(f)
writer.writerows(data)
print(f'valid_data: {len(data)}')
except Exception as e:
print(f'エラーが発生しました: {e}')
traceback.print_exc()
class BaseToponym:
@property
def name(self) -> BaseToponymName:
return self._name
@property
def kana(self) -> BaseToponymName:
return self._kana
@property
def hiragana(self) -> BaseToponymName:
return BaseToponymName(
''.join(
chr(ord(c) - 0x60) if 'ァ' <= c <= 'ン' else c
for c in self.kana.val
)
)
@property
def regexes(self) -> list[dict[str, str]]>:
return self._regexes
def build_dict_entry(self) -> list[tuple]:
results = []
names = self.name.split()
kanas = self.kana.split()
for name, kana in zip(names, kanas):
trimmed_toponym = self.__class__(name.val, kana.val).trim()
d = [
trimmed_toponym.name.val,
'',
'',
'',
'名詞',
'固有名詞',
'地域',
'一般',
'*',
'*',
trimmed_toponym.hiragana.val,
trimmed_toponym.kana.val,
trimmed_toponym.kana.val
]
results.append(tuple(d))
return results
def trim(self):
new_toponym = self
for regex in self.regexes:
if not self.name.search(regex['name']):
continue
new_toponym = self.__class__(
self.name.delete(regex['name']).val,
self.kana.delete(regex['kana']).val,
)
break
return new_toponym
class Prefecture(BaseToponym):
key = 'pref'
kana_key = f'{key}_kana'
def __init__(self, name: str, kana: str):
self._name = PrefectureName(name)
self._kana = PrefectureNameKana(kana)
self._regexes = [
{'name': r'都$', 'kana': r'ト$'},
{'name': r'府$', 'kana': r'フ$'},
{'name': r'県$', 'kana': r'ケン$'}
]
最終的には以下のような CSV が生成できました。
御料,,,,名詞,固有名詞,地域,一般,*,*,ごりょう,ゴリョウ,ゴリョウ
上祖師谷,,,,名詞,固有名詞,地域,一般,*,*,かみそしがや,カミソシガヤ,カミソシガヤ
下上野,,,,名詞,固有名詞,地域,一般,*,*,しもうわの,シモウワノ,シモウワノ
北河堀,,,,名詞,固有名詞,地域,一般,*,*,きたかわほり,キタカワホリ,キタカワホリ
清原,,,,名詞,固有名詞,地域,一般,*,*,きよはら,キヨハラ,キヨハラ
原谷乾,,,,名詞,固有名詞,地域,一般,*,*,はらだにいぬい,ハラダニイヌイ,ハラダニイヌイ
出津,,,,名詞,固有名詞,地域,一般,*,*,でづ,デヅ,デヅ
小栗須,,,,名詞,固有名詞,地域,一般,*,*,こぐるす,コグルス,コグルス
有井川,,,,名詞,固有名詞,地域,一般,*,*,ありいがわ,アリイガワ,アリイガワ
甲牧堀,,,,名詞,固有名詞,地域,一般,*,*,こうまきぼり,コウマキボリ,コウマキボリ
鈴川中,,,,名詞,固有名詞,地域,一般,*,*,すずかわなか,スズカワナカ,スズカワナカ
2. MeCab 用辞書を生成する
1.で生成した CSV ファイルを元に、辞書ファイルの生成をしました。
また、文脈 ID やコストは自動推定した結果で CSV ファイルを作成してくれるコマンドがあるため、今回は自動推定することにしました。
そのため、以下の手順で辞書の生成をしました。
- 文脈 ID、コストを自動推定するコマンドを実行する
- 1 のファイルを使って辞書を追加する
- 実行結果を見つつコストを調整する
1. 文脈 ID、コストを自動推定するコマンドを実行する
文脈 ID、コストの推定には以下の別途モデルファイルが必要です。
しかし、公式のモデルファイルを参照したところ、リンクが無効になってしまっていて入手できないことがわかりました。
taku910.github.io
そのため、代わりになるものを探したところ、公式を fork してメンテナンスをしている GitHub リポジトリがあったので、こちらを利用することにしました。
github.com
また、念の為ではありますが、MeCab 本体・辞書共に fork 先のリポジトリでビルドするものを使うことにしました。
そのため、まずは以下に従って MeCab と辞書のインストールを行いました。
https://shogo82148.github.io/mecab/#install
# 本体
$ curl -OL https://github.com/shogo82148/mecab/releases/download/v0.996.13/mecab-0.996.13.tar.gz
$ tar zxfv mecab-0.996.13.tar.gz
$ cd mecab-0.996.13/
$ ./configure
$ make
$ make check
$ sudo make install
# 辞書
$ curl -OL https://github.com/shogo82148/mecab/releases/download/v0.996.13/mecab-ipadic-2.7.0-20070801.tar.gz
$ tar zxfv mecab-ipadic-2.7.0-20070801.tar.gz
$ cd mecab-ipadic-2.7.0-20070801/
$ ./configure
$ make
$ sudo make install
ここから、さらに以下のページ内のモデルファイルをダウンロードして、コマンドを実行したところ、feature.def がないというエラーが出ました。
shogo82148.github.io
$ curl -OL https://github.com/shogo82148/mecab/raw/refs/heads/main/mecab-ipadic-2.7.0-20070801.model.bz2
$ bunzip2 mecab-ipadic-2.7.0-20070801.model.bz2
$ /usr/local/libexec/mecab/mecab-dict-index \
-m ./tmp/mecab-ipadic-2.7.0-20070801.model \
-d /usr/local/lib/mecab/dic/ipadic \
-u ./output/toponym.csv \
-f euc-jp \
-t euc-jp \
-a ./output/dict_source.csv
tmp/mecab-ipadic-2.7.0-20070801.model is not a binary model. reopen it as text mode...
feature_index.cpp(81) [ifs] no such file or directory: /usr/local/lib/mecab/dic/ipadic/feature.def
別途情報を確認したところ、どうやら推定に必要なファイルは -d で指定したディレクトリには生成されていないようです。そのため、以下を参考にダウンロードした辞書ディレクトリを参照するように変更しました。
https://hack.nikkei.com/blog/advent20231205/#コストの自動推定hack.nikkei.com
$ /usr/local/libexec/mecab/mecab-dict-index \
-m ./tmp/mecab-ipadic-2.7.0-20070801.model \
-d ./tmp/mecab-ipadic-2.7.0-20070801 \
-u ./output/toponym.csv \
-f euc-jp \
-t euc-jp \
-a ./output/dict_source.csv
tmp/mecab-ipadic-2.7.0-20070801.model is not a binary model. reopen it as text mode...
reading ./output/dict_source.csv ...
done!
# 左文脈ID,右文脈ID,コストが埋まっている状態で出力されている
$ iconv -f EUC-JP -t UTF-8 ./output/toponym.csv | head
御料,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,ごりょう,ゴリョウ,ゴリョウ
上祖師谷,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,かみそしがや,カミソシガヤ,カミソシガヤ
下上野,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,しもうわの,シモウワノ,シモウワノ
北河堀,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,きたかわほり,キタカワホリ,キタカワホリ
清原,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,きよはら,キヨハラ,キヨハラ
原谷乾,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,はらだにいぬい,ハラダニイヌイ,ハラダニイヌイ
出津,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,でづ,デヅ,デヅ
小栗須,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,こぐるす,コグルス,コグルス
有井川,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,ありいがわ,アリイガワ,アリイガワ
甲牧堀,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,こうまきぼり,コウマキボリ,コウマキボリ
鈴川中,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,すずかわなか,スズカワナカ,スズカワナカ
これで左文脈 ID、右文脈 ID、コストが埋まっているデータを生成できました。
2. 辞書を追加する
今回はお試しでの作成のため、手軽に更新が可能なユーザー辞書を作成しました。
$ /usr/local/libexec/mecab/mecab-dict-index \
-d ./tmp/mecab-ipadic-2.7.0-20070801 \
-u toponym.dic \
-f euc-jp \
-t euc-jp \
./output/toponym.csv
reading ./output/toponym.csv ... 73093
emitting double-array: 100% |###########################################|
done!
# dic ファイルの生成を確認する
$ ls toponym.dic
toponym.dic
# MeCab の設定ファイルに作成したファイルをユーザー辞書に含めることを追記
$ echo "userdic = /path/to/toponym.dic" | sudo tee -a /usr/local/lib/mecab/dic/ipadic/dicrc
userdic = /path/to/toponym.dic
# 追記内容を確認
$ tail /usr/local/lib/mecab/dic/ipadic/dicrc
; ChaSen
node-format-chasen = %m\t%f[7]\t%f[6]\t%F-[0,1,2,3]\t%f[4]\t%f[5]\n
unk-format-chasen = %m\t%m\t%m\t%F-[0,1,2,3]\t\t\n
eos-format-chasen = EOS\n
; ChaSen (include spaces)
node-format-chasen2 = %M\t%f[7]\t%f[6]\t%F-[0,1,2,3]\t%f[4]\t%f[5]\n
unk-format-chasen2 = %M\t%m\t%m\t%F-[0,1,2,3]\t\t\n
eos-format-chasen2 = EOS\n
userdic = /path/to/toponym.dic
shogo82148.github.io
最後に、前回と同じく「島氏永」で検証を試したところ、2番目の結果で「島」も「氏永」も地名として認識されることを確認できました。
$ echo "島氏永" | mecab -N5
島氏永 名詞,固有名詞,一般,*,*,*,島氏永,シマウジナガ,シマウジナガ
EOS
島 名詞,固有名詞,地域,一般,*,*,しま,シマ,シマ
氏永 名詞,固有名詞,地域,一般,*,*,うじなが,ウジナガ,ウジナガ
EOS
島 名詞,一般,*,*,*,*,島,シマ,シマ
氏永 名詞,固有名詞,地域,一般,*,*,うじなが,ウジナガ,ウジナガ
EOS
島 名詞,接尾,地域,*,*,*,島,トウ,トー
氏永 名詞,固有名詞,地域,一般,*,*,うじなが,ウジナガ,ウジナガ
EOS
島 名詞,固有名詞,人名,姓,*,*,島,シマ,シマ
氏永 名詞,固有名詞,地域,一般,*,*,うじなが,ウジナガ,ウジナガ
EOS
まとめ
独自辞書を作成することで、既存の辞書ではうまく解析できていなかった地名を解析できるようにしました。
地名データの補正部分は、現状目視での検査部分もかなり多く、データ作成にかなりの時間を要しましたが、今後 ABR の辞書の精度が向上すればより細かい地名の解析が簡単にできるようになりそう。