2017年9月16日土曜日

WikipediaのデータをWord2Vecに

今更ですが、WikipediaのデータをWord2Vecで遊んでみます。
わざわざ書き起こすのは、メモしておかないと、遊び方を忘れてしまうためです・・・。

今回の環境は下記のような感じです。
  • CentOS 7.4
  • MeCab 0.996
MeCabとneologdは以前の記事を参照していただくか、groongaなどのリポジトリからインストールしておいてください。

準備

今回もgit wgetを用意しておくのと、Pythonの3.xの環境を用意しておきます。word2vecをコンパイルするのにgccも必要です。MeCaabをソースから入れた場合は、すでに入っているものもあると思いますので、その場合は読み飛ばしてください。
yum install git wget epel-release
yum install python34 python34-pip
yum install gcc

Wikipediaのわかち書きテキストを作成する

Wikipediaのデータの切り出しには「Wikipedia_Extractor」というツールを使います。 gitで落としてきます。
mkdir src
cd ~/src/
git clone https://github.com/attardi/wikiextractor
cd wikiextractor/
Wikipediaのデータは公開されているので、ダウンロードします。100万件以上、2.4GB分の記事があります。
wget https://dumps.wikimedia.org/jawiki/latest/jawiki-latest-pages-articles.xml.bz2
落としてきたファイルは、圧縮されたXMLなのですが、テキストに近い形式とするため先ほどの「Wikipedia_Extractor」を使用します。なお、落としてきたファイルは解凍する必要はありません。下記のように指定すると、解凍してタグを除去して5Mごとに分割してくれます。
mkdir corpus
python3 WikiExtractor.py -b 5M -o corpus jawiki-latest-pages-articles.xml.bz2
実行すると下記のように処理が完了したWikipediaのページの名称とページのIDが表示されます。
INFO: 313154    クラーク郡 (イリノイ州)
INFO: 313155    ジョン・マルコヴィッチ
INFO: 313156    カート・ラッセル
INFO: 313157    モスリン
INFO: 313158    マイク・マイヤーズ
INFO: 313160    大阪府道197号深井畑山宿院線



Wikipediaの日本語データを正規化を行うため、mecab-ipadic-neologdのページから正規化用のPythonコードをコピーしてきて「normalize_neologd.py」という名前で保存します。
一応下にも書いておきますが・・・neologdのサイトを確認したほうが良いと思います。
ファイル: normalize_neologd.py
# encoding: utf8
from __future__ import unicode_literals
import re
import unicodedata

def unicode_normalize(cls, s):
    pt = re.compile('([{}]+)'.format(cls))

    def norm(c):
        return unicodedata.normalize('NFKC', c) if pt.match(c) else c

    s = ''.join(norm(x) for x in re.split(pt, s))
    s = re.sub('-', '-', s)
    return s

def remove_extra_spaces(s):
    s = re.sub('[  ]+', ' ', s)
    blocks = ''.join(('\u4E00-\u9FFF',  # CJK UNIFIED IDEOGRAPHS
                      '\u3040-\u309F',  # HIRAGANA
                      '\u30A0-\u30FF',  # KATAKANA
                      '\u3000-\u303F',  # CJK SYMBOLS AND PUNCTUATION
                      '\uFF00-\uFFEF'   # HALFWIDTH AND FULLWIDTH FORMS
                      ))
    basic_latin = '\u0000-\u007F'

    def remove_space_between(cls1, cls2, s):
        p = re.compile('([{}]) ([{}])'.format(cls1, cls2))
        while p.search(s):
            s = p.sub(r'\1\2', s)
        return s

    s = remove_space_between(blocks, blocks, s)
    s = remove_space_between(blocks, basic_latin, s)
    s = remove_space_between(basic_latin, blocks, s)
    return s

def normalize_neologd(s):
    s = s.strip()
    s = unicode_normalize('0-9A-Za-z。-゚', s)

    def maketrans(f, t):
        return {ord(x): ord(y) for x, y in zip(f, t)}

    s = re.sub('[˗֊‐‑‒–⁃⁻₋−]+', '-', s)  # normalize hyphens
    s = re.sub('[﹣-ー—―─━ー]+', 'ー', s)  # normalize choonpus
    s = re.sub('[~∼∾〜〰~]', '', s)  # remove tildes
    s = s.translate(
        maketrans('!"#$%&\'()*+,-./:;<=>?@[¥]^_`{|}~。、・「」',
              '!”#$%&’()*+,-./:;<=>?@[¥]^_`{|}〜。、・「」'))

    s = remove_extra_spaces(s)
    s = unicode_normalize('!”#$%&’()*+,-./:;<>?@[¥]^_`{|}〜', s)  # keep =,・,「,」
    s = re.sub('[’]', '\'', s)
    s = re.sub('[”]', '"', s)
    return s

if __name__ == "__main__":
    assert "0123456789" == normalize_neologd("0123456789")
    assert "ABCDEFGHIJKLMNOPQRSTUVWXYZ" == normalize_neologd("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
    assert "abcdefghijklmnopqrstuvwxyz" == normalize_neologd("abcdefghijklmnopqrstuvwxyz")
    assert "!\"#$%&'()*+,-./:;<>?@[¥]^_`{|}" == normalize_neologd("!”#$%&’()*+,-./:;<>?@[¥]^_`{|}")
    assert "=。、・「」" == normalize_neologd("=。、・「」")
    assert "ハンカク" == normalize_neologd("ハンカク")
    assert "o-o" == normalize_neologd("o₋o")
    assert "majikaー" == normalize_neologd("majika━")
    assert "わい" == normalize_neologd("わ〰い")
    assert "スーパー" == normalize_neologd("スーパーーーー")
    assert "!#" == normalize_neologd("!#")
    assert "ゼンカクスペース" == normalize_neologd("ゼンカク スペース")
    assert "おお" == normalize_neologd("お             お")
    assert "おお" == normalize_neologd("      おお")
    assert "おお" == normalize_neologd("おお      ")
    assert "検索エンジン自作入門を買いました!!!" == \
        normalize_neologd("検索 エンジン 自作 入門 を 買い ました!!!")
    assert "アルゴリズムC" == normalize_neologd("アルゴリズム C")
    assert "PRML副読本" == normalize_neologd("   PRML  副 読 本   ")
    assert "Coding the Matrix" == normalize_neologd("Coding the Matrix")
    assert "南アルプスの天然水Sparking Lemonレモン一絞り" == \
        normalize_neologd("南アルプスの 天然水 Sparking Lemon レモン一絞り")
    assert "南アルプスの天然水-Sparking*Lemon+レモン一絞り" == \
        normalize_neologd("南アルプスの 天然水- Sparking* Lemon+ レモン一絞り")

さらに、パイプで繋げられるように標準入力で入ってきたデータを正規化できるように微妙に小細工を入れつつラッパーを作ります。
ファイル: conv.py
import sys
import normalize_neologd as nneo
import re
for line in sys.stdin:
    line = line.replace("。","。\n")
    line = re.sub(r'<[^\]]*>', '', line)
    print(nneo.normalize_neologd(line))

先ほどまでに分解したWikipediaのデータをマージして正規化して分かち書きにします。ここまでの手順で、wikipediaの正規化されたプレーンテキストが手に入りました。
cat corpus/*/* | python3 conv.py | mecab -b 32768 -Owakati -d /usr/local/lib/mecab/dic/mecab-ipadic-neologd > wiki.mecab.txt
mecabのデフォルトのバッファ8192Byteだと「input-buffer overflow. The line is split. use -b #SIZE option.」というエラーが出ますので、-bで32768を指定するとエラーが出なくなりました。実際にはもう少し小さくてもいいのかもしれません。

word2vecをコンパイル

取ってきてmakeします。できた実行ファイルは手動でPATHの通ったところにコピーします。
cd ~/src
git clone https://github.com/dav/word2vec
cd word2vec/src
make
cp -p ../bin/* /usr/local/bin/.



学習とパラメータの概要

ここまで来たら、実行するだけです。下記のコマンドで学習を開始します。
cd ~/src/wikiextractor/
time word2vec -train wiki.mecab.txt -output wiki.mecab.word2vec.size200.bin -size 200 -window 5 -sample 1e-5 -negative 5 -binary 1
学習結果は-outputで指定し、wiki.mecab.binに保存されます。

オプションについて詳しく知りたい場合は、本家word2vecの説明Mikolov氏の論文など有用な情報がいっぱいころがってますのでそちらを読んで確認してください。

ここでは、ざっくりとした概要だけ。
オプション 内容
-train 学習する元となるファイルを指定します。今回は作成したファイルを指定します。
-size ベクトルの次元数を指定します。サンプル同様200を指定しましたが、wikipediaのデータを基とする場合400とか600の方が良いかもしれません。4GBのメモリだと300程度が限界だと思います。400を指定すると途中で強制終了しましたので、急遽仮想マシンのメモリを8GBで割り当てなおしました。
-window 関連性があると判断する前後の単語数。デフォルトのskip-gramなら10くらい。CBOWなら5を指定すればいいようだ。
-sample 出現回数の多い単語を学習の結果から除外するための閾値です。(1e-3〜1e-5の範囲で指定)これは、MeCabで分かち書きをした結果を見ればわかりますが、「の」や「が」や「ある」などどうでもよい語が頻出するためで、このようなものを学習内容から除外することができるみたいです。このQiitaの記事が大変分かりやすかったです。
-negative 指定した数の単語の関連性を低く設定します。低く設定されるのはWindowで指定した値から外れた単語のうちランダムに選ばれた単語になります。(5-10くらい。使わない場合は0を指定。)
-binary 1に指定すると結果をバイナリ形式で保存します。


Distanceで似た単語を探す

さっそく実行してみます。
distance wiki.mecab.word2vec.size200.bin
Enter word or sentence (EXIT to break): Python

Word: Python Position in vocabulary: 24421

Word Cosine distance
-------------------------------------
Perl 0.925127
Java 0.910050
Ruby 0.898116
C++ 0.892958
JavaScript 0.890242
プログラミング言語 0.885551
ライブラリ 0.884654
スクリプト言語 0.879306
PHP 0.878212
Lua 0.876160
C# 0.875964
コンパイラ 0.870334
VB.NET 0.861130
ソースコード 0.860082
C言語 0.858654
C/C++ 0.857195
統合開発環境 0.850669
処理系 0.849689

ふむ。
Enter word or sentence (EXIT to break): C言語

Word: C言語 Position in vocabulary: 16918

Word Cosine distance
-------------------------------------
コンパイラ 0.919928
プログラミング言語 0.915335
C++ 0.909260
処理系 0.893756
C/C++ 0.888579
インタプリタ 0.886861
バイトコード 0.885650
FORTRAN 0.880558
Smalltalk 0.878671
プリプロセッサ 0.877056
Java 0.874642
Haskell 0.873704
Perl 0.873273
Lua 0.873125
D言語 0.871268
高級言語 0.867297
Objective-C 0.867137
結構いい結果が得られているんじゃないでしょうか・・・。

word-analogyで似たベクトルの単語を探す


「A の時は B、C の時は何?」といった関連性が類似する(ベクトルの方向性が同じ)ものを答えてくれます。
日本の時は東京、アメリカの時は?というような内容に答えてくれます。
先ほどの学習結果が使えますので、新たな学習は必要ありません。


word-analogy wiki.mecab.word2vec.size200.bin
Enter three words (EXIT to break): 日本 東京 アメリカ

Word: 日本 Position in vocabulary: 38001
Word: 東京 Position in vocabulary: 348
Word: アメリカ Position in vocabulary: 172

Word Distance
------------------------------------------------------------------------
ニューヨーク 0.658165
アナザー・カントリー 0.626435
フォー・ソーシャルリサーチ 0.611632
米国 0.611108
シンシナティー 0.610880
きちんと正解が1番最初に出てきました。

Enter three words (EXIT to break): ボストン アメリカ バルセロナ

Word: ボストン Position in vocabulary: 4817
Word: アメリカ Position in vocabulary: 172
Word: バルセロナ Position in vocabulary: 7925

Word Distance
------------------------------------------------------------------------
スペイン 0.733546
マドリード 0.644633
イタリア 0.634427
ブラジル 0.631908
フランス 0.630570
これも正しい回答が一番最初に表示されました。

Enter three words (EXIT to break): 日本 東京 中国

Word: 日本 Position in vocabulary: 55
Word: 東京 Position in vocabulary: 348
Word: 中国 Position in vocabulary: 329

Word Distance
------------------------------------------------------------------------
北京 0.674370
上海 0.644645
杭州 0.610560
清華大学 0.595791
天津 0.593921
こういうロケーションにかかる部分は得意みたいです。
ほとんど、間違えなく答えが出てきます。

次に失敗してしまったパターンを・・・
Enter three words (EXIT to break): コンピュータ CPU 人間

Word: コンピュータ Position in vocabulary: 1816
Word: CPU Position in vocabulary: 4492
Word: 人間 Position in vocabulary: 500

Word Distance
------------------------------------------------------------------------
ラグズ 0.632038
ベオク 0.616553
破壊衝動 0.605122
ヤセイ 0.604995
スラード 0.604204
ソウルジェム 0.604126
ノドス 0.597569
凶暴性 0.595487
神殺し 0.592517
悪の力 0.590891
イリシッド 0.590625
卑小 0.590289
生命力 0.589909
紅世の王 0.589552
ケンク 0.587762
天空聖者 0.587749
虚無 0.583919
ゾラーク・ゾラーン 0.581554
大魔王バーン 0.581542
シャダーカイ 0.580933
レオモン 0.580893
ビシャモン 0.580755
人間らしい 0.580128
血肉 0.579877
獣性 0.579627
ええと、ほとんどなに言ってるのかわからないんですけど・・・。
これは、脳と答えてほしかった;;
なんとか、「血肉」が答えらしいでしょうかね・・・。

近いベクトルの単語を探してくるだけですので、もう少し次元を増やしたりすることで何とかなるのかもしれません。

Word2Phrase

これは、フレーズを検出してくれるプログラムです。フレーズとして扱うべきものを「_」で繋げることによって、word2vecでは単語として認識されます。例えば、分かち書きで「日本」「語」と2つの語に分割されてしまったものを「_」で繋げて「日本_語」とすることで、1つの単位として扱います。

複数回実行することでより長い単語を連結することができますが、本来はコードに手を入れた方が良いであろうことと、回数を重ねすぎると連結してほしくないところまで連結し始めますので、注意が必要です。
ではやってみます。

1回目

time word2phrase -train wiki.mecab.txt -output wiki.mecab.phrases.txt -threshold 500 -debug 2
Starting training using file wiki.mecab.txt
Words processed: 449700K Vocab size: 39165K
Vocab size (unigrams + bigrams): 22711828
Words in train file: 449716604
Words written: 449700K
real 113m6.868s
user 39m28.540s
sys 25m15.209s
出来上がったテキストファイルを開いてみると、下記のような単語が見つかりました。分かち書きでは「イスラム」「圏」と2単語に分かれてしまいましたが、word2phraseで「_」で連結されていることが確認できます。
アルバニア_語
古代_ギリシャ語
ポルトガル語_圏
イスラム_圏
体性感覚_野
日本語_学習者
出来上がったテキストを、もう一度word2phraseにかけてみます。

2回目

time word2phrase -train wiki.mecab.phrases.txt -output wiki.mecab.phrases2.txt -threshold 500 -debug 2
Starting training using file wiki.mecab.phrases.txt
Words processed: 437300K Vocab size: 43443K
Vocab size (unigrams + bigrams): 25057778
Words in train file: 437363941
Words written: 437300K
real 130m48.335s
user 27m41.600s
sys 47m3.203s
先ほどからさらに連結された単語の長さが増えていることが確認できます。「GHOST_IN_THE_SHELL/攻殻機動隊」を統計で認識できるのはすごい!と思いました。
十_六_進
千_数_百
カダイ_語_族
上位_10位_以内
被_修飾_語
WBC_世界_ヘビー級_王者
数_百_万_円
GHOST_IN_THE_SHELL/攻殻機動隊
単行_本_未_収録
大阪_芸術大学_芸術学部_映像
法起寺_式_伽藍_配置
武王_伐_紂_平話
先ほど作成されたテキストを、さらにもう一度word2phraseにかけてみます。

3回目

time word2phrase -train wiki.mecab.phrases2.txt -output wiki.mecab.phrases3.txt -threshold 500 -debug 2
Starting training using file wiki.mecab.phrases2.txt
Words processed: 433600K Vocab size: 44808K
Vocab size (unigrams + bigrams): 25779937
Words in train file: 433666534
Words written: 433600K
real 166m28.564s
user 27m51.933s
sys 64m7.211s
ちょっと、やりすぎ感が出てきました。そこまで連結しなくとも機械学習で認識できるであろう物と関連性のない単語が連結されている部分が増えてきたように思います。2回目の「十_六_進」までは良いと思うけど、「十_六_進_表記」まで連結する必要はないでしょう・・・。
十_六_進_表記
家庭_用_電気_機械_器具
オーストリア_福音主義_教会_アウクスブルク信仰告白_派
全世界_ウェイト_制_空手道_選手権_大会
大阪_芸術大学_芸術学部_美術_学科
全国_公営_競馬_主催者_協議会_会長
ここで出来上がったファイルをもとにword2vecで学習させると、また異なった結果が得られると思います。
「_」は必要ないので削ってしまってもよいのかもしれません。

0 件のコメント:

コメントを投稿