TadaoYamaokaの開発日記

個人開発しているスマホアプリや将棋AIの開発ネタを中心に書いていきます。

doc2vecでWikipediaを学習する

先日の日記でTF-IDFでFAQに回答することを試したが、TF-IDFでは質問文の類似度を単語の頻度に重み付けをして測っている。
そのため、単語が完全に一致している必要があり、同じ意味の単語でも異なる単語として認識してしまう。

word2vecを使用すると単語をベクトル化することができ、意味が近ければ近いベクトルを出力することができる。
word2vecを文に適用する場合、単語ベクトルの平均をとる方法もあるが、語順が失われるという欠点がある。

doc2vecを使用すると、文の語順を考慮して、文自体をベクトル化することができる。

doc2vecには、PV-DMとPV-DBOWの2種類があり、PV-DMとPV-DBOWのベクトルを組み合わせて使用することで精度を上げることができる。

PV-DMは、文と単語にユニークな固定次元のベクトルを割り当て、文の単語列をウィンドウサイズ幅ごとに抽出し、文のベクトルを追加して、それらの平均(または連結)をとったベクトルから、次に現れる単語を多クラス分類により予測するように学習する。
その際、単語ベクトルも同時に学習される。

PV-DBOWは、文のベクトルから、文中からランダムに抽出した単語を予測するように学習する。

PV-DMで、学習したモデルを使用して予測する時には、単語ベクトルモデルは固定して、文のベクトルのみを学習することで、文のベクトルを出力する。
予測のたびに、ランダムに初期化したベクトルから学習するため、毎回結果が異なることに注意する必要がある。

ここでは、gensimのdoc2vecの実装を使用して、日本語のWikipediaの全記事を使って、PV-DMのモデルを学習することを試みる。

環境

Wikipediaの全記事の取得

https://dumps.wikimedia.org/jawiki/latest/
ここから、jawiki-latest-pages-articles.xml.bz2をダウンロードする。
記事はXML形式になっている。

XMLから文章のみを抽出する

WikiExtractorを使用してXMLから文章のみを抽出する。

git clone https://github.com/attardi/wikiextractor.git
python wikiextractor/WikiExtractor.py -b 500M -o path/to/corpus jawiki-latest-pages-articles.xml.bz2

path/to/corpusディレクトリに、500Mごとにwiki_00 wiki_01 wiki_02 wiki_03 wiki_04が作成されるので、1つのファイルに連結する。

MinGWなどのcatコマンドを使用して、

cat wiki_00  wiki_01  wiki_02  wiki_03  wiki_04 >wiki

分かち書きする

MeCabを使用して分かち書きする。

mecab -O wakati -d <辞書> wiki -o wiki_wakati

辞書にはmecab-ipadic-NEologdを使用した。Windowsでの使用方法はこちらの日記を参照。

頻度の少ない単語を除外する

そのまま学習するとメモリが24GBあっても不足して学習できなかった。
文の数が多いと読み込みの際メモリ不足するのと、前処理で文を減らして読み込めた後でも単語数が多いとメモリが不足する。
そこで、前処理として、頻度の少ない単語を含む文自体を削除することにした。
単語の頻度は5以下でもメモリが不足したので、頻度が10以下の単語を含む文を除外した。

以下のようなスクリプトで、頻度の低い単語を抽出する。入力は分かち書き済みのコーパス

import argparse
from collections import defaultdict

parser = argparse.ArgumentParser()
parser.add_argument('input')
parser.add_argument('output')
args = parser.parse_args()

words = defaultdict(lambda: 0)
for line in open(args.input, "r", encoding="utf-8"):
    line = line.strip()
    if line == "" or line[0] == "<":
        continue
    for word in line.split(" "):
        words[word] += 1

print("word num : ", len(words))

few_word_num = 0
with open(args.output, "w", encoding="utf-8") as f:
    for word in words:
        if words[word] <= 10:
            few_word_num += 1
            f.write(word)
            f.write("\n")

print("few word num : ", few_word_num)

単語数は以下の通りとなった。

総単語数 2,822,644
頻度10以下 2,311,196
残り単語数 511,448

頻度の少ない単語の一覧を使って、文を削除する。入力は分かち書き済みのコーパス

import argparse
import re

parser = argparse.ArgumentParser()
parser.add_argument('input')
parser.add_argument('few_words')
parser.add_argument('output')
args = parser.parse_args()

few_words = set()
for line in open(args.few_words, "r", encoding="utf-8"):
    dst = re.sub(r'([\[\](){}\\*+.?^$\-|])', r'\\\1', line.strip())
    if dst in ("", "「", "」", "、", "。", "(", ")"):
        continue
    few_words.add(dst)

f = open(args.output, "w", encoding="utf-8")
for line in open(args.input, "r", encoding="utf-8"):
    if line.strip() == "" or line[0] == "<":
        continue
    found = False
    words = line.strip().split(" ")
    if len(words) <= 2:
        continue
    for word in words:
        if word in few_words:
            found = True
            break
    if found:
        continue
    f.write(line)
f.close()

doc2vecで学習する

前処理を行ったコーパスを使用してdoc2vecでPV-DMのモデルを学習する。
学習する単位は、行の単位とした。
gensimのdoc2vecのデフォルトでは長さが1の単語は除外されるが、日本語では長さが1の単語があるため対象とした。

以下のようなスクリプトで学習できる。

import gensim
import smart_open
import argparse

parser = argparse.ArgumentParser()
parser.add_argument('input', type=str)
parser.add_argument('--save_model', '-s', default='model', type=str)
args = parser.parse_args()

def read_corpus(fname):
    with smart_open.smart_open(fname, encoding="utf-8") as f:
        for i, line in enumerate(f):
            # For training data, add tags
            yield gensim.models.doc2vec.TaggedDocument(gensim.utils.simple_preprocess(line, min_len=1), [i])


train_corpus = list(read_corpus(args.input))

model = gensim.models.doc2vec.Doc2Vec(size=300, min_count=10, iter=55)

model.build_vocab(train_corpus)
model.train(train_corpus, total_examples=model.corpus_count, epochs=model.iter)

model.save(args.save_model)

ベクトルの次数は300、イテレーション回数は55とした。
元の論文では次数に400が使われているので、400とした方が良かったかもしれない。

学習には、2時間41分かかった。
保存したモデルのサイズは、約5GBとなった。

学習済みモデルを使用して類似単語を調べる

doc2vecにより単語ベクトルも学習されるため、学習済みモデルを使用して類似単語を調べることができる。

類似単語は以下のようにして調べる。

import gensim

model = gensim.models.Doc2Vec.load("model")
model.most_similar("リンゴ")
実行例
model.most_similar("リンゴ")
Out: 
[('ワイン', 0.6092509627342224),
 ('コーヒー', 0.6062097549438477),
 ('ケーキ', 0.595314621925354),
 ('トマト', 0.592261552810669),
 ('花', 0.5917296409606934),
 ('果物', 0.5882657766342163),
 ('バナナ', 0.5871806144714355),
 ('酒', 0.5837405920028687),
 ('ビール', 0.5820529460906982),
 ('牛乳', 0.5812638401985168)]

足し算もできる。

model.most_similar(positive=["ザク", "ガンダム"])
Out: 
[('ms', 0.7314956188201904),
 ('モビルスーツ', 0.6884805560112),
 ('x', 0.664232075214386),
 ('マシン', 0.6640591621398926),
 ('モンスター', 0.662639856338501),
 ('機体', 0.651045024394989),
 ('ロボット', 0.6460052132606506),
 ('戦車', 0.6428899168968201),
 ('戦闘機', 0.6382737159729004),
 ('cpu', 0.6371015906333923)]

引き算もできる。

model.most_similar(positive=["猫"], negative=["フレンズ"])
Out: 
[('人間', 0.6333577036857605),
 ('子供', 0.6314771175384521),
 ('動物', 0.6307740807533264),
 ('親', 0.6073141098022461),
 ('家族', 0.5995410084724426),
 ('が', 0.5993083715438843),
 ('犬', 0.5975848436355591),
 ('も', 0.5944583415985107),
 ('何', 0.5930898785591125),
 ('を', 0.5907615423202515)]

学習済みモデルを使用して文の類似度を測る

以前の日記でTF-IDFで試した気象庁のFAQを使用して、入力した文に意味が近い質問文を予想する。

import gensim
import MeCab
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
import argparse

parser = argparse.ArgumentParser()
parser.add_argument('faq', type=str)
parser.add_argument('model', type=str)
parser.add_argument("--dictionary", "-d", type=str, help="mecab dictionary")
args = parser.parse_args()

mecab = MeCab.Tagger("-Owakati" + ("" if not args.dictionary else " -d " + args.dictionary))

model = gensim.models.Doc2Vec.load(args.model)

questions = []
answers = []
for line in open(args.faq, "r", encoding="utf-8"):
    cols = line.strip().split('\t')
    questions.append(gensim.utils.simple_preprocess(mecab.parse(cols[0]).strip(), min_len=1))
    answers.append(cols[1])

doc_vecs = []
for question in questions:
    doc_vecs.append(model.infer_vector(question))

while True:
    line = input("> ")
    if not line:
        break

    vec = model.infer_vector(gensim.utils.simple_preprocess(mecab.parse(line), min_len=1))
    sims = cosine_similarity([vec], doc_vecs)
    index = np.argsort(sims[0])

    print(questions[index[-1]])
    print()
    print(answers[index[-1]])
    print()

    print(questions[index[-2]])
    print(questions[index[-3]])
    print(questions[index[-4]])
    print()

以下のような文を入力すると、それぞれ予測した質問文(上位4つ)は以下のようになった。

> みぞれとは何ですか?
['フェーン現象', 'と', 'は', '何', 'です', 'か']
['大雨', 'の', '回数', 'は', '増え', 'て', 'いる', 'の', 'です', 'か']
['プレート', 'と', 'は', '何', 'です', 'か']
['エルニーニョ現象', 'ラニーニャ現象', 'が', '発生', 'する', 'と', '日本', '近海', 'の', '海面', '水温', 'は', 'どの', 'よう', 'に', 'なる', 'の', 'です', 'か']

> 天気予報が外れる理由は?
['週間', '天気予報', 'が', '外れる', 'こと', 'が', 'あり', 'ます', 'が', 'なぜ', 'です', 'か']
['週間', '天気予報', 'は', 'よく', '外れる', 'ので', '日', '先', 'くらい', 'の', '予報', 'だけ', 'で', '良い', 'の', 'で', 'は', 'ない', 'です', 'か']
['噴火警報', '火口', '周辺', '警報', '噴火', '予報', 'について', '教え', 'て', 'ください']
['海上', 'の', '台風', 'の', '中心', '気圧', 'は', 'どの', 'よう', 'に', '測っ', 'て', 'い', 'ます', 'か']

> 地震は予知できますか?
['花粉情報', 'は', '気象庁', 'で', '発表', 'し', 'て', 'い', 'ます', 'か']
['空', 'は', 'どうして', '青い', 'の', 'です', 'か', '夕焼け', 'は', 'どうして', '赤い', 'の', 'です', 'か']
['東海', '地域', 'に', 'は', 'どの', 'よう', 'な', '監視', '体制', 'が', 'とら', 'れ', 'て', 'い', 'ます', 'か']
['地震', 'の', '予知', 'は', 'でき', 'ます', 'か']

> 津波の規模によってどんな被害が起きるのですか?
['津波', 'の', '高さ', 'によって', 'どの', 'よう', 'な', '被害', 'が', '発生', 'する', 'の', 'です', 'か']
['特別警報', 'と', '既存', 'の', '記録的短時間大雨情報', 'の', '違い', 'は', '何', 'です', 'か', '廃止', 'さ', 'れ', 'たり', 'は', 'し', 'ない', 'の', 'です', 'か']
['テレビ局', 'によって', '天気予報', 'の', '内容', 'が', '違う', 'こと', 'が', 'ある', 'の', 'は', 'なぜ', 'です', 'か']
['検定', 'が', '必要', 'と', 'なる', '気象', '測', '器', 'に', 'は', 'どんな', 'もの', 'が', 'あり', 'ます', 'か']

> 高潮の発生の仕組みは?
['副振動', 'と', 'は', '何', 'です', 'か']
['花粉症記念日', '月', '日', 'と', 'は', 'なん', 'です', 'か']
['雪', 'は', 'どうして', 'できる', 'の', 'です', 'か']
['竜巻', 'は', 'どうして', '起きる', 'の', 'です', 'か']

> 高潮の発生の仕組みは?
['高潮', 'の', '発生', 'の', '仕組み', 'は']
['雲', 'が', '七', '色', 'に', '見える', '彩雲', 'の', '仕組み', 'は', '何', 'です', 'か']
['津波', 'は', 'どの', 'よう', 'な', '仕組み', 'で', '発生', 'する', 'の', 'です', 'か']
['竜巻', 'は', 'どうして', '起きる', 'の', 'です', 'か']

みぞれについて聞いているのに、「みぞれ」が現れない文を予測している。
「天気予報」、「外れる」と2つ単語を含む場合は、近い文を予測している。
地震」、「予知」では単語が2つでも4番目になっている。
津波の規模によってどんな被害が起きるのですか?」と長い文を入力すると、それなりの精度になるようだ。

また、「高潮の発生の仕組みは?」を2回予測すると、異なる結果が返っている。予測のたびにランダムな初期化から学習するため起きる。

以上のように、短い文については、あまり精度が高くないという結果になった。
ウィンドウサイズはデフォルトの5を使用しているので、キーとなる単語は少なくとも5個はあった方が良いかもしれない。

ベクトルの次元やウィンドウサイズやイテレーション回数を変えてみてどうなるかさらに検証が必要そうだ。

短い文での類似を測るには、word2vecのベクトルの平均を使用するなどdoc2vecとは別の方法が有効かもしれない。