直感 Deep Learning: 5章メモ(単語分散表現)(その3)

以下の本を読みます。

直感 Deep Learning ―Python×Kerasでアイデアを形にするレシピ直感 Deep Learning ―Python×Kerasでアイデアを形にするレシピ
Antonio Gulli Sujit Pal 大串 正矢

オライリージャパン 2018-08-11
売り上げランキング : 1992

Amazonで詳しく見る
by G-Tools
キャラクターの原作とは関係ありません。本読みメモですがよく脱線します。誤りがありましたらご指摘いただけますと幸いです。
前回:その2 / 次回:まだ
f:id:cookie-box:20180305232608p:plain:w60

5章の5.3節まで読みましたが、5.4節は実際に単語分散表現を学習する場合はこれらのようなやり方があるといった紹介なので、手を動かしてみた方がよさそうですね。そもそも、ここまで登場した単語分散表現モデルも、手元で実装してみませんか?

f:id:cookie-box:20180305231302p:plain:w60

じゃあまず Skip-gram からだな。142~144ページにコードが載ってるからこれを実行するだけ…あれっ、ど初っ端の一行目から、keras.layers から Merge という名前のオブジェクトはインポートできませんって言われたんだけど! どういうことなの!?

f:id:cookie-box:20180305232608p:plain:w60

何? keras のバージョンの違いでしょうか…この本の要求バージョンは xii ページによると 2.1.6 ですね。プロデューサーさんのマシンに入っているのは…2.2.2 ですね。

f:id:cookie-box:20180305231302p:plain:w60

えーどうする? 勝手にプロデューサーの環境の keras のバージョン下げる?

f:id:cookie-box:20180305232608p:plain:w60

それはやめた方がいいんじゃ…。そもそも、keras.layers.Merge でやりたかったのは中心語の数値ベクトルと文脈語の数値ベクトルの内積を取ることですよね…keras Merge で検索すると、keras.layers に Dot というそれそのもののレイヤーがありますよ。試してみると…ちゃんと内積が取れますね。

# -*- coding: utf-8 -*-
import numpy as np
from keras.layers import Dot, Input
from keras.models import Model

x0 = Input(shape=(3,))
x1 = Input(shape=(3,))
y = Dot(axes=-1)([x0, x1])
model = Model(inputs=[x0, x1], outputs=y)
model.compile(loss='mean_squared_error', optimizer='adam') # dummy
print(model.summary())

x0_samples = np.array([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [1.0, 2.0, 3.0]])
x1_samples = np.array([[1.0, 1.0, 1.0], [2.0, 2.0, 2.0], [3.0, 3.0, 3.0]])
y_samples = model.predict([x0_samples, x1_samples])
print(y_samples) # --> [[6.0], [12.0], [18.0]]

これを応用すれば Skip-gram モデルは容易に実装できるはずです。せっかくなのでクラスにしてみましょう。Skip-gram が正例か負例か判定するモデルなので、SkipGramDiscriminator という名前にしてみました。

# -*- coding: utf-8 -*-
import numpy as np
from keras.layers import Dot, Input, Dense, Reshape, Embedding
from keras.models import Model

class SkipGramDiscriminator():
    def __init__(self, vocab_size, embed_size):
        self.vocab_size = vocab_size # 語彙数
        self.embed_size = embed_size # 埋め込み次元数
    def create_model(self):
        # 中心語ID --> 中心語数値ベクトル表現
        x0 = Input(shape=(1,))
        y0 = Embedding(self.vocab_size, self.embed_size,
                       embeddings_initializer='glorot_uniform')(x0)
        y0 = Reshape((self.embed_size,))(y0)
        self.word_embedder = Model(x0, y0)
        # 文脈語ID --> 文脈語数値ベクトル表現
        x1 = Input(shape=(1,))
        y1 = Embedding(self.vocab_size, self.embed_size,
                       embeddings_initializer='glorot_uniform')(x1)
        y1 = Reshape((self.embed_size,))(y1)
        self.context_embedder = Model(x1, y1)
        # 内積 --> ロジスティック回帰
        y = Dot(axes=-1)([y0, y1])
        y = Dense(1, kernel_initializer='glorot_uniform', activation='sigmoid')(y)
        self.discriminator = Model(inputs=[x0, x1], outputs=y)
        self.discriminator.compile(loss='mean_squared_error', optimizer='adam')
        print(self.discriminator.summary())
    
if __name__ == '__main__':
    sg = SkipGramDiscriminator(6, 3) # i,love,green,eggs,and,ham 6語を3次元空間へ埋込
    sg.create_model()
    x0_samples = np.array([[1], [4], [1], [4], [2]]) # 中心語: love,and,love,and,green
    x1_samples = np.array([[0], [5], [2], [2], [2]]) # 文脈語: i,ham,green,green,green
    y_samples = sg.discriminator.predict([x0_samples, x1_samples])
    print(y_samples) # 中心語と文脈語のペアであるかどうかの判定結果(学習まだ)

    # 中心語の数値ベクトル表現は中心語の Embedding 層の重みそのもの
    print(sg.word_embedder.get_weights())

    # IDから数値ベクトル表現を取り出せることの確認
    print(sg.word_embedder.predict([[0]])) # i の数値ベクトル表現
    print(sg.word_embedder.predict([[1]])) # love の数値ベクトル表現
# 中心語と文脈語のペアであるかどうかの判定結果(学習まだ)
[ [0.45034274]
  [0.4851311 ]
  [0.5297962 ]
  [0.5046127 ]
  [0.41040125] ]
# 中心語の数値ベクトル表現は中心語の Embedding 層の重みそのもの
[array([ [-0.3271621 , -0.14325774, -0.4473939 ],
        [-0.63613635,  0.71521103,  0.3045839 ],
        [-0.4950593 , -0.3844918 , -0.79637474],
        [ 0.52346075, -0.1862371 , -0.66979444],
        [-0.79966545, -0.35221177,  0.17136681],
        [ 0.13642508, -0.48409775, -0.34481126] ], dtype=float32)]
# IDから数値ベクトル表現を取り出せることの確認
[ [-0.3271621  -0.14325774 -0.4473939 ] ]
[ [-0.63613635  0.71521103  0.3045839 ] ]

まだ学習データを準備していないので正しく学習できるか試行できていませんが、モデル構造は意図通りなのではないでしょうか。

f:id:cookie-box:20180305231302p:plain:w60

そっか、Skip-gram の学習データって、コーパス内の単語に ID を割り振って、各IDのペアに正例か負例かのラベルを貼らないといけないんだよな。いちいち手でやってらんないし、学習データを生成する関数も書いた方がよさそうだな。

f:id:cookie-box:20180305232608p:plain:w60

それについては、143ページで紹介されている keras の skipgrams 関数がそれをやってくれるのでしょうね。ただ、英語コーパスを使用する場合なら本の通りでよさそうですが、もし日本語コーパスを使用したいならもっと特別な前処理が必要でしょうから、その方法を自分で調べる必要がありそうですね。今回は学習データに何をつかいましょうか?


2018-10-14 追記
f:id:cookie-box:20180305231302p:plain:w60

もうめんどくさいから機関車トーマスでいいよ。

f:id:cookie-box:20180305232608p:plain:w60

トーマスの原著(英語)があればいいのですが、手元には日本語版しかないので、ではやはり日本語用のプレ処理をしましょう。以下のリンクを参考にしました。

MeCab形態素解析結果を用いて、Skip-grams を愚直に書くなら以下のようになると思うんですよね。書いたクラスは、文章のリストを渡すと1文ずつ形態素解析して、未登録の単語を登録しながら文章をID列に変換して、Skip-grams の正例をひたすら集めています。ただ、負例をどのように準備すべきかがわからないんですよね。もちろん、正例にないIDのペアは負例なのですが…。keras の skipgrams 関数ではどのようにサンプリングされているのか確認したいですね。skipgrams 関数の入力はID列のようなので、単語にIDを割り振るまではプレ処理して後は任せるのがいいのかもしれませんが。

# -*- coding: utf-8 -*-
import MeCab
import codecs

class SkipGramCollector():
    def __init__(self, window_size):
        self.window_size = window_size # 周辺の何単語を考慮するか
        self.tagger = MeCab.Tagger('-Owakati')
        self.raw_words = []
        self.center_words = []
        self.context_words = []
    def _process_one_sentence(self, sentence):
        words = self.tagger.parse(sentence).split(' ') # 文 --> 単語列
        id_seq = [] # 単語列 --> ID列
        for word in words:
            if word in ['、', '「', '」', '\n']:
                continue # とりあえず無視
            if word not in self.raw_words:
                self.raw_words.append(word)
            index = self.raw_words.index(word)
            id_seq.append(index)
        n_words = len(id_seq)
        for id in id_seq: # 中心語と文脈語のペアの正例を登録
            for context_id in range(id - self.window_size, id):
                if -1 < context_id:
                    self.center_words.append(id)
                    self.context_words.append(context_id)
            for context_id in range(id + 1, id + self.window_size + 1):
                if context_id < n_words:
                    self.center_words.append(id)
                    self.context_words.append(context_id)
    def process_sentences(self, sentences):
        for sentence in sentences:
            if sentence == u"\r\n":
                continue
            self._process_one_sentence(sentence)

if __name__ == '__main__':
    split_sentence = None
    f = codecs.open('thomas_the_tank_engine.txt', 'r', 'utf-8') # 改行なしのテキスト
    for line in f:
        split_sentence = line.split(u'。')

    sgc = SkipGramCollector(2)
    sgc.process_sentences(split_sentence)

    # 最初の10単語をプリント
    print(sgc.raw_words[0:10])
    # 最初の10個の正例をプリント
    for i in range(10):
        print(sgc.raw_words[sgc.center_words[i]], ',', 
              sgc.raw_words[sgc.context_words[i]])
['機関', '車', 'トーマス', 'は', '大きな', '駅', 'で', 'はたらい', 'て', 'い']
機関 , 車
機関 , トーマス
車 , 機関
車 , トーマス
車 , は
トーマス , 機関
トーマス , 車
トーマス , は
トーマス , 大きな
は , 車

f:id:cookie-box:20180305231302p:plain:w60

…なあジュン、「機関車」が「機関」と「車」に分けられるのってどうなの? 機関車は機関車だろ。

f:id:cookie-box:20180305232608p:plain:w60

それは僕も結構そう思ったんですよね…。


2018-10-22 追記
f:id:cookie-box:20180305232608p:plain:w60

まあ形態素解析が多少意図通りでない点は置いておいて、Mecab形態素解析だけして、後は keras に任せた例が以下です。日本語テキストについて Skip-grams の正例も負例も得られていますね。ここでは「機関車トーマス」の最初の見開き3ページ分の文章を使いました。周辺の何単語を考慮するかの window_size は 1 にしています。skipgrams 関数は全ての正例をサンプリングし、デフォルトでは正例と同数の負例をランダムサンプリングしてくれるようですね(正例の何倍の数の負例をサンプリングするかは引数 negative_samples で調整できます)。

# -*- coding: utf-8 -*-
from keras.preprocessing.text import *
from keras.preprocessing.sequence import skipgrams
import MeCab
import codecs
import numpy as np
import pandas as pd

if __name__ == '__main__':
    tagger = MeCab.Tagger('-Owakati')
    
    # list_text に文章たちを格納(ただし、各文章は単語ごとに半角空白区切りになった状態)
    list_text = []
    f = codecs.open('thomas_the_tank_engine.txt', 'r', 'utf-8') # 改行なしのテキスト
    for line in f:
        list_text_raw = line.split('。')
        for text_raw in list_text_raw:
            text = tagger.parse(text_raw)
            list_text.append(text)
    
    # 半角空白区切りの文章たちを Tokenizer に渡して含まれる全単語を収集
    tokenizer = Tokenizer(filters='、。「」\n\r')
    tokenizer.fit_on_texts(list_text)
    
    # 単語-->ID辞書と、ID-->単語辞書を作成
    word2id = tokenizer.word_index
    id2word = {v:k for k, v in word2id.items()}
    
    # 文章ごとに Skip-gram 対を得る
    df = pd.DataFrame()
    for text in list_text:
        # 文章をID列化
        wids = [word2id[w] for w in text_to_word_sequence(text, filters='、。「」\n\r')]
        # ID列から Skip-gram 対を得る
        pairs, labels = skipgrams(wids, len(word2id), window_size=1)
        df_ = pd.DataFrame()
        df_['x0'] = np.array([pair[0] for pair in pairs]).astype(np.int64) # 中心語
        df_['x1'] = np.array([pair[1] for pair in pairs]).astype(np.int64) # 文脈語
        df_['y'] = np.array(labels).astype(np.int64) # 正解ラベル
        df = pd.concat([df, df_])
    
    df.reset_index(drop=True, inplace=True)
    print(df.shape)
    
    # 負例にサンプリングされた Skip-gram 対のうち正例に含まれているものを除去 (?)
    df_dup_check = df[df.duplicated(keep=False) & (df.y == 0)] # 複数ある対であって負例
    df_dup_check = df_dup_check[df_dup_check.duplicated() == False].copy()
    for index, row in df_dup_check.iterrows():
        df_temp = df[(df.x0 == row['x0']) & (df.x1 == row['x1'])]
        if np.sum(df_temp.y == 1) > 0: # 正例があるのでこの対の負例からは削除する
            df_temp = df_temp[df_temp.y == 0]
            df = df.drop(index=df_temp.index)
    df.reset_index(drop=True, inplace=True)
    print(df.shape)
    
    # 結果確認
    print("正例の数:", np.sum(df.y == 1))
    print("負例の数:", np.sum(df.y == 0))
    for index, row in df.iterrows():
        print("({:s} ({:d}), {:s} ({:d})) -> {:d}".format(
          id2word[row['x0']], row['x0'], id2word[row['x1']], row['x1'], row['y']))
        if index == 10:
            break
(444, 3)
(442, 3)
正例の数: 222
負例の数: 220
(は (3), ぼく (65)) -> 0
(大きな (13), 駅 (21)) -> 1
(まし (10), た (4)) -> 1
(駅 (21), 大きな (13)) -> 1
(まし (10), さ (69)) -> 0
(駅 (21), ピッピー (20)) -> 0
(はたらい (22), とおく (33)) -> 0
(トーマス (8), つい (30)) -> 0
(車 (7), からっぽ (39)) -> 0
(で (14), 駅 (21)) -> 1
(い (9), まし (10)) -> 1

ここで1点取り扱いがわからなかったのですが、本の143ページでは1つの文章のみから Skip-gram 対を抽出していますが、実際のコーパスには文章が複数含まれているはずです。その場合、1文1文ずつ skipgrams 関数を適用すると、負例としてサンプリングされる Skip-gram 対が実は他の文章では正例であったということがあるかもしれません。上のスクリプトでは、まずは1文1文ずつ全ての文章に対して Skip-gram 対を得て、最後にもし正例にもなっている負例が含まれていれば除去するということをしています(ので、最終的な正例の数と負例の数が微妙にずれています)。 しかし、どう処理するのが一般的なのかわかりません。

f:id:cookie-box:20180305231302p:plain:w60

ふーん? まあでも、学習データができたんなら早速学習してみようぜ!

f:id:cookie-box:20180305232608p:plain:w60

そうですね。例えば上のスクリプトに対して、上で実装したニューラルネットワークを読み込んで、抽出した学習データをそのまま渡せば学習できますよ。

# -*- coding: utf-8 -*-
from keras.preprocessing.text import *
from keras.preprocessing.sequence import skipgrams
import MeCab
import codecs
import numpy as np
import pandas as pd
from skip_gram_discriminator import SkipGramDiscriminator # 上で実装したクラスを読み込み

if __name__ == '__main__':
    # ここの部分のスクリプトは省略

    sg = SkipGramDiscriminator(len(word2id), 3) # 3次元に埋め込む場合
    sg.create_model()
    sg.discriminator.fit([df.x0.values, df.x1.values], df.y.values,
                         batch_size=32, epochs=100)

(次回があれば)つづく