PyTorchによる言語モデルの作り方

 

長年?PyTorchによる自然言語処理の実装方法がなんとなく分かっているようで分かっていない状態の私でしたが、、、

最近やっと実装方法が分かったので、でもやっぱり分かっていないので、本当に理解できているのかの確認の意味を込めて言語モデルの実装方法について書いていきたいと思います。

 

 

関連リンク

PyTorch documentation — PyTorch master documentation

Deep Learning for NLP with Pytorch — PyTorch Tutorials 0.4.1 documentation

環境

今回使うものとか。

MeCabWindowsにインストールするには。

https://www.pytry3g.com/entry/2018/04/24/143934#Windows

 

JapaneseTextEncoderについてはこちら。

www.pytry3g.com

言語モデル

言語モデルを実装する前に、言語モデルとは何なのか軽くまとめてみます。

任意の単語の列に対して確率を与えるものを言語モデルと呼びます。言語モデルは任意の単語の列に対して、それが人間が用いるであろう言葉らしさなのか、というのを確率で評価します。

言語モデルは任意の文字列を与えたとき、それが自然な単語の並びであるほど高い確率を出力し、不自然な単語の並びだと低い確率を出力します。

この記事ではPyTorchを使ってRNNLM(Recurrent Neural Network Language Model)を作っていきます。

今回使うもの

実装がうまくできているか検証しながらプログラムを動かしていたので、時間をかけないために小さいデータセットを使って言語モデルを作ってみました。

今回使うデータセットは以下の3つの文のみで、これを用いて言語モデルの実装をしていきます。

corpus = ["セネガルつええ、ボルト三体くらいいるわ笑笑",
          "しょーみコロンビアより強い",
          "それなまちがいないわ"]

言語モデルの実装

今回PyTorchを使って言語モデルの実装をしますが、2つの実装方法について書いてみます。一つ目は教師データを単語単位で与える方法、二つ目は教師データを文単位で与える方法です。

実はこの記事を書く前に3つ目の実装方法としてミニバッチ学習についても書くつもりでいましたが、試行錯誤の結果、、、

 

なんと!?実装できま

 

せんでした\(^o^)/

この記事の冒頭で実装方法が分かったようで分かっていないと書いたのは、まぁそういう意味です。(※実装方法が分かり次第この記事を更新する予定です。)

 

ということで話を進めていきます。

実装方法を書く前に、RNNで言語モデルを学習する方法をcorpusにあるそれなまちがいないわを例に使って書いてみます。

RNNには文章を形態素解析し単語ID化したものを渡す必要があります。それなまちがいないなわ形態素解析してみると['それ', 'な', 'まちがい', 'ない', 'わ']単語IDにすると[21, 22, 23, 24, 13, 2]になりました。(※単語IDの最後の2は終端記号です。)

この処理にはJapaneseTextEncoderを使いました。

RNNでそれなまちがいないわを学習させるときのイメージはこんな感じです。

f:id:pytry3g:20180914153727p:plain

時系列に沿って単語を入力し、それぞれの単語の次の単語が出力されるように学習していきます。(</s>は終端記号です。)

上の図を詳細にしたのが下の図になります。

f:id:pytry3g:20180914150607p:plain

RNNのネットワークに時系列に沿って単語IDを入力します。今回の例ではそれの単語ID21をネットワークに渡しています。同時に隠れ状態をネットワークに渡します。

RNNは通常のフィードフォワード型のネットワークとは異なり、入力と隠れ状態(hidden state)と呼ばれるひとつ前のネットワークの状態をネットワークに渡すことにより現在の出力を出すことができます。(※上の例では文章の最初の単語を与えているので隠れ状態は初期化したものを渡しています。)

ネットワークからは隠れ状態とネットワークの現在の状態が出力されます。現在の状態と教師データを損失関数に与え損失を計算します。

単語それの損失を計算したら次の単語の単語ID(22)をネットワークに渡します。ネットワークには単語IDとひとつ前の隠れ状態を与えます。

f:id:pytry3g:20180914155300p:plain

あとは上の例と同様に学習を進めていきます。

前準備

これから、PyTorchを使って言語モデルの実装をしていきます。

まずは、必要なライブラリのインポート。

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from text_encoder import JapaneseTextEncoder

 

学習データをJapaneseTextEncoderに渡して単語辞書を作る。

corpus = ["セネガルつええ、ボルト三体くらいいるわ笑笑",
          "しょーみコロンビアより強い",
          "それなまちがいないわ"]

encoder = JapaneseTextEncoder(
    corpus, append_eos=True
)
# encoder.word2id['セネガル'] -> 4

JapaneseTextEncoderのword2idは辞書形式になっていてkeyが単語、valueが単語IDになっている。

 

次に、JapaneseTextEncoderのbuild()を使って、学習データを単語辞書をもとに単語IDに変換する。

# text to indices
encoder.build()
# encoder.dataset
# [[4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 14, 2],
#  [15, 16, 17, 18, 19, 20, 2],
#  [21, 22, 23, 24, 13, 2]]

 

最後に学習のための設定をする。

n_vocab = len(encoder.word2id)
EMBEDDING_DIM = 64
HIDDEN_DIM = 64

model = RNNLM(EMBEDDING_DIM, HIDDEN_DIM, n_vocab)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)

 

今回は2つの実装方法について書きますが、PyTorchで実装するときの言語モデルの雛形は以下のようになります。

class RNNLM(nn.Module):
    def __init__(self, embedding_dim, hidden_dim, vocab_size):
        super(RNNLM, self).__init__()
        self.hidden_dim = hidden_dim
        
        self.word_embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.dropout = nn.Dropout()
        
        # The GRU takes word embeddings as inputs, and outputs hidden states
        # with dimensionality hidden_dim.
        self.gru = nn.GRU(embedding_dim, hidden_dim)
        
        self.output = nn.Linear(hidden_dim, vocab_size)
        
    def init_hidden(self):
        pass
    
    def forward(self, hoge):
        pass

実装方法によってinit_hidden()forward()を変更する必要があります。

単語単位での実装

教師データを単語単位で与えて学習する方法をcorpusにあるそれなまちがいないわを例に使って書いてみます。

前準備ができているものとして話を進めていきます。

それなまちがいないわを変数sampleに入れる。

sample = encoder.dataset[-1]
print(encoder.decode(sample))
print(sample)
"""out:
それなまちがいないわ</s>
[21, 22, 23, 24, 13, 2]
"""

 

PyTorchで学習をするためにtorch.tensor()を使ってテンソルに変換する。

indices_inは入力、indices_outは教師データです。

indices_in = torch.tensor(sample[:-1])
indices_out = torch.tensor(sample[1:])
print(indices_in)
print(indices_out)
"""out:
tensor([ 21,  22,  23,  24,  13])
tensor([ 22,  23,  24,  13,   2])
"""

 

ここから、単語IDをネットワークに渡してネットワークの出力と教師データからLossを計算します。手順は以下のとおりです。

  1.  学習モードに入る。
  2.  zero_grad()で勾配を初期化
  3.  init_hidden()で隠れ状態を初期化
  4.  テンソル(単語ID)をひとつずつネットワークに渡す
  5.  Lossを計算する。
  6.  backward()で勾配を計算する。
  7.  step()パラメータの更新。

 

1. 学習モードに入る。

言語モデルの雛形のコンストラクタに書いてありますが今回はDropoutを使います。Dropoutを使うときは学習モードに入る必要があるようです。

model.train()
"""out:
RNNLM(
  (word_embeddings): Embedding(25, 64)
  (dropout): Dropout(p=0.5)
  (gru): GRU(64, 64)
  (output): Linear(in_features=64, out_features=25, bias=True)
)
"""

 

2. zero_grad()で勾配を初期化

model.zero_grad()

 

3. init_hidden()で隠れ状態を初期化

model.init_hidden()

 

4. テンソル(単語ID)をひとつずつネットワークに渡す

5. Lossを計算する。

単語IDをネットワークに渡しそのネットワークの出力と教師データから誤差を計算します。

loss = torch.zeros(1)
for idx_in, idx_out in zip(indices_in, indices_out):
    y = model(idx_in.view(1))
    loss += criterion(y, idx_out.view(1))

 

6. backward()で勾配を計算する。

損失の累積から勾配を計算します。

loss.backward()

 

7. step()でパラメータの更新。

optimizer.step()

PyTorchでの学習はこのような流れになります。次にネットワークのinit_hidden()forward()の中身について見てみます。

 

def init_hidden(self)

ここでは隠れ状態の初期化を行います。minibatch_sizeのところはバッチサイズを指定します。単語単位での学習ではミニバッチ学習はしませんので、バッチサイズは1にします。

    def init_hidden(self):
        # The axes semantics are (num_layers, minibatch_size, hidden_dim)
        self.hidden_state = torch.zeros(1, 1, self.hidden_dim)

def forward(self, index)

ここでは与えられた単語IDを単語分散表現にしてから、GRUに渡し、最終的にLinear layerから出力しています。図にするとこんな感じ。

f:id:pytry3g:20180920145453p:plain

forward()を実装する上で個人的につまづいたところがGRUに渡すときのテンソルの形状でした。今までDocumentを見てもわからなかった、、、

    def forward(self, index):
        embed = self.word_embeddings(index) 
        embed = embed.view(1, 1, -1)
        embed = self.dropout(embed)
        gru_out, self.hidden_state = self.gru(embed, self.hidden_state) 
        out = self.output(gru_out.view(1, -1)) 
        return out

GRUにはinputh_0を渡します。今回の場合、inputは単語分散表現でh_0は隠れ状態になります。

inputの形状は(sep_len, batch, input_size)です。ここでseq_lenとはネットワークに与えた入力のサイズのことを意味します。例えば、ひとつの単語をネットワークに与えるならseq_lenは1、3つの単語をネットワークに与えるならseq_lenは3になります。

batchはバッチサイズなので1、input_sizeは単語分散表現の次元になります。

そのため、GRUに単語分散表現を渡すときは、その形状に気を付ける必要があります。forward()の中を順番に見ていくと、indexword_embeddingsに渡したときの形状は

        embed = self.word_embeddings(index)
        print(index.shape, embed.shape) 
        # torch.Size([1]) torch.Size([64])

単語IDから64次元の単語分散表現にしています。

そして、view()を使ってGRUに合わせた形状に変換します。ひとつの単語を入力としたのでseq_lenは1、batchも1、input_sizeには単語分散表現の次元64に設定。

        embed = embed.view(1, 1, -1)
        embed = self.dropout(embed)
        print(embed.shape) # torch.Size([1, 1, 64])

 

形状を変換した単語分散表現と隠れ状態をGRUに渡す。

        gru_out, self.hidden_state = self.gru(embed, self.hidden_state)
        print(gru_out.shape, self.hidden_state.shape)
        # (torch.Size([1, 1, 64]) torch.Size([1, 1, 64]))

 

最後に出力ですが、ここには(入力サイズx隠れ層の次元)の形状にしてから渡します。self.outputは (隠れ層の次元x単語の総数)=(64x25)になっているので出力の結果は下のようになります。

        out = self.output(gru_out.view(1, -1)) 
        # torch.Size([1, 25])

単語単位での実装は以上のような感じです。

 

スポンサーリンク

 

 

文単位での実装

教師データを文単位で与えて学習する方法をcorpusにあるそれなまちがいないわを例に使って書いてみます。

前準備ができているものとして話を進めていきます。

それなまちがいないわの単語ID列を変数sampleに入れてtorch.tensor()を使ってテンソルに変換する。

sample = encoder.dataset[-1]
inputs = torch.tensor(sample[:-1])
print(encoder.decode(sample))
print(inputs)
"""out:
それなまちがいないわ</s>
tensor([ 21,  22,  23,  24,  13])
"""

文単位で単語分散表現にしてからGRUに渡す方法について、わかりやすい説明がTutorialにあったので、その説明に沿ってやり方を書いてみます。

PyTorchでLSTM、GRUを使うときは3次元のテンソル(seq_len, minibatch_size, input_size)を渡す必要があります。seq_lenはsequenceの長さ、今回の例だとinputsの長さになるのでlen(inputs)です。minibatch_sizeはミニバッチ学習をしないので1、input_sizeは単語分散表現の次元になります。

下のプログラムは単語IDをひとつずつ渡したときと一気に渡したときの例です。ひとつずつ渡したときのoutの中身と一気に渡したときのout[-1]の中身が同じになっていることがわかります。

embeddings = nn.Embedding(n_vocab, 3) # 3次元の単語分散表現
gru = nn.GRU(3, 3) # Input dim is 3、output dim is 3


### 単語IDをひとつずつ渡した場合 ###
hidden = torch.zeros(1, 1, 3) # 隠れ状態の初期化
for i in inputs:
    embed = embeddings(i)
    out, hidden = gru(embed.view(1, 1, -1), hidden)
print(out) # tensor([[[-0.1262,  0.2403, -0.0440]]])

### 単語IDを一気に渡した場合 ###
hidden = torch.zeros(1, 1, 3)
embed = embeddings(inputs)
out, hidden = gru(embed.view(len(inputs), 1, -1), hidden)
print(out)
"""out:
tensor([[[ 0.1282,  0.2160,  0.2106]],

        [[ 0.0952,  0.4316,  0.0429]],

        [[ 0.1437,  0.4835, -0.3459]],

        [[ 0.1964,  0.2238, -0.0518]],

        [[-0.1262,  0.2403, -0.0440]]])
"""

 

文単位での学習の流れは単語単位での学習の流れとほぼ同じです。異なる点は4. 単語IDをひとつずつネットワークに渡すところです。

まとめると、下のようになります。

# Initialize gradients and hidden states.
model.zero_grad()
model.init_hidden()

indices_in = torch.tensor(sample[:-1]) # 入力
indices_out = torch.tensor(sample[1:]) # 教師データ

# Forward pass
y = model(indices_in)
# loss + backward + update
loss = criterion(y, indices_out)
loss.backward()
optimizer.step()

 

文単位で学習するのでforward()の中身を少し変更する必要があります。init_hidden()は変更する必要はありません。

    def forward(self, indices):
        embed = self.word_embeddings(indices) 
        embed = embed.view(len(indices), 1, -1) 
        embed = self.dropout(embed)
        gru_out, self.hidden_state = self.gru(embed, self.hidden_state)
        out = self.output(gru_out.view(len(indices), -1)) 
        return out

 

文単位で学習するときforward()には単語ID列のテンソルを渡しています。forward()の中を順番に見ていくと、indicesword_embeddingsに渡したときの形状は

        embed = self.word_embeddings(indices)
        print(indices, indices.shape)
        # tensor([ 21,  22,  23,  24,  13]) torch.Size([5])
        print(embed.shape) # torch.Size([5, 64])

word_embeddingsの形状は 5x64 になっています。5は単語ID列のサイズ、64は単語分散表現の次元、前準備のところで64に設定しています。

GRUに渡す前にview()を使って形状をGRUに合わせます。変換前は 5x64 の形状になっているので、これを3次元の形状(seq_len, batch, input_size)にします。

seq_lenは入力サイズなので5、batchはバッチサイズのことで今回はミニバッチ学習はしないので1、input_sizeは単語分散表現の次元64にします。

        embed = embed.view(len(indices), 1, -1) 
        embed = self.dropout(embed)
        print(embed.shape) # torch.Size([5, 1, 64])

 

単語分散表現と隠れ状態をGRUに渡す。

        gru_out, self.hidden_state = self.gru(embed, self.hidden_state)
        print(gru_out.shape, self.hidden_state.shape)
        # (torch.Size([5, 1, 64]) torch.Size([1, 1, 64]))

 

GRUから出た出力を(入力サイズx隠れ層の次元)の形状にしてからネットワークの出力層に渡します。

        out = self.output(gru_out.view(len(indices), -1)) 
        print(out.shape)
        # torch.Size([5, 25])

文単位での学習はこんな流れになります。

最後にコードをまとめたものを下のソースコードのところに置いときます。

 

ゼロから作るDeepLearning

自然言語処理を勉強したい方へのおすすめの本です。

tensorflow, chainerやPyTorchといったフレームワークを使わずにゼロからnumpyを使ってディープラーニングの実装をしています。

扱っている内容はword2vec, RNN, GRU, seq2seqやAttentionなど、、、

ソースコード

この記事で紹介した方法で言語モデルを作るにはMeCab、JapaneseTextEncoderのtext_encoder.pyreserved_tokens.pyが必要になります。

言語モデルを作る

下のコードを実行すると、学習から学習モデルの保存までをしてくれます。単語単位で学習する方法train_per_word()と文単位で学習する方法train_per_line()について実装しています。

import logging
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from text_encoder import JapaneseTextEncoder

corpus = ["セネガルつええ、ボルト三体くらいいるわ笑笑",
          "しょーみコロンビアより強い",
          "それなまちがいないわ"]

logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)

logging.info("Building dictionary and dataset.")
encoder = JapaneseTextEncoder(corpus, append_eos=True)
encoder.build()
logging.info("Done...")

n_vocab = len(encoder.word2id)
EMBEDDING_DIM = 64
HIDDEN_DIM = 64
logging.info("Vocab has %i elements", n_vocab)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
class RNNLM(nn.Module):
    def __init__(self, embedding_dim, hidden_dim, vocab_size):
        super(RNNLM, self).__init__()
        self.hidden_dim = hidden_dim

        self.word_embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.dropout = nn.Dropout()

        self.gru = nn.GRU(embedding_dim, hidden_dim)

        self.output = nn.Linear(hidden_dim, vocab_size)

    def init_hidden(self):
        self.hidden_state = torch.zeros(1, 1, self.hidden_dim, device=device)

    def forward(self, indices):
        embed = self.word_embeddings(indices)
        embed = self.dropout(embed.view(len(indices), 1, -1))
        gru_out, self.hidden_state = self.gru(embed, self.hidden_state)
        out = self.output(gru_out.view(len(indices), -1))
        return out


model = RNNLM(EMBEDDING_DIM, HIDDEN_DIM, n_vocab).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)
# Training
def train_per_word(inputs, targets):
    loss = torch.zeros(1, device=device)
    for x, t in zip(inputs, targets):
        y = model(x.view(1))
        loss += criterion(y, t.view(1))
    return loss


def train_per_line(inputs, targets):
    outputs = model(inputs)
    loss = criterion(outputs, targets)
    return loss


logging.info("Training mode")
model.train()
loss = torch.zeros(1)
for epoch in range(100):
    
    if (epoch+1) % 10 == 0:
        logging.info("Epoch %i: %.2f", epoch+1, loss.item())
    
    encoder.shuffle()
    for i, sentence_idxs in enumerate(encoder.dataset):
        model.zero_grad()
        model.init_hidden()

        inputs = torch.tensor(sentence_idxs[:-1], device=device)
        targets = torch.tensor(sentence_idxs[1:], device=device)

        loss = train_per_word(inputs, targets)
        #loss = train_per_line(inputs, targets)
        loss.backward()
        optimizer.step()

logging.info("Saving the model")
model_name = "embedding{}_wiki_{}.model".format(EMBEDDING_DIM, device)
torch.save(model.state_dict(), model_name)

文章生成

学習済みのモデルに単語を与えて文章を生成してみます。

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from text_encoder import JapaneseTextEncoder

corpus = ["セネガルつええ、ボルト三体くらいいるわ笑笑",
          "しょーみコロンビアより強い",
          "それなまちがいないわ"]

encoder = JapaneseTextEncoder(corpus, append_eos=True)
encoder.build()

n_vocab = len(encoder.word2id)
EMBEDDING_DIM = 64
HIDDEN_DIM = 64
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
class RNNLM(nn.Module):
    def __init__(self, embedding_dim, hidden_dim, vocab_size):
        super(RNNLM, self).__init__()
        self.hidden_dim = hidden_dim

        self.word_embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.dropout = nn.Dropout()

        self.gru = nn.GRU(embedding_dim, hidden_dim)

        self.output = nn.Linear(hidden_dim, vocab_size)

    def init_hidden(self):
        self.hidden_state = torch.zeros(1, 1, self.hidden_dim, device=device)

    def forward(self, indices):
        embed = self.word_embeddings(indices)
        embed = self.dropout(embed.view(len(indices), 1, -1))
        gru_out, self.hidden_state = self.gru(embed, self.hidden_state)
        out = self.output(gru_out.view(len(indices), -1))
        return out
    
    
model = RNNLM(EMBEDDING_DIM, HIDDEN_DIM, n_vocab).to(device)
model_name = "embedding{}_wiki_{}.model".format(EMBEDDING_DIM, device)
# 学習モデルを読み込む
model.load_state_dict(torch.load(model_name))
morpheme = "それ"
sentence = [morpheme]
# 推論モード
model.eval()
model.init_hidden()
with torch.no_grad():
    ### 文章生成 ###
    for i in range(30):
        index = encoder.word2id[morpheme]
        x = torch.tensor([index], device=device)
        y = model(x)
        probs = F.softmax(y)
        p = probs[0].cpu().detach().numpy()
        morpheme = np.random.choice(encoder.vocab, p=p)
        sentence.append(morpheme)
        if morpheme == "</s>":
            break
print("".join(sentence).replace("</s>", "。"))

この文章生成のプログラムでは終端記号が出力されたときか、モデルからの単語の出力が30を超えるまで学習モデルから単語を出力し続けます。