#概要
キーワードマッチングを行うときの表記ゆれ対策として自分がよくやることをまとめました。
#環境
macOS Catalina 10.15.4
python 3.8.0
#想定するシステム
任意の単語や日本語を入力として、検索対象文書から関連しそうなものを抽出するシステムを想定します。基本的なやり方はキーワードマッチングで、入力と検索対象文書からそれぞれキーワードを抽出し、一致するものがあるか探します。ただしユーザは自由に入力できるので、表記ゆれを吸収した上で、キーワードマッチングをする必要があります。想定される表記ゆれの要因としては、下記が考えられます。
- 全角、半角の違い
- ひらがな、カタカナ、漢字、ローマ字の表記ゆれ
- 活用形の違い
- 1,2文字程度の打ち間違い、変換ミス
これらを吸収できるような頑健な検索システムを作ることを目指します。
#表記ゆれ対策
##1. neologdnによる正規化
mecab-ipadic-neologdを適用する際におこなうのが望ましいとされる正規化処理を行います。一から書いてもよいのですが、neologdnというこれらの正規化処理をライブラリを作ってくださったかたがいるのでお借りします。全角数字や全角英字を半角にする、半角のかな・カナを全角にする、などの処理が含まれます。つまり、全角、半角の違いを吸収できます。
pip install neologdn
import neologdn
def format_text(text):
text = neologdn.normalize(text)
return text
##2. ローマ字に変換
ひらがな、カタカナ、漢字、ローマ字の表記ゆれを吸収するため、入力文と検索対象のフリガナを取得し、ローマ字に変換した上で取得します。同音異義語の区別ができなくなりますが、今回は許容します。
やりかたはMeCabで読みを取得したうえで、romkanというライブラリで更にローマ字に変換します。MeCabの辞書にない単語の場合は次善策として、表層形のひらがな部分だけをローマ字に変換したもので代用します。
pip install romkan
import MeCab
import romkan
#日本語をローマ字に変換する
def jp2roma(text):
text = myomi.parse(text)[:-1]#最後の改行コードを除く
text = romkan.to_roma(text)
text = text.replace("'","") #カナの切れ目に挿入されるアポストロフィを削除します。
return text
##3. 原型の抽出
動詞などは活用することで語尾が代わってしまい、マッチングに引っかからなくなることが考えられるため、MeCabで形態素解析して、原型を取得することで、語尾の活用による表記ゆれを吸収します。MeCabの辞書に存在せず、原型を取得できない単語は、表層形で代用します。以下のコードでは、キーワード検索で助詞などをヒットさせる意味は基本的には薄いと思ったため、抽出する単語を自立語(非自立でない名詞、動詞、形容詞、副詞)に絞っています。
import MeCab
import re, regex
#形態素解析objectの宣言
m=MeCab.Tagger()
myomi = MeCab.Tagger("-Oyomi")
#正規表現objectの宣言
re_kana = regex.compile(r'[\p{Script=Hiragana}\p{Script=Katakana}ーA-Za-z]+')
re_num = re.compile('[0-9]+')
#自立語の原型を抽出
def get_raw_stem(text):
words = []
tokens = m.parse(text).splitlines()[:-1]
#もし入力が8文字以下で2単語以上に分割されていて、全部ひらがなかカタカナだったらそのままをstemとする。
if len(tokens)>1 and len(text)<=8 and re_kana.fullmatch(text):
words.append(text)
for token in tokens:
if '\t' not in token: continue
surface, pos = tuple(token.split('\t'))
pos = pos.split(',')
ok = (pos[0]=='名詞' and pos[1] in ['一般','固有名詞','サ変接続','形容動詞語幹'])
ok = (ok or (pos[0]=='形容詞' and pos[1] == '自立'))
ok = (ok or (pos[0]=='副詞' and pos[1] == '一般'))
ok = (ok or (pos[0]=='動詞' and pos[1] == '自立'))
#自立語以外も抽出する場合は以下をコメントアウト
if ok == False: continue
stem = pos[-3]
if stem == '*': stem = surface
if stem == 'ー': continue
#ひらがなまたはカタカナ一文字の除外
if re_kana.fullmatch(stem) and len(stem)==1: continue
#数字一文字の除外
if re_num.fullmatch(stem): continue
if stem == '': continue
words.append(stem)
return words
なお前節のjp2romaと合わせると、漢字かな交じり文から自立語の原型をローマ字で取得する関数が作れます。
#原型のフリガナのローマ字を取得
def get_roma_stem(text):
stem = get_raw_stem(text)
stem = [jp2roma(v) for v in stem]
return stem
##4. 編集距離に基づく正解単語の推測
1,2文字程度の打ち間違い、変換ミスがあっても頑健にキーワードマッチングをやりたいときには編集距離を使います。検索対象に含まれる単語から、入力単語と最も近いものを選び、入力単語はその単語に置き換えたときの検索結果をみます。編集距離はeditdistanceというライブラリで計算します。あまり編集距離が大きいものが選ばれても意味がないので、文字列長で正規化した編集距離が一定値より少ない場合のみ採用するようにします。
pip install editdistance
import editdistance as ed
#text1の長さで規格化した編集距離
#text1かtext2の文字数が0の場合はデフォルト値(基本的には長めの値)を返す。
def get_relative_ed(text1, text2, default=2):
if len(text1) == 0: return default
if len(text2) == 0: return default
dist = ed.eval(text1, text2)
return dist/len(text1)
#単語集合words2のなかで単語w1との編集距離がしきい値以下、かつ、もっとも編集距離が近い単語のリストを返す。
def get_ed_based_correct_word(w1, words2, threshold=0.4):
word = []
min_dist = threshold
for w2 in words2:
dist = get_relative_ed(w1, w2)
if dist > min_dist: continue
if dist < min_dist:
min_dist = dist
word = [w2]
elif dist == min_dist:
word.append(w2)
return word
##5. 類義語辞書の作成
同じ単語の表記ゆれではなく、ユーザが意味の近い異なる単語を使っている場合も考えられます。これに対しては類義語辞書を予め作っておくという対策が考えられます。
以下ではkey,valueをそれぞれ単語とその類義語リストと言う形で記述した辞書を事前に作成しておき、参照することで、類義語も正しく参照できるようにしています。なおkey値、value値はそれぞれMeCabで形態素解析できる最小単位にしておくのが望ましいです。一応最小単位以外でなくとも成り立つコードにはなっていますが、意図せぬマッチングを起こす可能性があります。
{
"うるさい": ["騒々しい", "騒がしい", "やかましい", "爆音"],
"分割": ["区切り", "区切る", "分ける", "分かれる"],
"代表的": ["代表", "中心的"]
}
#型:objは{str:[str]}、objのkey、valueの各要素はmecabで形態素解析できる最小要素とは限らない。
def get_synonym_to_correct_word_dict(obj):
dic = {}
for k,v in obj.items():
k_stem = get_raw_stem(k)
if len(k)==0: continue
v_stem = get_raw_stem("\n".join(v))
k_stem = set(k_stem)
v_stem = set(v_stem)
for v3 in v_stem:
if v3 not in dic: dic[v3]=set()
dic[v3] |= k_stem
dic = {k:list(v) for k,v in dic.items()}
return dic
#型:inv_dictは{str:[str]}、key、valueの各要素はmecabで形態素解析できる最小要素
def jp_dict_to_roma(inv_dict):
dic = {}
for k,v in inv_dict.items():
k_roma = jp2roma(k)
v_roma = set([jp2roma(v2) for v2 in v])
if k_roma not in dic: dic[k_roma]=set()
dic[k_roma] |= v_roma
dic = {k:list(v) for k,v in dic.items()}
return dic
##6. word2vecによる類義語の推測
類義語リストを作りたくない、または類義語リストにないものにも対応したい場合は、word2vecによる類似度を基準とする方法が考えられます。
ただしword2vecによる類似度計算の精度はあまり高くないので、他の方法に比べて実装する意味は薄いかも知れません。
関数は編集距離の近い単語を求める前々節のものをword2vecの類似度に置き換えるだけです。編集距離は小さいものを選ぶのに対し、word2vecの類似度は大きいものを選ぶ点に注意してください。
from gensim.models import word2vec
#word2vecモデルの読み込み
MODEL_PATH = "wiki.model"
model = word2vec.Word2Vec.load(MODEL_PATH)
#word2vecの辞書に含まれていればtrueを返す。
def in_w2v(word):
return word in model.wv.vocab
#word2vecの辞書に含まれている単語だけを返す。
def remove_unknown_w2v_word(words):
return [v for v in words if in_w2v(v)]
#word2vecのvocabに含まれるかどうかは事前にチェックしておき、この関数の中ではチェックしない
#w1、words2はローマ字ではなく漢字かな交じりを使うことに注意
def get_w2v_based_correct_word(w1, words2, threshold=0.8):
word = []
max_sim = threshold
for w2 in words2:
sim = model.wv.similarity(w1=w1,w2=w2)
if sim < max_sim: continue
if sim > max_sim:
max_sim = sim
word = [w2]
elif dist == max_sim:
word.append(w2)
return word
なおword2vecの場合、類義語辞書や編集距離を使うメリットはあまり有りません。類義語や編集距離の近い単語が見つかっているのであれば、それの完全一致で検索すればよいからです。
#実装例
上記関数を組み合わせることで、いろいろな表記ゆれを吸収することができるようになります。
一方で、表記ゆれ対策をすればするほど、誤検出率は上がるので、具体的にどの機能を組み込むかは、実際の出力を見ながら選択する必要があるかも知れません。
実装例として、上記6つの対策を組み合わせた表記ゆれ吸収機能を持つコードを示します。検索時間の節約と精度向上の目的で、以下の順序で検索を行っていきます。類義語リストは常に使用する前提です。
- 原型のローマ字
- 編集距離
- word2vec
具体的な処理の流れを文章で書くと下記のようになります。
-
事前処理
- 文書集合の各文書に対してキーワード(自立語の原型のローマ字表記)抽出をする
- 文書集合全体のキーワードのリストをつくる
- 類義語の辞書を作る
-
入力ごとの処理
- 入力文のキーワードを抽出する
- 入力文のキーワードと同じ単語が類義語リストにある場合は、対応する単語に置き換える。置き換え語、各キーワードが検索対象文書中の単語に存在するか確かめる。存在していれば、文書検索を行う。
- 上記でマッチする単語がなかった場合、編集距離を考慮して、マッチする単語があるか探す。
- 上記でマッチする単語がなかった場合、word2vecの類似度を考慮して、マッチする単語があるかを探す。
import neologdn
import MeCab
import romkan
import re, regex
import editdistance as ed
import json
from gensim.models import word2vec
from itertools import chain
import pandas as pd
###一般的に使える関数の定義###
#word2vecモデルの読み込み
MODEL_PATH = "wiki_neologd.model"
model = word2vec.Word2Vec.load(MODEL_PATH)
#形態素解析objectの宣言
m=MeCab.Tagger()
myomi = MeCab.Tagger("-Oyomi")
#正規表現objectの宣言
#re_kana = re.compile('[a-zA-Z\u3041-\u309F]')
re_kana = regex.compile(r'[\p{Script=Hiragana}\p{Script=Katakana}ーA-Za-z]+')
re_num = re.compile('[0-9]+')
#文書の正規化をする
def format_text(text):
text = neologdn.normalize(text)
return text
#日本語をローマ字に変換する
def jp2roma(text):
text = myomi.parse(text)[:-1]#最後の改行コードを除く
text = romkan.to_roma(text)
text = text.replace("'","") #カナの切れ目に挿入されるアポストロフィを削除します。
return text
#自立語の原型を抽出
def get_raw_stem(text):
words = []
tokens = m.parse(text).splitlines()[:-1]
#もし入力が8文字以下で2単語以上に分割されていて、全部ひらがなかカタカナだったらそのままをstemとする。
if len(tokens)>1 and len(text)<=8 and re_kana.fullmatch(text):
words.append(text)
for token in tokens:
if '\t' not in token: continue
surface, pos = tuple(token.split('\t'))
pos = pos.split(',')
ok = (pos[0]=='名詞' and pos[1] in ['一般','固有名詞','サ変接続','形容動詞語幹'])
ok = (ok or (pos[0]=='形容詞' and pos[1] == '自立'))
ok = (ok or (pos[0]=='副詞' and pos[1] == '一般'))
ok = (ok or (pos[0]=='動詞' and pos[1] == '自立'))
#自立語以外も抽出する場合は以下をコメントアウト
if ok == False: continue
stem = pos[-3]
if stem == '*': stem = surface
if stem == 'ー': continue
#ひらがなまたはカタカナ一文字の除外
if re_kana.fullmatch(stem) and len(stem)==1: continue
#数字一文字の除外
if re_num.fullmatch(stem): continue
if stem == '': continue
words.append(stem)
return words
#原型のフリガナのローマ字を取得
def get_roma_stem(text):
stem = get_raw_stem(text)
stem = [jp2roma(v) for v in stem]
return stem
#text1の長さで規格化した編集距離
#text1かtext2の文字数が0の場合はデフォルト値(基本的には長めの値)を返す。
def get_relative_ed(text1, text2, default=2):
if len(text1) == 0: return default
if len(text2) == 0: return default
dist = ed.eval(text1, text2)
return dist/len(text1)
#単語集合words2のなかで単語w1との編集距離がしきい値以下、かつ、もっとも編集距離が近い単語のリストを返す。
def get_ed_based_correct_word(w1, words2, threshold=0.4):
word = []
min_dist = threshold
for w2 in words2:
dist = get_relative_ed(w1, w2)
if dist > min_dist: continue
if dist < min_dist:
min_dist = dist
word = [w2]
elif dist == min_dist:
word.append(w2)
return word
#型:objは{str:[str]}、objのkey、valueの各要素はmecabで形態素解析できる最小要素とは限らない。
def get_synonym_to_correct_word_dict(obj):
dic = {}
for k,v in obj.items():
k_stem = get_raw_stem(k)
if len(k)==0: continue
v_stem = get_raw_stem("\n".join(v))
k_stem = set(k_stem)
v_stem = set(v_stem)
for v3 in v_stem:
if v3 not in dic: dic[v3]=set()
dic[v3] |= k_stem
dic = {k:list(v) for k,v in dic.items()}
return dic
#型:inv_dictは{str:[str]}、key、valueの各要素はmecabで形態素解析できる最小要素
def jp_dict_to_roma(inv_dict):
dic = {}
for k,v in inv_dict.items():
k_roma = jp2roma(k)
v_roma = set([jp2roma(v2) for v2 in v])
if k_roma not in dic: dic[k_roma]=set()
dic[k_roma] |= v_roma
dic = {k:list(v) for k,v in dic.items()}
return dic
#word2vecの辞書に含まれていればtrueを返す。
def in_w2v(word):
return word in model.wv.vocab
#word2vecの辞書に含まれている単語だけを返す。
def remove_unknown_w2v_word(words):
return [v for v in words if in_w2v(v)]
#word2vecのvocabに含まれるかどうかは事前にチェックしておき、この関数の中ではチェックしない
#w1、words2はローマ字ではなく漢字かな交じりを使うことに注意
def get_w2v_based_correct_word(w1, words2, threshold=0.8):
word = []
max_sim = threshold
for w2 in words2:
sim = model.wv.similarity(w1=w1,w2=w2)
if sim < max_sim: continue
if sim > max_sim:
max_sim = sim
word = [w2]
elif dist == max_sim:
word.append(w2)
return word
#検索対象文書、類義語リストごとに宣言できるクラスを定義
class Normalizer:
def __init__(self, documents, synonym_obj):
self.set_documents(documents)
self.set_synonym2correct(synonym_obj, self.all_stem_raw, self.all_stem_roma)
#documentから単語を抽出しメンバーとして保持する
def set_documents(self, documents):
documents = [format_text(v) for v in documents]
stem_raw = [get_raw_stem(v) for v in documents]
all_stem_raw = list(set(chain.from_iterable(stem_raw)))
stem_raw2roma = {v:jp2roma(v) for v in all_stem_raw}
stem_roma2raw = {v:k for k,v in stem_raw2roma.items()}
stem_roma = [ [stem_raw2roma[v2] for v2 in v1] for v1 in stem_raw]
all_stem_roma = list(set([stem_raw2roma[v] for v in all_stem_raw]))
#各stem_rawを含む文書がなにかのリスト
stem_raw_to_doc = {}
for i, v1 in enumerate(stem_raw):
for v2 in v1:
if v2 not in stem_raw_to_doc: stem_raw_to_doc[v2]=[]
stem_raw_to_doc[v2].append(i)
#各stem_romaを含む文書がなにかのリスト
stem_roma_to_doc = {}
for i, v1 in enumerate(stem_roma):
for v2 in v1:
if v2 not in stem_roma_to_doc: stem_roma_to_doc[v2]=[]
stem_roma_to_doc[v2].append(i)
self.threshold_ed = 0.3
self.threshold_w2v = 0.7
self.documents = documents
self.stem_raw=stem_raw
self.all_stem_raw = remove_unknown_w2v_word(all_stem_raw)#w2vの辞書にない単語は除いておく
self.stem_roma = stem_roma
self.all_stem_roma = all_stem_roma
self.stem_raw_to_doc = stem_raw_to_doc
self.stem_roma_to_doc = stem_roma_to_doc
#基本使わないが念の為
self.stem_raw2roma = stem_raw2roma
self.stem_roma2raw = stem_roma2raw
#類義語辞書と文中単語を一括管理する辞書を作って、メンバーに保存する
def set_synonym2correct(self, synonym_obj, doc_words_raw, doc_words_roma):
#文中に登場する単語はそのまま
synonym2correct_raw = {v:[v] for v in doc_words_raw}
synonym2correct_roma = {v:[v] for v in doc_words_roma}
synonym2correct_raw_tmp = get_synonym_to_correct_word_dict(synonym_obj)
for k,v in synonym2correct_raw_tmp.items():
if k in synonym2correct_raw: continue
synonym2correct_raw[k]=v
synonym2correct_roma_tmp = jp_dict_to_roma(synonym2correct_raw_tmp)
for k,v in synonym2correct_roma_tmp.items():
if k in synonym2correct_roma: continue
synonym2correct_roma[k]=v
self.synonym2correct_raw = synonym2correct_raw
self.synonym2correct_roma = synonym2correct_roma
self.synonym2correct_keys_roma = list(synonym2correct_roma.keys())
self.synonym2correct_keys_raw = remove_unknown_w2v_word(list(synonym2correct_raw.keys()))
#検索対象文書中または類義語リストから編集距離に基づいて該当する物を選ぶ
def get_ed_based_correct_word(self, word):
words2 = self.synonym2correct_keys_roma
words = get_ed_based_correct_word(word, words2, self.threshold_ed)
return words
#複数単語をまとめてうけつけ
def get_ed_based_correct_words(self, words):
words = [self.get_ed_based_correct_word(w) for w in words]
words = list(set(chain.from_iterable(words)))
return words
#word2vecの類似度が近い単語を検索するラッパー関数、w1は漢字かな交じり
def get_w2v_based_correct_word(self, word):
words2 = self.synonym2correct_keys_raw
words = get_w2v_based_correct_word(word, words2, self.threshold_w2v)
words = [self.stem_raw2roma[w] for w in words] #出力はローマ字で
return words
#複数単語をまとめてうけつけ
def get_w2v_based_correct_words(self, words):
words = [self.get_w2v_based_correct_word(w) for w in words]
words = list(set(chain.from_iterable(words)))
return words
#ローマ字のsynonymを正しい単語になおす。もともと文中に含まれる単語の場合はそのまま
def synonyms_to_correct_words(self, words):
words = [self.synonym2correct_roma[w] for w in words if w in self.synonym2correct_roma]
words = list(set(chain.from_iterable(words)))
return words
#ローマ字同士で一致するキーワードをベースに文書を検索
#類義語リストに含まれる単語は検索可能な単語に直してから文書検索
def find_document_simple(self, words):
words = self.synonyms_to_correct_words(words)#synonymがあれば正しい単語に直す
documents = self.documents
stem_roma_to_doc = self.stem_roma_to_doc
docs = []
cnt = {}
max_cnt = 0
for w in words:
if w not in stem_roma_to_doc: continue
doc_ids = stem_roma_to_doc[w]
for i in doc_ids:
cnt[i]=cnt.get(i,0)+1
if cnt[i] > max_cnt:
max_cnt = cnt[i]
docs = [documents[i]]
elif cnt[i] == max_cnt:
docs.append(documents[i])
return docs
#編集距離で補正できる単語が存在している場合は直してから文書検索
def find_document_using_ed(self, words):
words = self.get_ed_based_correct_words(words)
return self.find_document_simple(words)
def find_document_using_w2v(self, raw_words):
words = self.get_w2v_based_correct_words(raw_words)
return self.find_document_simple(words)
def find_document(self, text):
text = format_text(text)
raw_words = get_raw_stem(text)
if len(raw_words) == 0: return []
words = [jp2roma(v) for v in raw_words]
docs = self.find_document_simple(words)
if len(docs) > 0: return docs
docs = self.find_document_using_ed(words)
if len(docs) > 0: return docs
raw_words = remove_unknown_w2v_word(raw_words)
docs = self.find_document_using_w2v(raw_words)
if len(docs) > 0: return docs
return []
#類義語のリストを返す
documents = [
'MeCabとPythonで日本語の発音をカナで取得する関数を作りました。例えば、「今日はよく寝ました」と入力したら「キョーワヨクネマシタ」と返すようなものです。',
'日本語(カタカナ文字列)をモーラ単位で分かち書き(モーラ分かち書き)するpythonの関数を作りました。日本語の音韻の代表的な分割単位としてモーラと音節があります。モーラはいわゆる俳句の「5・7・5」を数えるときの区切り方で、長音(ー)、促音(ッ)、撥音(ン)も1拍と数えます。それに対し、音節では長音、促音、撥音は単体で数えられず、直前の単一で音節となれるカナと合わせてひとつの拍と見なされます。',
'日本語(カタカナ文字列)を音節単位で分かち書き(音節分かち書き)するpythonの関数を作りました。日本語の音韻の代表的な分割単位としてモーラと音節があります。モーラはいわゆる俳句の「5・7・5」を数えるときの区切り方で、長音(ー)、促音(ッ)、撥音(ン)も1拍と数えます。それに対し、音節では長音、促音、撥音は単体で数えられず、直前の単一で音節となれるカナと合わせてひとつの拍と見なされます。「バーン」のように長音、促音、撥音が連続した場合は3以上のモーラ数で1音節となります。',
'Pythonで、単語(品詞)の単位ではなく、文節単位で分かち書き(下記表の一番下の行)する関数をつくりました。',
'「100兆の階乗の25兆桁目の数字を求めよ(表記は10進法)」という問題をpythonで解いてみました。(元ネタは「100兆の階乗の右から数えて25兆番目にある数字は偶数であるか奇数であるか」という有名な問題です)',
'MeCabのデフォルト辞書をmecab-ipadic-NEologdに変更するために、自分の環境(macOS)でやったことのメモです。多くの方がすでに書いてくださっているように、デフォルト辞書はmecabrcというファイルを編集すると変更できるのですが、自分の環境だとmecabrcが複数あってどれを編集すればよいか迷ったので、どのファイルを編集すべきかの調べ方も含めて、実際にうまくできた方法について書いています。'
]
synonym_dict = {
"うるさい": ["騒々しい", "騒がしい", "やかましい", "爆音"],
"分割": ["区切り", "区切る", "分ける", "分かれる"],
"代表的": ["代表", "中心的"]
}
normalizer = Normalizer(documents, synonym_dict)
while True:
print("入力> ",end="")
text = input()
docs = normalizer.find_document(text)
for d in docs:
disp = d
if len(d)>20: disp = d[:20]+"..."
print(disp)
print("")
#結果
ターミナル上でいろいろ入力してみたところ、出力は以下のようになりました。
表記ゆれに対してもある程度頑健に対応できています。
入力> 階乗
「100兆の階乗の25兆桁目の数字を求め...
入力> かいぞう
「100兆の階乗の25兆桁目の数字を求め...
入力> モーラで区切る
日本語(カタカナ文字列)をモーラ単位で分...
入力> もうらでくぎる
日本語(カタカナ文字列)をモーラ単位で分...
日本語(カタカナ文字列)を音節単位で分か...
入力> ぶんかつ
日本語(カタカナ文字列)をモーラ単位で分...
日本語(カタカナ文字列)を音節単位で分か...
入力> 右
「100兆の階乗の25兆桁目の数字を求め...
入力> 左
「100兆の階乗の25兆桁目の数字を求め...
#課題
長めの文章をひらがな、またはカタカナのみで入力されるとうまく形態素解析ができず、検索精度が出ません。
検索対象の文書が増えてくると、検索時間が長くなってしまうかも知れません。
doc2vecによる検索を試して見ても良いかなと思いました。