Word2VecでElasticsearchのSynonymを生成する

Elasticsearchには、あらかじめ設定されたSynonym(シノニム/同義語/類義語)で検索を行える機能があります。
今回は、このSynonymの設定を自動的に生成した手順を紹介します。

目次

環境

概要

blog.linkode.co.jp

Elasticsearch のアナライザをカスタマイズするで、Synonymの設定を簡単に解説しましたが、この設定を何もないところから手作業で作成するのは大変そうです。
そこで、テンプレートになるものや、自動で生成できる方法がないかを模索していました。
今回紹介する方法は、一定のノイズは出るものの、プログラムによって作成できるため、何もないところから手作業で作るよりも初回の導入コストを軽減できます。

Word2Vec

Synonymを抽出するためにWord2Vecを使用しました。 Word2VecについてはWeb上の情報も豊富でさまざまなエントリがあります。
簡単には名前の通り、単語をベクトルで表現する手法です。 ベクトル化を行って、あるベクトル(単語)が別のベクトルとどれだけ同じ方向を向いているかを計算することで、単語同士の類似度合いがわかります。
方向は、2つのベクトルの成す角のコサインで求めることができ、これをコサイン類似度といいます。

Wikipediaデータベースのダンプ

抽出元になる日本語のコーパスには、日本語Wikipediaのダンプを使用しました。
Wikipedia不定期にデータベースのダンプの生成と提供が行われています。 詳細はこちらをご覧ください。 https://ja.wikipedia.org/wiki/Wikipedia:%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89

Wikipediaのダンプからテキストの抽出

以下のページから、最新の日本語Wikipediaのダンプを取得します。 https://dumps.wikimedia.org/jawiki/latest/

たくさんリンクがありますが、今回はすべての記事が含まれる、次のデータを使用しました。 jawiki-latest-pages-articles-multistream.xml.bz2

記事作成時点では、圧縮されている状態で3GB以上のファイルになっていました。
圧縮されているXMLファイルから、記事のテキストを抽出したいので、WikiExtractorという、Wikipediaデータベースのダンプからテキストを抽出できるPythonスクリプトを使用します。

WikiExtractorは直接スクリプトを呼び出して使用できます。

python WikiExtractor.py jawiki-latest-pages-articles-multistream.xml.bz2

コマンドを実行すると、textというディレクトリが作成されて、次のような構造でファイルが出力されていきます。

text/
├─AA/
│ ├─wiki_00
│ ├─wiki_01
│ :
│ └─wiki_99
├─AB/
├─AC/
:
├─/AZ
├─/BA
: :

各ファイルの内容は、いくつかのWikipediaページのURL、タイトル、記事、となっていることが確認できます。
オプションで何も指定しない場合は、wiki_**ファイルは1MB単位で区切られるようです。

テキストの分かち書き

記事の文章そのままでは単語のベクトル化が行えないので、前処理として、記事の文章を分かち書き(単語と単語の間へ半角スペースを挿入)して、1つのファイルにまとめます。

単語単位の分解を行う形態素解析器として、今回はSudachiPyを使用しました。
リポジトリREADMEにある通り、インストールは以下のコマンドで行なえます。

pip install sudachipy sudachidict_core

以下のようなPythonスクリプトで、WikiExtractorで出力されたtextディレクトリ内を走査して、wiki_**のテキストを分かち書きしつつ、jawiki-wakachigaki.txtというファイルへ出力します。

step01_wakachi.py

# -*- coding: utf-8 -*-
import os
import codecs
from sudachipy import tokenizer 
from sudachipy import dictionary
  
tokenizer_obj = dictionary.Dictionary().create()
split_mode = tokenizer.Tokenizer.SplitMode.C
  
def convert(dst, src_filename):
    """ src_filename のファイルを読み込み、半角空白で分かち書きしたものを dst へ書き込む.
    """
  
    print('Convert:[%s]' % src_filename)
  
    f = codecs.open(src_filename, 'r', 'utf-8')
    lines = f.readlines()
    f.close()
  
    for line in lines:
        if line.strip() == '':
            dst.write('\n')
        else:
            token_array = [m.surface() for m in tokenizer_obj.tokenize(line, split_mode)]
            cnv_line = ' '.join(token_array) + '\n'
            dst.write(cnv_line)
  
    print('Complete:[%s]' % src_filename)
  
def main():
  
    out = codecs.open('jawiki-wakachigaki.txt', 'w', 'utf-8')
  
    # `text`ディレクトリ内のファイルを走査
    for root, _, files in os.walk('text'):
        for file in files:
            file_path = os.path.join(root, file)
            convert(out, file_path)
  
    out.close()
  
if __name__ == "__main__":
    main()

Word2Vecの学習済みモデル作成

前処理ができたので、Word2Vecで単語のベクトル化を行った学習済みモデルを作成します。
言語処理でよく使われるアルゴリズムを含むPythonのライブラリgensimを使用します。

SudachiPyと同様、pipでインストールできます。

pip install gensim

Word2Vecの学習済みモデルを作成するスクリプトは以下の通りです。
モデル作成を行っている、word2vec.Word2Vecのパラメーターの詳細についてはAPIリファレンスをご確認ください。
また、loggingモジュールのbasicConfig関数で、ログのフォーマットとレベルを設定することで、モデル作成の進捗が確認できます。

step02_model.py

# -*- coding: utf-8 -*-
from gensim.models import word2vec
from logging import basicConfig, INFO
  
def main():
  
    sentences = word2vec.Text8Corpus('jawiki-wakachigaki.txt')
  
    model = word2vec.Word2Vec(sentences, size=100, min_count=20, window=15)
    model.save("jawiki.model")
  
if __name__ == "__main__":
  
    # 出力するログのフォーマットとレベルを設定
    basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=INFO)
  
    main()

Synonymファイルの作成

モデルの作成ができたら、モデルを基にSynonymファイルの生成を行います。

Pythonスクリプトは以下の通りです。

step03_synonym.py

# -*- coding: utf-8 -*-
import codecs
from gensim.models.word2vec import Word2vec
from sudachipy import tokenizer 
from sudachipy import dictionary
  
  
def main():
  
  t_obj = dictionary.Dictionary().create()
  mode = tokenizer.Tokenizer.SplitMode.C
  
  # 同義語(類義語)とみなす閾値(最大1.0)
  SIMILARTY_THRESHOLD = 0.95
  
  model = Word2Vec.load("jawiki.model")
  wv = model.wv # Word2VecKeyedVectors
  keys = wv.vocab.keys()
  
  out = codecs.open('synonym.txt', 'w', 'utf-8')
  
  for key in keys:
  
    # 1文字以下の単語はスキップ
    if not (len(key) > 1):
      continue
  
    # key の品詞を取得
    tokens = t_obj.tokenize(key, mode)
    pos = tokens[0].part_of_speech()
  
    # 名詞以外はスキップ
    if pos[0] != '名詞':
      continue
  
    # 数詞はスキップ
    if pos[1] == '数詞':
      continue
  
    results = wv.most_similar(positive=[key])
    # 閾値以上の単語を synonym_array へ追加
    synonym_array = []
    for result in results:
      if result[1] >= SIMILARTY_THRESHOLD:
        synonym_array.append(result[0])
  
    # 1つ以上類似判定された単語があれば書き込む
    if synonym_array != []:
      syn = ', '.join(synonym_array)
      out.write(key + ', ' + syn + '\n')
      print('key:[%s], synonym[%s]' % (key, syn))
  
  out.close()
  
  
if __name__ == "__main__":
  main()
 
 
 
 
 
 
 
 
 
 
 
 
コサイン類似度の閾値を 0.95 に設定して
この値以上の単語のみ抽出します。
 
モデルファイル jawiki.model から、学習できた
単語を keys として取り出しています。
 
 
 
 
 
 
1文字だけのもの、名詞以外のもの、
また、名詞の中でも数詞は除外します。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
most_similar で類似の単語を取得します。
most_similar 関数には類似した単語の
上位いくつまでを返却するかを指定できる
topn というデフォルト引数があり、
デフォルト値は 10になっています。
result は単語とコサイン類似度のタプルです。
 
 
1つ以上、類似と判定できた単語があれば
ファイルへ書き込みを行います。
 
 
 
 
 
 
 
 

結果のsynonym.txtを確認してみると、コサイン類似度で近しい言葉を集めているので、厳密には同義語とは言えないものの、似た文脈で使われそうな言葉が収集できていました。
中には対義語も混じっています。

世紀, 世紀末
いくつ, 幾つ
前述, 先述
ヶ所, カ所, ヵ所
起き, 起こっ
みなし, 見なし
イギリス英語, アメリカ英語
アメリカ英語, イギリス英語
すべて, 全て
みなさ, 見なさ, 見做さ
西部, 東部
東部, 西部, 北部
南部, 北部
カ国, か国, ヶ国, ヵ国
か国, カ国, ヶ国
国連, 国際連合
減少, 増加
増加, 減少
全て, すべて
目途, 目処
佐藤, 鈴木
見なし, みなし
...

初めは厳密に同義語に絞り込む必要がある、と思い込んでいました。
ただ、この結果を見たときに、近しい文脈で使われる言葉が集められたのであれば用途によっては検索にヒットしやすくなるのではないかと考えるようになりました。

Elasticsearchへの適応

『Elasticsearch のアナライザをカスタマイズする』で紹介したSynonymの設定と同様に、Elastecsearchへ適応します。

ただ、まったく同じアナライザーの設定のままではIllegal Argument Exceptionが発生する可能性が高いです。
Exceptionの原因はさまざまなのですが、たとえば

%, パーセント

というsynonymは、前処理にあたる、Character filtersによって、が除去されてしまっている、という旨のレスポンスが返却されます。

他にも

東京芸術大学, 東京藝術大学

がエラーになることを確認しました。 東京芸術大学SydachiPyのモードtokenizer.Tokenizer.SplitMode.Cでは、ひとつの形態素として扱われていたものが、kuromoji_tokenizerでは複数の形態素に分解されてしまうためエラーとなってしまっているようです。

これらを解決するには、たとえばフィルターの設定を見直したり、ElasticsearchのSudachiプラグインを導入して形態素解析器器のモードを統一するなどの方法があります。
またそれでもエラーが起きる場合、Elasticsearchは問題が起きているsynonymファイルの行数を丁寧にレスポンスしてくれるので、エラーが起きている箇所を確認し、ひとつずつ修正していく必要があります。

所感

膨大な同義語を手作業で収集する事は避けたいと考えて、今回の調査・検証を行いました。
最終的にはやはり人の手が必要でメンテナンスして育てていく必要があるということを痛感したのですが、それでもスクラッチで設定するよりもコストが減らせているのではないかと思います。
Wikipedia以外のコーパスでも同じことが行えそうですので、商品検索、医療、エンターテインメント等、サービスの目的に応じてコーパス形態素解析器を選択することで検索の精度が上げられるのではないでしょうか。

参考ドキュメント・記事