#1.はじめに
名著、**「ゼロから作るDeep Learning2」**を読んでいます。今回は5章のメモ。
コードの実行はGithubからコード全体をダウンロードし、ch05の中で jupyter notebook にて行っています。
#2.RNNLM
まず、PTBデータセットの語順を学習する下記のコード、ch05/train_custom_loop.py を実行してみます。
import sys
sys.path.append('..')
import matplotlib.pyplot as plt
import numpy as np
from common.optimizer import SGD
from dataset import ptb
from simple_rnnlm import SimpleRnnlm
# ハイパーパラメータの設定
batch_size = 10
wordvec_size = 100
hidden_size = 100
time_size = 5 # Truncated BPTTの展開する時間サイズ
lr = 0.1
max_epoch = 100
# 学習データの読み込み(データセットを小さくする)
corpus, word_to_id, id_to_word = ptb.load_data('train')
corpus_size = 1000
corpus = corpus[:corpus_size]
vocab_size = int(max(corpus) + 1)
xs = corpus[:-1] # 入力
ts = corpus[1:] # 出力(教師ラベル)
data_size = len(xs)
print('corpus size: %d, vocabulary size: %d' % (corpus_size, vocab_size))
# 学習時に使用する変数
max_iters = data_size // (batch_size * time_size)
time_idx = 0
total_loss = 0
loss_count = 0
ppl_list = []
# モデルの生成
model = SimpleRnnlm(vocab_size, wordvec_size, hidden_size)
optimizer = SGD(lr)
# ミニバッチの各サンプルの読み込み開始位置を計算
jump = (corpus_size - 1) // batch_size
offsets = [i * jump for i in range(batch_size)]
for epoch in range(max_epoch):
for iter in range(max_iters):
# ミニバッチの取得
batch_x = np.empty((batch_size, time_size), dtype='i')
batch_t = np.empty((batch_size, time_size), dtype='i')
for t in range(time_size):
for i, offset in enumerate(offsets):
batch_x[i, t] = xs[(offset + time_idx) % data_size]
batch_t[i, t] = ts[(offset + time_idx) % data_size]
time_idx += 1
# 勾配を求め、パラメータを更新
loss = model.forward(batch_x, batch_t)
model.backward()
optimizer.update(model.params, model.grads)
total_loss += loss
loss_count += 1
# エポックごとにパープレキシティの評価
ppl = np.exp(total_loss / loss_count)
print('| epoch %d | perplexity %.2f'
% (epoch+1, ppl))
ppl_list.append(float(ppl))
total_loss, loss_count = 0, 0
# グラフの描画
x = np.arange(len(ppl_list))
plt.plot(x, ppl_list, label='train')
plt.xlabel('epochs')
plt.ylabel('perplexity')
plt.show()
グラフの縦軸は次に来る単語の確率を予測するパープレキシティという指標で、$perplexity=e^L\ (L=-\frac{1}{N}\sum_n\sum_k t_{nk}log y_{nk})$という計算式で表わされます。パープレキシティの値は1に近くほど、予測精度が高いことになります。簡単に言うと、パープレキシティは次に来る単語の選択肢の数と言うことができます。それでは、データを準備する部分をざっと見ておきます。
corpus は PTBデータセットの先頭1,000語のみを使い、学習用データxs と教師データts は1語づらしでそれぞれ999語取得します。
そして、offsets
でバッチサイズ分の読み込み位置(10箇所)を決め、タイムサイズ分のデータ毎(5個)で区切ってミニバッチを作成します。offsets + time_idx
がデータサイズの999以上になったら再び0からスタートしてデータを取得しています。
それでは、モデルの生成を行っている、class SimpleRnnlm を見てみましょう。
#3.SimpleRnnlm
class SimpleRnnlm:
def __init__(self, vocab_size, wordvec_size, hidden_size):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
# 重みの初期化
embed_W = (rn(V, D) / 100).astype('f')
rnn_Wx = (rn(D, H) / np.sqrt(D)).astype('f')
rnn_Wh = (rn(H, H) / np.sqrt(H)).astype('f')
rnn_b = np.zeros(H).astype('f')
affine_W = (rn(H, V) / np.sqrt(H)).astype('f')
affine_b = np.zeros(V).astype('f')
# レイヤの生成
self.layers = [
TimeEmbedding(embed_W),
TimeRNN(rnn_Wx, rnn_Wh, rnn_b, stateful=True),
TimeAffine(affine_W, affine_b)
]
self.loss_layer = TimeSoftmaxWithLoss()
self.rnn_layer = self.layers[1]
# すべての重みと勾配をリストにまとめる
self.params, self.grads = [], []
for layer in self.layers:
self.params += layer.params
self.grads += layer.grads
def forward(self, xs, ts):
for layer in self.layers:
xs = layer.forward(xs)
loss = self.loss_layer.forward(xs, ts)
return loss
def backward(self, dout=1):
dout = self.loss_layer.backward(dout)
for layer in reversed(self.layers):
dout = layer.backward(dout)
return dout
def reset_state(self):
self.rnn_layer.reset_state()
class SimpleRnnlmは、TimeEmbedding, Time RNN, Time Affine, Time Softmax With Loss という4つのTimeレイヤを重ねたものです。では、Timeレイヤを順番に見て行きましょう。
#4.TimeEmbeddingレイヤ
class TimeEmbedding:
def __init__(self, W):
self.params = [W]
self.grads = [np.zeros_like(W)]
self.layers = None
self.W = W
def forward(self, xs):
N, T = xs.shape
V, D = self.W.shape
out = np.empty((N, T, D), dtype='f')
self.layers = []
for t in range(T):
layer = Embedding(self.W)
out[:, t, :] = layer.forward(xs[:, t])
self.layers.append(layer)
return out
def backward(self, dout):
N, T, D = dout.shape
grad = 0
for t in range(T):
layer = self.layers[t]
layer.backward(dout[:, t, :])
grad += layer.grads[0]
self.grads[0][...] = grad
return None
Time Embeddingレイヤは、xsから1列づつデータを切り出しEmbeddingレイヤに入力して、その出力を**out(N, T, D)**に溜め込む、という動作をforループでT回繰り返すというものです。
#5.RNN レイヤ
TimeRNN レイヤをを見る前に、TimeRNN レイヤに使われている RNN レイヤから見て行きます。
class RNN:
def __init__(self, Wx, Wh, b):
self.params = [Wx, Wh, b]
self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
self.cache = None
def forward(self, x, h_prev):
Wx, Wh, b = self.params
t = np.dot(h_prev, Wh) + np.dot(x, Wx) + b
h_next = np.tanh(t)
self.cache = (x, h_prev, h_next)
return h_next
RNNレイヤは、2つの重みを持っています。それは、入力x_tと内積(MatMul)を取る重みW_xと入力h_prevと内積(MatMUl)を取る重みW_hです。
def backward(self, dh_next):
Wx, Wh, b = self.params
x, h_prev, h_next = self.cache
dt = dh_next * (1 - h_next ** 2)
db = np.sum(dt, axis=0)
dWh = np.dot(h_prev.T, dt)
dh_prev = np.dot(dt, Wh.T)
dWx = np.dot(x.T, dt)
dx = np.dot(dt, Wx.T)
self.grads[0][...] = dWx
self.grads[1][...] = dWh
self.grads[2][...] = db
return dx, dh_prev
逆伝播はこんな形。Affineの変形版なので、特に複雑なところはありません。
#6.TimeRNNレイヤ
class TimeRNN:
def __init__(self, Wx, Wh, b, stateful=False):
self.params = [Wx, Wh, b]
self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
self.layers = None
self.h, self.dh = None, None
self.stateful = stateful
def forward(self, xs):
Wx, Wh, b = self.params
N, T, D = xs.shape
D, H = Wx.shape
self.layers = []
hs = np.empty((N, T, H), dtype='f')
if not self.stateful or self.h is None:
self.h = np.zeros((N, H), dtype='f')
for t in range(T):
layer = RNN(*self.params)
self.h = layer.forward(xs[:, t, :], self.h)
hs[:, t, :] = self.h
self.layers.append(layer)
return hs
TimeRNNレイヤは、T個のRNNレイヤを連結したネットワークです。ブロック間の状態hを引き継ぐかどうかをstateful
という引数で調整できるようにします。
順伝播では、まず出力用の容器 hs(N, T, H)
を用意します。そして、forループを回しながら、xs[:, t, :]
によってt番目のデータを切り出して、通常のRNNに入力し、出力はhs[:, t, :]
で用意した容器の指定位置に格納して行くと共に、layersにレイヤーを登録して行きます。
つまり、TimeRNNレイヤは、RNNレイヤの入出力にデータ切り出しとデータまとめの機能を付け加えたものです。
def backward(self, dhs):
Wx, Wh, b = self.params
N, T, H = dhs.shape
D, H = Wx.shape
dxs = np.empty((N, T, D), dtype='f')
dh = 0
grads = [0, 0, 0]
for t in reversed(range(T)):
layer = self.layers[t]
dx, dh = layer.backward(dhs[:, t, :] + dh) # 合算した勾配
dxs[:, t, :] = dx
for i, grad in enumerate(layer.grads):
grads[i] += grad
for i, grad in enumerate(grads):
self.grads[i][...] = grad
self.dh = dh
return dxs
def set_state(self, h):
self.h = h
def reset_state(self):
self.h = None
TimeRNNの順伝播は出力が2つあるので、逆伝播のときはその2つが合算された $dh_t+dh_{next}$が入力されます。
まず、下流に流す容器dxsを作り、順伝播とは逆順でRNNレイヤのbackward()で各時刻の勾配dxを求め、dxsの該当するインデックスに代入します。重みパラメータは、各レイヤの重み勾配を加算し最終結果をself.gradsに上書きします。
#7.TimeAffineレイヤ
class TimeAffine:
def __init__(self, W, b):
self.params = [W, b]
self.grads = [np.zeros_like(W), np.zeros_like(b)]
self.x = None
def forward(self, x):
N, T, D = x.shape
W, b = self.params
rx = x.reshape(N*T, -1)
out = np.dot(rx, W) + b
self.x = x
return out.reshape(N, T, -1)
def backward(self, dout):
x = self.x
N, T, D = x.shape
W, b = self.params
dout = dout.reshape(N*T, -1)
rx = x.reshape(N*T, -1)
db = np.sum(dout, axis=0)
dW = np.dot(rx.T, dout)
dx = np.dot(dout, W.T)
dx = dx.reshape(*x.shape)
self.grads[0][...] = dW
self.grads[1][...] = db
return dx
Time Affineレイヤは、Affineレイヤの入出力に、時間軸方向のTに対応出来るようにそれぞれreshapeを付け加えたものです。
#8.TimeSoftmaxWithLoss
class TimeSoftmaxWithLoss:
def __init__(self):
self.params, self.grads = [], []
self.cache = None
self.ignore_label = -1
def forward(self, xs, ts):
N, T, V = xs.shape
if ts.ndim == 3: # 教師ラベルがone-hotベクトルの場合
ts = ts.argmax(axis=2)
mask = (ts != self.ignore_label)
# バッチ分と時系列分をまとめる(reshape)
xs = xs.reshape(N * T, V)
ts = ts.reshape(N * T)
mask = mask.reshape(N * T)
ys = softmax(xs)
ls = np.log(ys[np.arange(N * T), ts])
ls *= mask # ignore_labelに該当するデータは損失を0にする
loss = -np.sum(ls)
loss /= mask.sum()
self.cache = (ts, ys, mask, (N, T, V))
return loss
def backward(self, dout=1):
ts, ys, mask, (N, T, V) = self.cache
dx = ys
dx[np.arange(N * T), ts] -= 1
dx *= dout
dx /= mask.sum()
dx *= mask[:, np.newaxis] # ignore_labelに該当するデータは勾配を0にする
dx = dx.reshape((N, T, V))
return dx
Time Softmax with Loss レイヤー は、$x_t, t_t$のSotmax with Loss をT個合算してTで割るレイヤーです。
#9.日本語版データセットの作成
全体の理解を深めるために、日本語のデータセットでもやってみましょう。但し、単語単位でやろうとすると形態素解析が必要なので文字単位とします。今回は、青空文庫から「老人と海」をダウンロードして使っています。
import numpy as np
import io
def load_data():
# file_name の先頭から1000文字をUTF-8 形式で textに読み込み
file_name = './data_rojinto_umi.txt'
length = 1000
with io.open(file_name, encoding='utf-8') as f:
text = f.read().lower()
text = text[:length]
# word_to_id, id_to_ward の作成
word_to_id, id_to_word = {}, {}
for word in text:
if word not in word_to_id:
new_id = len(word_to_id)
word_to_id[word] = new_id
id_to_word[new_id] = word
# corpus の作成
corpus = np.array([word_to_id[W] for W in text])
return text, corpus, word_to_id, id_to_word
file_name
で指定したテキストファイルの先頭から1000文字分をUTF-8形式で読み込み。text, corpus, word_to_id, id_to_word を返す関数です。ちょっと動かしてみます。
text, corpus, word_to_id, id_to_word = load_data()
print('text_length = ', len(text))
print(text)
text
の長さは指定通り1000になっています。文字単位で用意すると、テキストはとてもコンパクトですね。
print('vocab_size = ', len(word_to_id))
print(word_to_id)
word_to_id
です。vocab_size
は236と思ったほど大きくはありません。これを使って text
の1文字1文字をidに置き換えて、corpus
を作成するわけです。
print('corpus_length = ', len(corpus))
print(corpus[:500])
corpus です。表示は、始めから500文字分のみにしています。
text2 = ''.join([id_to_word[id] for id in corpus])
print(text2)
corpus
の id を id_to_word
を使って文字に変換すると、この様に最初のテキストに戻ります。
さて、せっかくなので、テスト用のデータと答えを用意して、epoch毎にどの程度予測ができているのかを確認してみたいと思います。
# corpus からのサンプル
x = corpus[:50]
t = corpus[1:51]
print('x = ', x)
print('t = ', t)
# 文字による確認
text_x = ''.join([id_to_word[id] for id in x])
text_t = ''.join([id_to_word[id] for id in t])
print('text_x = ', text_x)
print('text_t = ', text_t)
# バッチ形式に変換
test_x = x.reshape(10, 5)
test_t = t.reshape(10, 5)
print(test_x)
print(test_t)
corpus
から50文字分を1文字づらしで x, t を取得します。一応、文字に変換して内容を確認しています。そして、モデルで使われているシェイプ(10, 5)に変換し、テスト用の予測データ test_x と test_t を作成しています。
def generate(self, xs):
for layer in self.layers:
xs = layer.forward(xs)
return xs
後、各epoch毎に予測をさせるために、simple_rnnlm.pyの最後に、このコードを追加しておきます。
#10.改造版RNNLM
それでは、最初に実行したコード ch05/train_custom_loop.py を元に、学習データの読み込み、テスト用データの推論実行 の2つの部分を修正追加して実行します。epoch数は、1000回です。
import sys
sys.path.append('..')
import matplotlib.pyplot as plt
import numpy as np
from common.optimizer import SGD
from dataset import ptb
from simple_rnnlm import SimpleRnnlm
# ハイパーパラメータの設定
batch_size = 10
wordvec_size = 100
hidden_size = 100
time_size = 5 # Truncated BPTTの展開する時間サイズ
lr = 0.1
max_epoch = 1000
# ----------- 学習データの読み込み -------------
text, corpus, word_to_id, id_to_word = load_data()
corpus_size = 1000
vocab_size = int(max(corpus) + 1)
# ----------------------------------------------
xs = corpus[:-1] # 入力
ts = corpus[1:] # 出力(教師ラベル)
data_size = len(xs)
print('corpus size: %d, vocabulary size: %d' % (corpus_size, vocab_size))
# 学習時に使用する変数
max_iters = data_size // (batch_size * time_size)
time_idx = 0
total_loss = 0
loss_count = 0
ppl_list = []
# モデルの生成
model = SimpleRnnlm(vocab_size, wordvec_size, hidden_size)
optimizer = SGD(lr)
# ミニバッチの各サンプルの読み込み開始位置を計算
jump = (corpus_size - 1) // batch_size
offsets = [i * jump for i in range(batch_size)]
for epoch in range(max_epoch):
for iter in range(max_iters):
# ミニバッチの取得
batch_x = np.empty((batch_size, time_size), dtype='i')
batch_t = np.empty((batch_size, time_size), dtype='i')
for t in range(time_size):
for i, offset in enumerate(offsets):
batch_x[i, t] = xs[(offset + time_idx) % data_size]
batch_t[i, t] = ts[(offset + time_idx) % data_size]
time_idx += 1
# 勾配を求め、パラメータを更新
loss = model.forward(batch_x, batch_t)
model.backward()
optimizer.update(model.params, model.grads)
total_loss += loss
loss_count += 1
# エポックごとにパープレキシティの評価
ppl = np.exp(total_loss / loss_count)
print('| epoch %d | perplexity %.2f'
% (epoch+1, ppl))
ppl_list.append(float(ppl))
total_loss, loss_count = 0, 0
# ---------- テスト用データで予測 ------------
pred= model.generate(test_x)
predict = np.argmax(pred, axis = 2)
print(predict)
# ------------------------------------------------
# グラフの描画
x = np.arange(len(ppl_list))
plt.plot(x, ppl_list, label='train')
plt.xlabel('epochs')
plt.ylabel('perplexity')
plt.show()
1000epoch後の perplexity は 1.08まで下がりました。各epoch毎に、テスト用データによる予測結果を表示しているので、1epoch後の予測結果を見ると全て5(ひらがなの「た」) です。学習したばかりの時は、こんな感じなんでしょう。これが、1000epoch後の予測結果は、結構それっぽくなりました。では、最終の予測結果を確認してみましょう。
予測結果を答え合わせし正解だったところを赤丸で囲んでいます。24/50ですので、正答率48%で、思ったほど高くはないです。5文字の後半の正答率が高いのは、前半の文字を踏まえて予測できるためでしょうか。1行目の予測結果をモデルで表してみると、
なるほど。こんな感じに間違えたわけですか。