Shingoの数学ノート

プログラミングと機械学習のメモ

word2vec(Skip-Gram with Negative Sampling)の理論と実装2

日付:    カテゴリ: 自然言語処理


前回の続きです。wikipediaをコーパスにして、Skip-Gram with negative samplingを実装しようと思います。

wikipediaからコーパスを作成しよう

まずは、wikipediaからデータを取得します。全部のデータで実行するととても時間がかかるため、一部だけ使用します。以下のデータをダウンロードしてください。

https://dumps.wikimedia.org/jawiki/20191201/jawiki-20191201-pages-articles1.xml-p1p106175.bz2

その後、WikiExtractor.pyを使用して使いやすい形にします。以下のサイトからWikiExtractor.pyをダウンロードしましょう。

http://medialab.di.unipi.it/Project/SemaWiki/Tools/WikiExtractor.py

次に、そのディレクトリで以下のコマンドを打ちます。

python WikiExtractor.py -cb 250K -o extracted jawiki-20191201-pages-articles1.xml-p1p106175.bz2

extractedの下に「AA」などのフォルダが出来ていれば成功です。

中身は次のようになっているため、タグを取り除く等のクレンジングを行う必要があります。

<doc id="2696621" url="https://ja.wikipedia.org/wiki?curid=2696621" title="USアレッツォ">\nUSアレッツォ\n\nSSアレッツォ(Società Sportiva Arezzo S.r.l.)はイタリアのトスカーナ州アレッツォを本拠地とするサッカークラブチームである(旧称USアレッツォ("Unione Sportiva Arezzo")。2018-19シーズンはセリエCに所属している。\n1923年、名門ユヴェントスにあやかり、"ユヴェントスFCアレッツォ"("Juventus Football Club Arezzo")として創設。1930年にUSアレッツォ("Unione Sportiva Arezzo")と名前を変えた。

ファイルを全て読み込み、クレンジングをして1つのDataFrameにします。(カレントディレクトリにdataフォルダを作成してください。)

import glob
import bz2
import re
from tqdm import tqdm_notebook
import pandas as pd
paths = glob.glob("./extracted/*/*")
# ()の中身を削除する
def sakujo(strs,start = "(",end = ")"):
    cnt = 0
    text = ""
    for s in strs:
        if s == start:
            cnt+=1
        if cnt==0:
            text+=s
        if s == end and cnt>=1:
            cnt -= 1
    return text

# 実際の処理。タグや()を外してクレンジング。
lists = []
for path in tqdm_notebook(paths):
  with bz2.open(path) as f:
    text = f.read().decode("utf8")
  text = re.sub("<.*?>\n.*?\n","",text)
  text = re.sub("<.*?>","",text)
  text = re.sub("\n","",text)
  text = re.sub("\(.*?\)","",text)
  text = sakujo(text)
  lists.append([path,text])
wiki_df = pd.DataFrame(lists,columns=["path","text"])
wiki_df.to_pickle("./data/wiki_df.pkl")

これにより、wikipediaのクレンジングが完了しました。

分かち書きとデータ分割

次に、分かち書きを行います。今回はMeCab(Neologd)を使用します。まだインストールしてない方はこちらを参照してください。

まず、学習対象の品詞を決定します。割と悩みましたが、以下の品詞を抽出します。また、stop wordも設定します。(頻出のwordをstop wordにしています。)

keep_hinshi=[
             ["形容詞","自立"],
             ["動詞","自立"],
             ["副詞","一般"],
             ["名詞","サ変接続"],
             ["名詞","ナイ形容詞語幹"],
             ["名詞","一般"],
             ["名詞","形容動詞語幹"],
             ["名詞","固有名詞"],
             ["名詞","副詞可能"]
]
sw=["*","\n","する","なる","ある","いう"]
また、形態素解析も行います。
import MeCab
def ja_parse(tweet,keep_hinshi,stop_word=["","*","\n"]):
  t=MeCab.Tagger()
  temp1 = t.parse(tweet)
  temp2 = temp1.split("\n")[:-2]
  t_list = [ [i.split("\t")[0]] + i.split("\t")[1].split(",") for i in temp2]
  output = [w[7] for w in t_list if w[7] not in stop_word and w[1:3] in keep_hinshi]
  return output

実際にwikipediaの形態素解析を実行します。形態素解析では早い部類のMeCabでも時間がかかるので、並列処理を行なっています。

from joblib import Parallel,delayed
import numpy as np
lists = Parallel(n_jobs=4,verbose=1)([delayed(lambda x:ja_parse(x,keep_hinshi,stop_word=sw))(w) for w in wiki_df["text"]])

ここから、使用する単語を絞り、単語のidを割り振り、分布を算出します。今回は、30より多く出現している単語に絞りました。全部で167621個あります。

minthr = 30
# listsを1次元にする。
ar = []
for i,ws in enumerate(lists):
    for w in ws:
        ar.append(w)   
# min_thrより大きい単語のみを残す。
s = pd.Series(ar).value_counts()
r = s[s>minthr].rank(ascending=False,method="first").astype(int)-1
word2id = r.to_dict()
id2word = {v:k for k,v in word2id.items()}
word_hist = pd.Series(ar).value_counts().loc[np.array([id2word[i] for i in range(len(id2word))])].values

作成したidや分布を保存します。

import pickle
with open("./data/w2vconf.pkl","wb") as f:
    pickle.dump({
    "word2id":word2id,
    "id2word":id2word,
    "word_hist":word_hist
    },f)

また、私のPCのメモリは16GBですが、全然足りなかったため、バッチごとにファイルを読みに行くことにしました。と言うわけで、バッチごとにファイルを分割します。

今回はwindow=4にして、前後4つずつ単語を取得し、それをyとしています。また、バッチサイズは1000にしました。先にdataフォルダの直下にwakatiフォルダを作成してください。

window=4
size = 1000
cnt=0
X=[]
y=[]
for idx in tqdm_notebook(range(window,len(train_w)-window)):
    X.append(train_w[idx])
    y.append(np.concatenate(
        [train_w[idx-window:idx],
         train_w[idx+1:idx+window+1]]))
    if (idx+1)%1000==0:
        np.savez_compressed("./data/wakati/wiki_wakati_{}.npz".format(cnt),
                            X=np.array(X), 
                            y=np.array(y))
        X=[]
        y=[]
        cnt+=1

ここで前処理が終わります。このブログではあまり変数の中身を確認しませんので適宜変数を確認していってくださいね。

SGNSを実装しよう

ようやく本題です。pytorchの流れとして、dataset→dataloader→modelの作成があります。まずはdatasetを作成しましょう。

とはいっても、前準備で正例データは作成しましたので、ただ読み込むだけです。負例データはnp.random.choiceを使用します。(本来は正例データ以外の単語を使用するべきですが、時間がかかるため全体の単語の重み付きランダムサンプリングになっています。)

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
from torch.autograd import Variable
import numpy as np

class w2vdataset(torch.utils.data.Dataset):

    def __init__(self, paths,word_hist,pow=3/4,sample=5):
        self.paths = paths
        self.len = len(paths)
        self.word_hist = word_hist
        self.word_len = len(word_hist)
        self.word_weight = self.word_hist**pow/(self.word_hist**pow).sum()
        self.sample = sample
        
        
    def __len__(self):
        return self.len

    def negative_sampling(self,outputs):
        ar = np.arange(len(self.word_hist))
        c = np.random.choice(ar,
            p=self.word_weight,
            size=self.sample*outputs.shape[0]*outputs.shape[1]
           ).reshape(outputs.shape[0],outputs.shape[1]*self.sample)

        return c

    def __getitem__(self, idx):
        l=np.load(self.paths[idx])
        X,y = l["X"],l["y"]
        nega_words = self.negative_sampling(y)        
        return X,y,nega_words

datasetとdataloaderをインスタンス化します。batch_size=1となっていますが、実際のサイズは1000です。

wakati_paths = glob.glob("./data/wakati/wiki_wakati_*.npz")
batch_size = 1
pow = 3/4
sample = 5
ds = w2vdataset(wakati_paths,word_hist,pow=pow,sample=sample)
dl = torch.utils.data.DataLoader(ds,
                       batch_size=batch_size, shuffle=True)

肝心のSGNSです。といってもすごくシンプルで、前回の図の通りに組んでいきます。inputsとpoji,negaのembedding結果をそれぞれ掛け合わせて、sigmoidを噛ませるだけです。

dim = 200
torch.manual_seed(1)
class word2vec_SGNS(nn.Module):
    def __init__(self,dim):
        super(word2vec_SGNS, self).__init__()
        # embedding layerの設定
        self.embeds_i = nn.Embedding(len(word2id), dim)
        self.embeds_o = nn.Embedding(len(word2id), dim)
        # 初期化
        initrange = 1/dim
        self.embeds_i.weight.data.uniform_(-initrange, initrange)
        self.embeds_o.weight.data.uniform_(-initrange, initrange)

    def forward(self,inputs,outputs,nega_words):
        # inputsの単語のembedding
        self.inputs = self.embeds_i(inputs)
        # 正例ラベルのスコアを算出
        self.poji_outputs = self.embeds_o(outputs)
        self.poji_score = torch.bmm(self.poji_outputs, self.inputs.unsqueeze(2)).view(-1)        
        # 負例ラベルのスコアを算出
        self.nega_outputs = self.embeds_o(nega_words)
        self.nega_score = torch.bmm(self.nega_outputs, self.inputs.unsqueeze(2)).view(len(self.poji_score),-1)
        return self.poji_score.sigmoid(),self.nega_score.sigmoid()

***追記 20201119***

次のコードでモデルを定義してください。

model = word2vec_SGNS(dim=200).cuda()
optimizer = optim.SGD(model.parameters(), lr=1)

いよいよ学習です。先にmodelフォルダを作成してください。なお、RTX2080Tiで1epochあたり22分程度かかります。

for epoch in range(20): #学習回数20回
    total_loss = 0
    poji_score_mean = 0
    nega_score_mean = 0
    data_cnt = 0
    
    for x, y, nega_words in tqdm_notebook(dl):
        # batch_size=1の次元が余計なので、squeezeで元に戻す。
        x = Variable(x).cuda().squeeze(0)
        y = Variable(y).cuda().squeeze(0)
        nega_words = Variable(nega_words).cuda().squeeze(0)
        optimizer.zero_grad()
        poji_score,nega_score = model(x,y,nega_words)
        # 実際は負例は.sum(dim=1).mean()だが、学習がnegaに偏りすぎたので単にmean()としています。
        loss =  -torch.log(poji_score+1e-10).mean() - (torch.log(1 - nega_score+1e-10)).mean()
    
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
        poji_score_mean += poji_score.mean()
        nega_score_mean += nega_score.mean()
        data_cnt += 1
    print(epoch+1, "train_loss:",total_loss/data_cnt,"poji:",poji_score_mean.item()/data_cnt,"nega:",nega_score_mean.item()/data_cnt)
    
    torch.save(model.state_dict(), "./model/model{}.h5".format(epoch+1))

SGNSの結果を見よう

ある程度学習できたら、実際に結果を見てみましょう。もし途中のepochの学習結果を見たいのであれば、以下のコードを実行しましょう。

epoch=20
model.load_state_dict(torch.load("./model/model{}.h5".format(epoch)))

次は似ている単語を見るための関数の作成です。(20201125一部追記)

p=model.embeds_i.weight
c = ["f{:03d}".format(i) for i in range(200)]
w = [id2word[i] for i in range(len(word2id))]
word_df = pd.DataFrame(p.cpu().detach().numpy(),columns=c,index=w)
def cos_sim(v1,v2):
  return np.dot(v1,v2)/np.linalg.norm(v1)/np.linalg.norm(v2)
def w2v(w):
    return word_df.loc[w].values
def word_sim(v1,top=20):
  sim = word_df.apply(lambda v2:cos_sim(v1,v2),axis=1)
  return sim.sort_values(ascending=False)[:top]

まずは、ラーメンの類似度から。

v1=w2v("ラーメン")
word_sim(v1)
結果
ラーメン      1.000000
寿司        0.962305
餃子        0.943175
うどん       0.942448
弁当        0.923823
焼肉        0.912093
丼         0.909097
和食        0.907628
たこ焼き      0.900868
和菓子       0.899265
麺         0.898703
お好み焼き     0.894609
土産        0.894093
手作り       0.892730
おでん       0.887496
名物        0.887184
ハンバーガー    0.885918
駄菓子       0.885593
お菓子       0.885479
焼きそば      0.883539
dtype: float64

一番近いのが寿司?食べ物という観点では一緒だが、似ているかどうかと言われると怪しい。でも、餃子とか麺とかは似ているといえば似ている。 もう一つ試してみよう。

v2=w2v("楽しい")
word_sim(v2)
結果
楽しい    1.000000
楽しみ    0.969969
面白い    0.949566
感動     0.948224
友達     0.947869
遊び     0.943903
笑顔     0.939842
観る     0.936940
大好き    0.936386
女の子    0.935551
元気     0.933673
楽しさ    0.933347
笑い     0.933258
すごい    0.927031
いつも    0.923831
思い出    0.923417
幸せ     0.922852
喋る     0.921187
想い     0.920456
優しい    0.917699
dtype: float64

なんとなく楽しいと近い単語が並んでいるのではないか。最後に恒例の単語の足し算引き算をしてみる。

v3 = w2v("サッカー")
v4 = w2v("蹴る")
v5 = w2v("投げる")
word_sim(v3-v4+v5)
結果
サッカー           0.952638
ラグビー           0.928705
バスケットボール       0.917544
野球             0.915660
バレーボール         0.901993
アメリカンフットボール    0.883758
アイスホッケー        0.883444
陸上競技           0.870330
フットボール         0.868901
卓球             0.866964
ユース            0.864876
現役時代           0.864565
ソフトボール         0.860536
ハンドボール         0.859906
テニス            0.858122
フットサル          0.856992
自転車競技          0.856940
Jリーグ           0.851903
クリケット          0.848675
女子サッカー         0.844556
dtype: float64

サッカーが1番上に来てしまっているが、ラグビー、バスケ、野球など、ボールを投げるスポーツが上位に来ている。

SGNSの実装はこれで終了です。windowの数やoptimizer等をしっかりいじれば、もっと精度は高くなっていくと思いますので、ぜひ試してみてください!



Comment Box is loading comments...