word2vecを用いた検索アルゴリズム【python】

#概要
単語の完全一致だけでなく、類義語でもヒットさせられることを期待して、word2vecを用いた検索アルゴリズムを作りました。結果としては、word2vecの情報だけでは満足する精度にはなりませんでしたが、期待していた効果がでそうな気配はあるので、他の方法と組み合わせることは有用と感じました。
#環境
macOS Catalina 10.15.4
python 3.8.0
#word2vecの学習
詳細なやり方は他記事に譲ります。
wikipediaの全文をダウンロードし、mecab-ipadic-neologdで分かち書きし、gensimでword2vecの学習を行いました。
学習時のコードは下記です。[Wikipediaコーパスを使った，word2vecのモデル作成 | 接着剤の精進日記](https://bondo.hateblo.jp/entry/2018/05/14/085406)よりコードを拝借しました。

```Python
from gensim.models import word2vec
import logging

logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
sentences = word2vec.Text8Corpus('./wiki_wakati_neologd.txt')

model = word2vec.Word2Vec(sentences, size=200, min_count=20, window=15)
model.save("./wiki_neologd.model")
```

#検索アルゴリズム
入力文と検索対象の文書リストの類似度を次の方法で計算し、もっとも類似度の高い文章を得ます。

* 文書Aと文書Bから自立語（非自立でない名詞、非自立でない動詞、形容詞、副詞）を抽出する
* 文書Aの各自立語に対して、文書Bに含まれる最も類似度の高い自立語を探す
* 各最高類似度の平均値を文書Aと文書Bの類似度とする
* 文書Aを入力文、文書Bを検索対象文書の各文章として、入力文ともっとも類似度の高い文章を結果として出力する。

上記を実現するコードは下記のとおりです。

```Python
import re
import neologdn
import MeCab, jaconv
import pandas as pd
from gensim.models import word2vec

#word2vecモデルの読み込み
MODEL_PATH = "wiki_neologd.model"
model = word2vec.Word2Vec.load(MODEL_PATH)
#形態素解析用object
m = MeCab.Tagger()
#正規表現処理用object
re_kana = re.compile('[a-zA-Z\u3041-\u309F]')
re_num = re.compile('[0-9]+')

#形態素解析前の文書正規化用関数
def format_text(text):
  text = neologdn.normalize(text)
  return text

#文章の自立語の原型を抽出する関数、英数字１文字は除く
def extract_words(text):
  words = []
  for token in m.parse(text).splitlines()[:-1]:
    if '\t' not in token: continue

    surface = token.split('\t')[0]
    pos = token.split('\t')[1].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_document_db(documents):
  id_list = list(range(len(documents)))
  words_list = []
  for d in documents:
    d = format_text(d)
    words = extract_words(d)
    #重複除く
    words = set(words)
    #word2vecにない単語は除いておく
    words = [w for w in words if w in model.wv.vocab]
    words_list.append(words)
  db = pd.DataFrame({"id":id_list, "text":documents, "word":words_list})
  return db

#単語リストAと単語リストBの類似度を返す
def calc_similarity(words1, words2):
  total = 0
  for w1 in words1:
    if w1 not in model.wv.vocab: continue
    max_sim = 0
    for w2 in words2:
      if w2 not in model.wv.vocab: continue
      sim = model.wv.similarity(w1=w1, w2=w2)
      max_sim = max(max_sim, sim)
    total += max_sim
  return total

#入力文とdbの各文書の類似度を計算して、返す
def add_similarity_to_db(text, db):
  text = format_text(text)
  words1 = extract_words(text)
  #重複除く
  words1 = set(words1)
  words1 = [w for w in words1 if w in model.wv.vocab]
  similarity = []
  for words2 in db.word:
    sim = calc_similarity(words1, words2)
    similarity.append(sim)

  similarity_df = pd.DataFrame({"similarity": similarity})
  db2 = pd.concat([db,similarity_df], axis=1)
  
  db2 = db2.sort_values(["similarity"], ascending=False)
  db2 = db2.reset_index()
  return db2

#出力確認
#検索対象の文書集合
#一要素一文書として、文書のリストを作ってください。ここでは例として自分の過去記事から適当に抜粋した文章を使っています。
documents = [
  'MeCabとPythonで日本語の発音をカナで取得する関数を作りました。例えば、「今日はよく寝ました」と入力したら「キョーワヨクネマシタ」と返すようなものです。',
  '日本語（カタカナ文字列）をモーラ単位で分かち書き（モーラ分かち書き）するpythonの関数を作りました。日本語の音韻の代表的な分割単位としてモーラと音節があります。モーラはいわゆる俳句の「５・７・５」を数えるときの区切り方で、長音（ー）、促音（ッ）、撥音（ン）も１拍と数えます。それに対し、音節では長音、促音、撥音は単体で数えられず、直前の単一で音節となれるカナと合わせてひとつの拍と見なされます。',
  '日本語（カタカナ文字列）を音節単位で分かち書き（音節分かち書き）するpythonの関数を作りました。日本語の音韻の代表的な分割単位としてモーラと音節があります。モーラはいわゆる俳句の「５・７・５」を数えるときの区切り方で、長音（ー）、促音（ッ）、撥音（ン）も１拍と数えます。それに対し、音節では長音、促音、撥音は単体で数えられず、直前の単一で音節となれるカナと合わせてひとつの拍と見なされます。「バーン」のように長音、促音、撥音が連続した場合は３以上のモーラ数で１音節となります。',
  'Pythonで、単語（品詞）の単位ではなく、文節単位で分かち書き（下記表の一番下の行）する関数をつくりました。',
  '「100兆の階乗の25兆桁目の数字を求めよ(表記は10進法）」という問題をpythonで解いてみました。（元ネタは「100兆の階乗の右から数えて25兆番目にある数字は偶数であるか奇数であるか」という有名な問題です)',
  'MeCabのデフォルト辞書をmecab-ipadic-NEologdに変更するために、自分の環境（macOS）でやったことのメモです。多くの方がすでに書いてくださっているように、デフォルト辞書はmecabrcというファイルを編集すると変更できるのですが、自分の環境だとmecabrcが複数あってどれを編集すればよいか迷ったので、どのファイルを編集すべきかの調べ方も含めて、実際にうまくできた方法について書いています。'
]

db = get_document_db(documents)
input_texts = ["日本語を区切って分ける","階乗の計算","日本語をモーラで区切る", "日本語を音節で区切る"]
for text in input_texts:
  print(text)
  result = add_similarity_to_db(text, db)
  for sim, d in list(zip(result.similarity, result.text))[:10]:
    disp = d
    if len(d)>20: disp = d[:20]+"..."
    print(sim, disp)

```

出力は下記です。文書集合が少なく、例としてわかりにくいですが、前半２つは一応それっぽい結果にはなっています。
後半２つは、モーラ分かち書きと音節分かち書きに対して全く同じスコアになってしまいました。モーラと音節が今回の文書集合では共起してしまっているためと思われます。
tfidfなどを使って各文書における単語の重みを計算に含めることで、マシになる可能性がありますが、その検証は今後の課題としたく思います。

```
日本語を区切って分ける
2.593316972255707 日本語（カタカナ文字列）をモーラ単位で分...
2.593316972255707 日本語（カタカナ文字列）を音節単位で分か...
1.6599590480327606 MeCabとPythonで日本語の発音を...
1.5144233107566833 MeCabのデフォルト辞書をmecab-...
1.4240807592868805 Pythonで、単語（品詞）の単位ではな...
1.18932443857193 「100兆の階乗の25兆桁目の数字を求め...
階乗の計算
1.4738755226135254 「100兆の階乗の25兆桁目の数字を求め...
1.1860262751579285 MeCabとPythonで日本語の発音を...
1.1831795573234558 日本語（カタカナ文字列）をモーラ単位で分...
1.1831795573234558 日本語（カタカナ文字列）を音節単位で分か...
1.1831795573234558 Pythonで、単語（品詞）の単位ではな...
0.7110081613063812 MeCabのデフォルト辞書をmecab-...
日本語をモーラで区切る
3.0 日本語（カタカナ文字列）をモーラ単位で分...
3.0 日本語（カタカナ文字列）を音節単位で分か...
1.754945456981659 MeCabとPythonで日本語の発音を...
1.6068530082702637 Pythonで、単語（品詞）の単位ではな...
1.226668268442154 MeCabのデフォルト辞書をmecab-...
1.1506744921207428 「100兆の階乗の25兆桁目の数字を求め...
日本語を音節で区切る
3.0 日本語（カタカナ文字列）をモーラ単位で分...
3.0 日本語（カタカナ文字列）を音節単位で分か...
1.862914353609085 MeCabとPythonで日本語の発音を...
1.6907644867897034 Pythonで、単語（品詞）の単位ではな...
1.2761026918888092 MeCabのデフォルト辞書をmecab-...
1.2211730182170868 「100兆の階乗の25兆桁目の数字を求め...
```