直感 Deep Learning: 5章メモ(単語分散表現)(番外編)

以下の本を読みます。

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

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

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

前回 keras で Skip-gram 学習器をつくってみたけど、学習した結果ってどんな感じになるんだ?

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

それなんですが…当初「機関車トーマス」を題材にするつもりだったんですが、解析するために持ち出そうとすると小さい子が泣いちゃうんですよね…。

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

小さい子から絵本取り上げるなよ!

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

それに、元々テキストファイルになっていませんし…なので、題材を変えて、春名さんのセリフを解析することにします。春名さんにとってドーナツがどのように重要なのか手がかりが得られるかもしれませんし。

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

趣旨変わってる!?

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

春名さんのセリフはまとめられているページ(若里 春名 - アイドルマスターsideM Wiki*)からパースしてきたんですが、セリフ中のカタカナが半角になっているので以下のページのスクリプトを拝借しました。ただ、このスクリプトで半角の伸ばし棒が全角の伸ばし棒に変換できなかったので、それだけはテキストエディタ上で手でやりました。

今回 Beautiful Soup で td タグ中の文字列を収集したんですが、改行が含まれているセリフがパースできなかったんですよね。面倒なのでとばしました。セリフを csv に書き出して、この記事の最下部にあるスクリプト(※)を実行した結果が以下です。

0                                 楽しいことがないなら、自分で作る!
1 ジュンはコーヒーのコトはまだ許してないけど、勉強教えてくれるのはホント助かってる。ナツキは無...
2  なぁ、プロデューサー。オレ、バカだけど…やる気だけは本物だからさ。たまには思い出してくれよなっ。
Name: str, dtype: object
正例の数: 5021
負例の数: 4947
(9968, 3)

===== 単語登場回数 (ユニーク単語数:1280, 語数:5922) =====
[('て', 232), ('の', 220), ('な', 195), ('に', 181), ('だ', 155), 
 ('が', 112), ('は', 111), ('も', 111), ('オレ', 91), ('よ', 91), 
 ('た', 90), ('し', 87), ('と', 85), ('で', 82), ('ん', 81), 
 ('プロデューサー', 75), ('って', 75), ('ぜ', 74), ('か', 69), ('ない', 66), 
 ('を', 66), ('ドーナツ', 57), ('さ', 47), ('てる', 43), ('から', 43)]

===== 文書ベース単語登場回数 (文書数:286) =====
[('な', 161), ('て', 154), ('の', 153), ('に', 131), ('だ', 128), 
 ('が', 96), ('も', 96), ('オレ', 88), (' よ', 88), ('は', 86), 
 ('で', 77), ('し', 75), ('ぜ', 74), ('と', 73), ('ん', 73), 
 ('た', 73), ('プロデューサー', 71), ('って', 66), ('か', 64), ('ない', 62), 
 ('を', 62), ('ドーナツ', 48), ('さ', 45), ('から', 41), ('てる', 40)]

===== Skip-gram対の例 =====
(こと (30), 今日 (45)) -> 0
(ない (20), け (977)) -> 0
(自分 (128), で (14)) -> 1
(が (6), なら (49)) -> 1
(ない (20), 自分 (128)) -> 1
(楽しい (75), が (6)) -> 1
(の (2), は (7)) -> 1
(けど (28), て (1)) -> 1
(てる (24), ナツキ (244)) -> 1
(ホント (129), は (7)) -> 1
(けど (28), 補給 (1064)) -> 0

登場頻度が上位の単語は助詞や助動詞ばかりに見えますね…上位25件で意味のある単語は「オレ」「プロデューサー」「ドーナツ」くらいでしょうか。…それはさておき、なんで僕春名さんに許されてない感じになってるんでしょうか?

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

いや俺が知りたいよ! …でも、助詞や助動詞ばかりっていいのか? 多くの単語が助詞や助動詞と隣り合っていることが多い、ってだけになっちゃわない?

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

本来はストップワードを除去しなければならないでしょうね。でも今回はよくわからなかったので単語の正規化やストップワード除去はしていません。なので雰囲気だけです。雰囲気だけで「春名さんのセリフ」コーパスの各形態素を4次元空間に埋め込み、さらに2次元に次元削減してプロットした図が以下です。なお、次元数やエポック数は全くチューニングしていません。最低限損失が減少したなというくらいです。

f:id:cookie-box:20181030090639p:plain:w520

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

おお、こうなるのか…だから何!?

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

そうですね…「オレ」から見て「ドーナツ」「ドラム」が同じ方角にあるので、どっちも好きなんじゃないですか?

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

分析が雑!

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

春名さんの結果だけだとよくわからないので、参考のために他の事務所のアイドルの方ですが、椎名法子さんのセリフも解析してみました。何でも彼女もドーナツに目がないということで。

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

なんで他の事務所にまでドーナツに目がないアイドルがいるんだ!?

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

春名さんの方が後発ですけどね。椎名さんについてもセリフがまとめられているページ(椎名法子 -アイマス デレステ攻略まとめwiki【アイドルマスター シンデレラガールズ スターライトステージ】 - Gamerch)から適当にパースしました。スクリプトの実行結果が以下です。

0              すっごーい!ごちそうさまでしたー!
1    ぷはーっ!もう最っ高!会場中が幸せの輪になっちゃった☆
2        いい仕上がりだね!私たち、とってもクリスピー!
Name: str, dtype: object
正例の数: 3631
負例の数: 3629
(7260, 3)

===== 単語登場回数 (ユニーク単語数:994, 語数:4198) =====
[('!', 191), ('○', 147), ('♪', 146), ('の', 132), ('て', 110), 
 ('に', 108), ('?', 94), ('は', 90), ('プロデューサー', 89), ('ドーナツ', 72), 
 ('た', 69), ('だ', 65), ('~', 60), ('で', 58), ('ー', 57), 
 ('も', 56), ('よ', 53), ('っ', 52), ('ね', 49), ('と', 45), 
 ('あたし', 42), ('し', 41), ('を', 41), ('か', 39), ('が', 37)]

===== 文書ベース単語登場回数 (文書数:309) =====
[('!', 154), ('♪', 134), ('の', 115), ('に', 95), ('プロデューサー', 89), 
 ('て', 89), ('?', 82), ('は', 81), ('○', 74), ('ドーナツ', 70), 
 ('た', 61), ('だ', 57), ('~', 54), ('で', 54), ('よ', 50), 
 ('も', 50), ('ね', 49), ('ー', 47), ('っ', 45), ('あたし', 40), 
 ('し', 40), ('と', 38), ('か', 37), ('を', 37), ('が', 35)]

===== Skip-gram対の例 =====
(でし (400), ー (15)) -> 1
(! (1), でし (400)) -> 1
(ごちそうさま (399), ! (1)) -> 1
(い (48), カワイク (371)) -> 0
(ー (15), ちゃお (839)) -> 0
(ごちそうさま (399), また (310)) -> 0
(た (11), ! (1)) -> 1
(もう (104), 最 (401)) -> 1
(輪 (49), 幸せ (130)) -> 1
(幸せ (130), 話 (380)) -> 0
(高 (402), こう (560)) -> 0

春名さんのセリフの解析に利用したスクリプトを使いまわしたので、椎名さんのセリフにのみ登場する記号が除去できていないですね。セリフのページでは「〇〇プロデューサー」となっているので、〇という単語が上位に出てきてしまっています。それで、プロットは以下ですね。
f:id:cookie-box:20181030092936p:plain:w520
彼女も「あたし」から見て「アイドル」と「ドーナツ」が同じ方向にありますね。…つまり、春名さんや椎名さんにとってドーナツとは、仕事なのでは?

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

なんでだよ!?

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

まあ今回は処理に行き届いていない面も多いですし、実際には膨大なコーパスで学習済みのネットワークを調整するべきなんでしょう。確かテキストの5章の最後の方はそのようなやり方の話だったので、きちんと分析したい場合はそちらを参考にしてみましょう。


スクリプト
# -*- coding: utf-8 -*-
import numpy as np
import pandas as pd
from keras.layers import Dot, Input, Dense, Reshape, Embedding
from keras.models import Model
from keras.preprocessing.text import *
from keras.preprocessing.sequence import skipgrams
import MeCab
import codecs
from sklearn.manifold import TSNE
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.font_manager import FontProperties

# Skip-gram ペア収集クラス
class SkipGramCollector():
    def __init__(self, list_text_raw, window_size):
        self.filters = '、….!?、。「」『』\n\r'
        self.tagger = MeCab.Tagger('-Owakati')
        self.window_size = window_size # 周辺の何単語を考慮するか
        
        # 形態素解析し全単語を収集して、単語-->ID辞書と、ID-->単語辞書を作成
        self.list_text = []
        for text_raw in list_text_raw:
            text = self.tagger.parse(text_raw)
            self.list_text.append(text)
        self.tokenizer = Tokenizer(filters=self.filters)
        self.tokenizer.fit_on_texts(self.list_text)
        self.word2id = self.tokenizer.word_index
        self.id2word = {v:k for k, v in self.word2id.items()}
        
        # 文章ごとに Skip-gram 対を得る
        df = pd.DataFrame()
        for text in self.list_text:
            wids = [self.word2id[w] for w in text_to_word_sequence(text, filters=self.filters)]
            pairs, labels = skipgrams(wids, len(self.word2id), window_size=self.window_size)
            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_])
        
        # 負例にサンプリングされた 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("正例の数:", np.sum(df.y == 1))
        print("負例の数:", np.sum(df.y == 0))
        self.df = df

# Skip-gram 識別器クラス
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__':
    _train = False # 単語分散表現を学習する
    _plot = True # 結果をプロットする
    who = 'noriko' # 'haruna'
    
    if _train:
        # ===== 学習用 Skip-gram 対の生成 =====
        df = pd.read_csv(who + '.csv') # 'str' というカラムにセリフが入っているデータフレーム
        print(df['str'].head(3))
        sgc = SkipGramCollector(df['str'].values, 2)
        print(sgc.df.shape)
        np.savetxt('word_' + who + '.csv', np.array([k for k, v in sgc.word2id.items()]),
                   delimiter=',', fmt='%s')
        
        print('\r\n----- 単語登場回数 (ユニーク単語数:' + str(len(sgc.word2id)) +
             ', 語数:' + str(sum([v[1] for v in sgc.tokenizer.word_counts.items()])) + ') -----')
        print(sorted(sgc.tokenizer.word_counts.items(), key=lambda x:x[1], reverse=True)[0:25])
        
        print('\r\n----- 文書ベース単語登場回数 (文書数:' + str(len(sgc.list_text)) + ') -----')
        print(sorted(sgc.tokenizer.word_docs.items(), key=lambda x:x[1], reverse=True)[0:25])
        
        print('\r\n----- Skip-gram対の例 -----')
        for index, row in sgc.df.iterrows():
            print("({:s} ({:d}), {:s} ({:d})) -> {:d}".format(
              sgc.id2word[row['x0']], row['x0'], sgc.id2word[row['x1']], row['x1'], row['y']))
            if index == 10:
                break
        
        # ===== ネットワークの学習 =====
        sg = SkipGramDiscriminator(len(sgc.word2id), 4) # 4次元に埋め込む場合
        sg.create_model()
        sg.discriminator.fit([sgc.df.x0.values, sgc.df.x1.values], sgc.df.y.values,
                             batch_size=32, epochs=100)
        weight = sg.word_embedder.get_weights()[0]
        print(weight.shape)
        np.savetxt('weight_' + who + '.csv', weight, delimiter=',')
    
    if _plot:
        # ===== 2次元に次元削減してプロット =====
        fp = FontProperties(fname=r'C:\WINDOWS\Fonts\YuGothB.ttc', size=13)
        weight = np.loadtxt('weight_' + who + '.csv', delimiter=',')
        words = np.loadtxt('word_' + who + '.csv', delimiter=',', dtype='unicode')
        print(weight.shape)
        weight2 = TSNE(n_components=2, random_state=0).fit_transform(weight)
        print(weight2.shape)
        plt.scatter(weight2[:,0], weight2[:,1], c='darkgray')
        for word in (['オレ', 'プロデューサー', 'ドーナツ', 'ドラム'] if who is 'haruna' else \
                     ['あたし', 'プロデューサー', 'ドーナツ', 'アイドル']):
            i = np.where(words == word)
            plt.scatter(weight2[i,0], weight2[i,1], c='black')
            plt.text(weight2[i,0], weight2[i,1], word, fontproperties=fp)
        plt.savefig('figure_' + who + '.png')

(次回があれば)つづく