け日記

SIerから転職したWebアプリエンジニアが最近のITのキャッチアップに四苦八苦するブログ

Word2Vecで京都観光に関するブログ記事の単語をベクトル化する

京都観光に関するブログ記事を使い、Word2Vecで単語のベクトル化します。
ベクトル化することで、例えば「紅葉」という言葉から紅葉の名所を列挙したり、「カップル」という言葉からデートコースを探したりできないか、というのを試みてみたいと思います。

Word2Vec

数万〜数十万ある単語をそれぞれ数百次元のベクトルに落とし込むことで、単語ごとの特徴量を得る手法です。

単語ごとにベクトルとして表現されるため、単語間の類似度計算(例えば"air"は"water"よりも"atomosphere"に似ているなど)や、単語どうしの加減算(例えばking-man+womanなど)が可能となります。

  • ベクトル化できれば、コサイン類似度などで類似度を算出できます

内部的には2層のニューラルネットワークを学習することでベクトルを得ています。 こちらの方の解説記事がとても参考になります(今回採用するSkip-Gramに関する記述もあります)。

qiita.com

入力(学習データ)は辞書である必要はなく、あらゆる文書を対象に学習できます。

京大ブログコーパス

類義語辞書のデータソースとして、京大ブログコーパスを使います。

これは、4つのテーマ(京都観光、携帯電話、スポーツ、グルメ)に関する249記事(合計4186文)のアノテーション(形態素、構文、格・省略・照応、評判情報)付きコーパスです。 京大と日本電信電話株式会社の共同プロジェクトにより作られました(ちなみにMeCabも同じプロジェクトの成果の1つです)。

http://nlp.ist.i.kyoto-u.ac.jp/kuntt/

リンクから、"解析済みブログコーパス"(KNBC_v1.0_090925.tar.bz2)をダウンロード・解凍し、得られたフォルダ(KNBC_v1.0_090925)をこれから作成するPythonファイルと同じフォルダにコピーします。

そして、KNBC_v1.0_090925/corpus2/Kyoto.tsvから記事毎の本文のみを抽出します(今回はアノテーションを使いません)。
Kyoto.tsvは1行1文となっており、例えば記事KN203であれば以下のように7文で構成されます。

KN203_Kyoto_1-1-1-01 [京都観光]鴨川                
KN203_Kyoto_1-1-2-01    最近、鴨川沿いを歩くことにはまってます☆    [著者]    鴨川沿いを歩くことにはまってます    採否+   鴨川沿いを歩くこと
KN203_Kyoto_1-1-3-01    鴨川沿いを歩くのはほんとに気持ちよくて、京都はいいところだなぁとしみじみ感じています。   [著者]\n[著者]  ほんとに気持ちよくて\nいいところだなぁとしみじみ感じています   感情+\n批評+    鴨川沿いを歩くの\n京都
KN203_Kyoto_1-1-3-02    虫がたまに口の中に入るのが難点なのですが・・  [著者]    虫がたまに口の中に入るのが難点なのですが・・  メリット- 虫がたまに口の中に入るの
KN203_Kyoto_1-1-4-01    でも、最近歩きすぎて、なんだか足に筋肉がついてきてる気が!?              
KN203_Kyoto_1-1-5-01    歩きすぎには要注意です。    [著者]    歩きすぎには要注意です   当為  歩きすぎ
KN203_Kyoto_1-1-6-01    とかいいつつ、今日もいい天気なので、時間をみつけていってこようと思います☆ [著者]    今日もいい天気なので、時間をみつけていってこようと思います 採否+   [鴨川沿いを歩くの]

Pythonでディクショナリ(texts)に記事毎の本文を抽出します。 合計72記事となりました。

# {'記事ID(KNxxx)': '本文'}
texts = {}

with open('./KNBC_v1.0_090925/corpus2/Kyoto.tsv', 'r', encoding='euc-jp') as f:
    # Kyoto.tsvファイルを読み込み
    kyoto_tsv = f.read()
    
    # 記事毎に本文を抽出する
    for line in kyoto_tsv.split('\n'):
        if line == '':
            break
        
        # Kyoto.tsvは1行1文となっており、各行は以下のようにタブ区切りされている
        # KN203_Kyoto_1-1-2-01 最近、鴨川沿いを歩くことにはまってます☆    [著者]    鴨川沿いを歩くことにはまってます    採否+   鴨川沿いを歩くこと
        columns = line.split('\t')
        # 各行の1番目のカラムにはKNxxx(記事ID)が付与されているので、記事ID毎に本文(2番目のカラム)を連結する
        # なお3番目以降のカラムはアノテーションで、今回は使用しないため、読み捨てる
        index = columns[0].split('_')[0]
        if not index in texts:
            texts[index] = ''
        texts[index] = texts[index] + columns[1]
    
print('記事数: {}'.format(len(texts)))
# 記事数: 72
print('本文(KN203): {}'.format(texts['KN203']))
# 本文(KN203): [京都観光]鴨川最近、鴨川沿いを歩くことにはまってます☆鴨川沿いを歩くのはほんとに気持ちよくて、京都はいいところだなぁとしみじみ感じています。虫がたまに口の中に入るのが難点なのですが・・でも、最近歩きすぎて、なんだか足に筋肉がついてきてる気が!?歩きすぎには要注意です。とかいいつつ、今日もいい天気なので、時間をみつけていってこようと思います☆

janome形態素解析

今回もjanome形態素解析します(janomeについては11/2の投稿で詳しく紹介しています)。

ただし、今回はNEologdを内包したjanomeを使うため、janomewikiを参考にインストールします。

(very experimental) NEologd 辞書を内包した janome をビルドする方法 · mocobeta/janome Wiki · GitHub

Janome-0.3.5.neologd20170828.tar.gzをダウンロードして、以下コマンドでインストールします。
使用時には Tokenizer(mmap=True) でTokenizerを生成します。

$ pip install Janome-0.3.5.neologd20170828.tar.gz --no-compile
$ python -c "from janome.tokenizer import Tokenizer; Tokenizer(mmap=True)"

それでは形態素解析分かち書きします。 名詞、形容詞、副詞の原型を抽出しています。

from janome.tokenizer import Tokenizer
from janome.charfilter import UnicodeNormalizeCharFilter, RegexReplaceCharFilter
from janome.tokenfilter import POSKeepFilter, LowerCaseFilter, ExtractAttributeFilter
from janome.analyzer import Analyzer

            
char_filters = [UnicodeNormalizeCharFilter(), # UnicodeをNFKCで正規化
                RegexReplaceCharFilter('\d+', '0')] # 数字を全て0に置換

tokenizer = Tokenizer(mmap=True) # NEologdを使う場合、mmap=Trueとする

token_filters = [POSKeepFilter(['名詞']), # 名詞のみを取得する
                 LowerCaseFilter(), # 英字は小文字にする
                 ExtractAttributeFilter('base_form')] # 原型のみを取得する

analyzer = Analyzer(char_filters, tokenizer, token_filters)

print('KN203から抽出した単語: ')
for token in analyzer.analyze(texts['KN203']):
    print(token)
# KN203から抽出した単語: 
# 鴨川
# 最近
# 鴨川
# 沿い
# こと
# 鴨川
# 沿い
# の
# 京都
# ところ
#  ・・・

ストップワードの除去

上で見たKN203もそうですが、「の」や「こと」、「京都」、「観光」などはほとんどの文書に現れます。

そのため、一定の頻度(ここでは25%以上)の文書に現れる単語はストップワードとして除去します。

# ストップワードの取得
def get_stopwords(texts_words):
    word_count = {}
    
    for words in texts_words.values():
        for word in set(words):
            if not word in word_count:
                word_count[word] = 0
            word_count[word] += 1
            
    # 18(25%)以上の文書に含まれる単語をストップワードとする
    return {k for k, v in word_count.items() if v >= len(texts_words) * 0.25}


# 文書毎に形態素解析で分かち書きされた単語リストを持つ
texts_words = {}
for k, v in texts.items():
    texts_words[k] = list(analyzer.analyze(v))

stopwords = get_stopwords(texts_words)
print('stop words: {}'.format(stopwords))
# stop words: {'時間', '人', '観光', '京都', '的', 'それ', '何', 'こと', 'とき', '気', '中', 'もの', '前', '京都観光', '0', 'さ', 'ところ', 'ん', 'よう', '私', 'これ', 'の', '一'}

Word2Vecの入力ファイルの生成

前処理の最後に、Word2Vecで読み込む入力ファイルを生成します。

Word2Vecは、1行1文書で、かつ、各行は単語ごとに半角スペースで区切られているテキストファイルとなっている必要があります。
"prepared_text.txt"に書き出しています。

preprocessed_texts = {}

for k, v in texts_words.items():
    preprocessed_texts[k] = ' '.join([w for w in v if w not in stopwords])

print('前処理後(KN203): {}'.format(preprocessed_texts['KN203']))
# 前処理後(KN203): 鴨川 最近 鴨川 沿い 鴨川 沿い 虫 口 難点 最近 足 筋肉 すぎ 要注意 今日もいい天気

with open('prepared_text.txt', 'w') as f:
    f.write('\n'.join(preprocessed_texts.values()))

これで準備完了です。

Word2Vecを使う

それではWord2Vecでモデルを構築していきます。

トピック分析のライブラリとして有名なgensimを使います。
gensimのトピック分析モデルの一つとしてWord2Vecも組み込まれています(他にもLDAなどが内包されています)。 いつもどおり、 $ pip install gensim でインストールしておきます。

radimrehurek.com

Word2Vecのモデル構築は2ステップです。
ベクトル化のアルゴリズムとしてCBoWとSkip-Gramが選択できます。 データセットが小さくても良い性能が出ると評価されているSkip-Gramを使います。

  1. word2vec.LineSentenceでprepared_text.txtをコーパスにする
  2. word2vec.Word2Vecで作成したコーパスからモデルを構築する
    • sgは、ベクトル化のアルゴリズムを選択するオプションで、1の場合はSkip-Gramが選択される(デフォルトは0で、CBoWが選択される)
    • sizeは、ベクトルの次元数(デフォルトは100)
    • windowは、Skip-Gram使用時に前後いくつの単語を見るか定めるもの(デフォルトは5)
    • min_countは、出現頻度がこの値より少ない単語についてはベクトル化に含められないようになります(デフォルトは0)
from gensim.models import word2vec


# prepared_text.txtからコーパスを生成
sentences = word2vec.LineSentence('prepared_text.txt')

# モデル構築
model = word2vec.Word2Vec(sentences, sg=1, size=200, window=5, min_count=3)

それでは、いくつか京都観光に関する単語と、類似している単語上位10個を列挙してみます。

"紅葉"については、"貴船神社"、"嵐山"といった紅葉の名所との関連が強いことがわかります。 上手くいった例かと思います。

一方で、"カップル"は特に関連するような言葉は出てこない結果となりました。 "カップル"を含む元々の文書数が少ない("紅葉"が10に対して"カップル"は6)ことが影響しているようです。

similars1 = model.wv.most_similar(positive='紅葉', topn=10)
print(similars1)
# [('通り', 0.9995862245559692),
#  ('そう', 0.9995594024658203),
#  ('僕', 0.9995542764663696),
#  ('貴船神社', 0.9995498061180115),
#  ('道', 0.9995443820953369),
#  ('寺', 0.9995434880256653),
#  ('日', 0.99953693151474),
#  ('ため', 0.9995332956314087),
#  ('方', 0.9995331168174744),
#  ('嵐山', 0.9995298385620117)]

similars2 = model.wv.most_similar(positive='カップル', topn=10)
print(similars2)
# [('寺', 0.9993862509727478),
#  ('自転車', 0.999378502368927),
#  ('さん', 0.9993761777877808),
#  ('お寺', 0.9993686676025391),
#  ('水', 0.9993683695793152),
#  ('山', 0.9993564486503601),
#  ('そう', 0.9993509650230408),
#  ('通り', 0.9993289709091187),
#  ('雰囲気', 0.9993254542350769),
#  ('時', 0.9993242025375366)]

文書数が少ないため、類義語辞書を簡単に作るといったWord2Vecの真骨頂を出し切れていない感じはありますね。。。

岩波データサイエンス Vol.2

岩波データサイエンス Vol.2