前回の記事では言語モデルを作るという目的をふまえて一般的なRNNの構造について取り扱ったが、今回はPythonで実際に言語モデルを実装し、その言語モデルに自動で文章を吐き出させるプログラムを作ってみる。
モジュール構成
一般的なNNと同様、以下のモジュール構成になる。これは実装の一例であり、入出力や関数の切り分けパターンは他にも存在する。
- Forward Propagationを行う関数
入力:文 (のシーケンス)
出力:予測値 (のシーケンス)、隠れ層(
のシーケンス)
- 重み更新値(デルタ )計算する関数
入力:文 、(教師データのシーケンス)
- 更新値を重みに適用する関数
- エラー値計算する関数
入力:文 、(教師データのシーケンス)
- トレーニングを行う関数
入力:訓練データ、各種パタメータ(学習率, エポック数, バックステップ数, 学習率の低減率, バッチサイズ, etc.)
各モジュール
今回の実装では、以下のようにRNNクラスのインスタンス変数として重みや重み更新値を設定した。
class RNN(object): def __init__(self, vocab_size, hidden_dims): self.vocab_size = vocab_size self.hidden_dims = hidden_dims # matrices V (input -> hidden), W (hidden -> output), U (hidden -> hidden) self.V = random.randn(self.hidden_dims, self.vocab_size)*sqrt(0.1) self.W = random.randn(self.vocab_size, self.hidden_dims)*sqrt(0.1) self.U = random.randn(self.hidden_dims, self.hidden_dims)*sqrt(0.1) # aggregated weight changes for V, W, U self.deltaV = zeros((self.hidden_dims, self.vocab_size)) self.deltaW = zeros((self.vocab_size, self.hidden_dims)) self.deltaU = zeros((self.hidden_dims, self.hidden_dims))
Forward Propagation、つまり入力(e.g. “The bank went bankrupt”)から次単語列(e.g. “bank went bankrupt again”)の予測を行う関数predictは、次のように実装される。前記事の図に示したように、単語の長さに応じてレイヤーの枚数が変わっていくようなイメージだ。
def predict(self, x): ''' x list of words, as indices, e.g.: [0, 4, 2] y matrix of probability vectors for each input word s matrix of hidden layers for each input word ''' # NOTE: in this implement, s[0] = [0. 0. ... 0.] and s[t+1] is the hidden layer corresponding to y[t] s = zeros((len(x)+1, self.hidden_dims)) y = zeros((len(x), self.vocab_size)) for t in range(len(x)): x_vector = self.get_one_hot_vector(x[t]) net_in = dot(self.V, x_vector) + dot(self.U, s[t]) s[t+1] = sigmoid(net_in) net_out = dot(self.W, s[t+1]) y[t] = softmax(net_out) return y,s
単語はone-hotベクトルなので、文はマトリックスになるが、この実装では1であるインデックスだけを整数で表して文を1次元のベクトルとして表している。
今回、損失関数にはCross Entropy Lossを用いた。文一つの損失は各単語の損失
の和(あるいは平均)で表される。
今回はデータセットの評価にはmean_loss関数、つまり、データ全体でみた損失関数の単語ごとの平均を用いた。
def compute_loss(self, x, d): y, s = self.predict(x) loss_t = zeros(len(y)) for t in range(len(y)): d_t_vector = self.get_one_hot_vector(d[t]) y_t_vector = y[t] # dot product works as product & summation loss_t[t] = -dot(d_t_vector, log(y_t_vector)) # combined loss is summation of loss over t loss = sum(loss_t) return loss def compute_mean_loss(self, X, D): ''' X a list of input vectors, e.g., [[0, 4, 2], [1, 3, 0]] D a list of desired outputs, e.g., [[4, 2, 3], [3, 0, 3]] ''' sum_of_loss = 0 sum_of_length = 0 for i in range(len(X)): sum_of_loss += self.compute_loss(X[i], D[i]) sum_of_length += len(X[i]) # loss per word = total loss in the dataset devided by total number of words in the dataset. mean_loss = sum_of_loss / float(sum_of_length) return mean_loss
重み更新値デルタを計算する関数acc_deltas_bpttは以下のようになる。基本的に、前回記事の更新式をそのままコードにしただけである。遡るタイムステップstepsはハイパーパラメータで、大きいほど正確だが計算量は大きくなる。
def acc_deltas_bptt(self, x, d, y, s, steps): ''' steps number of time steps to go back in BPTT no return values ''' net_out_grad = ones(len(x)) net_in_grad = array([s_t * (ones(len(s_t))- s_t) for s_t in s]) sum_deltaW = zeros((self.vocab_size, self.hidden_dims)) sum_deltaV = zeros((self.hidden_dims, self.vocab_size)) sum_deltaU = zeros((self.hidden_dims, self.hidden_dims)) # NOTE: in this implement, s[0] = [0. 0. ... 0.] and s[t+1] is the hidden layer corresponding to y[t] (same in net_in_grad) for t in reversed(range(len(x))): d_t_vector = self.get_one_hot_vector(d[t]) delta_out_t = (d_t_vector - y[t]) * net_out_grad[t] sum_deltaW += outer(delta_out_t, s[t + 1]) delta_in = zeros((len(x), self.hidden_dims)) for tau in range(0,1+steps): if t - tau < 0: break if tau == 0: delta_in[t - tau] = dot(self.W.T, delta_out_t) * net_in_grad[t + 1] else: delta_in[t - tau] = dot(self.U.T, delta_in[t - tau + 1]) * net_in_grad[t - tau + 1] sum_deltaV += outer(delta_in[t - tau], x[t - tau]) sum_deltaU += outer(delta_in[t - tau], s[t - tau]) # multiply learning rate when actually applying delta self.deltaW = sum_deltaW self.deltaV = sum_deltaV self.deltaU = sum_deltaU
トレーニングの関数は直接掲載しないが、基本的にはエポックごとに繰り返し、各エポックでトレーニングセンテンスごとにacc_delta_bpttを実行する(誤差逆伝搬でデルタを計算する)。計算し終わったら重みを更新し、次のエポックに移る流れとなる。各エポックごとにだんだんと学習率を下げるのが一般的で、どれくらいのペースで下げていくかもハイパーパラメータのひとつである。今回は、を
エポックでの学習率として、以下を用いた。
は実数値で、今回は
を用いている。
トレーニング結果と文章生成結果
今回は隠れ層のユニット数 を(10, 50, 100)、BPTTにおいて遡るタイムステップ数
を(0,3,10)、学習率の初期値
を(0.5, 0.1, 0.05)の範囲で変更し、計27通りのパラメータの組み合わせで実験を行った。なお、コーパスはPenn TreebankのWSJ Corpusを利用している。(トレーニングに時間がかかるのでトレーニングセンテンスを1000個に絞った。)なお、ボキャブラリーサイズ
は2000、エポック数は25である。
各パラメータの平均損失(mean loss)の値は以下の表のようになった。
それぞれ(
の時の値,
の時の値,
の時の値)を表す。結果として、
が最も良い組み合わせだとわかった。
次に、この組み合わせでフルトレーニングセット(6万件くらい)でネットワークを鍛えなおし(1エポックに30分近くかかった)、できた言語モデルを使って自動で文章を生成させてみた。以下が、文章作成関数generateのコードである。
def generate_sequence(self, start, end, maxLength): sequence = [start] loss = 0. x = [start] while True: # predict next word from current sequene x y,s = self.predict(x) # generate next word by sampling the word according to the last element of y word_index = multinomial_sample(y[-1]) x.append(word_index) sequence.append(word_index) pointwise_loss = -log(y[-1][word_index]) loss += pointwise_loss if word_index == end or len(sequence) > maxLength: break return sequence, loss
文頭記号から<s>出発し、predict関数を使ってベクトルを計算し、その確率分布に応じて次の単語を選択、今までのセンテンスと結合させて再度predict関数で次の単語を・・という流れで文章を作り、</s>に到達したらセンテンスを返す という処理だ。
出力結果のうち、特にmean lossの小さかった例が以下である。
mean loss: 3.36850018043 [’<s>’, ’for’, ’offered’, ’market’, ’.’, ’,’, ’that’, ’says’, ’,’, ’</s>’] mean loss: 3.63943955362 [’<s>’, ’net’, ’was’, ’to’, ’share’, ’the’, ’</s>’] mean loss: 3.78469000827 [’<s>’, ’these’, ’has’, ’the’, ’resources’, ’the’, ’he’, ’,’, ’.’, ’it’, ’which’, ’fund’, ’</s>’]
・・微妙である。
“, that says,” とか “was to share”とか部分的に意味の通っている表現は見受けられるものの、全体を通しては意味不明な文章が多い。文章生成だけでいったらマルコフ連鎖モデル(品詞→品詞の遷移(transition)確率と品詞→単語のemission確率を組み合わせるモデル)なんかの方がいいかも。
ひとつの原因として、ボキャブラリーサイズが2000だと、one-hotベクトルを使っているので生じたベクトルがスパースになってしまうからだと考えられる。ベクトルの一番大きな値でも0.01か0.02(つまり一番出やすい単語でも1%か2%の確率、他が0.5%くらい)というケースが多く、この分布に従ったらほとんどランダムに単語をピックアップしているようなものだ。ピックアップのアルゴリズム(例えば上位100単語だけ見て確率を正規化し直すとか)を変えるだけで性能は向上するかもしれない。
ハイパーパラメータやエポック数、学習アルゴリズムが原因かもしれないので、その点はもう少し検討して見る余地はあるかも。
まとめ
言語モデルにおいて最も伝統的なNgramは、直前N単語前の単語まで考慮して単にコーパス内の単語のつながりをカウントしまくって単にカウントとカウントの割り算で確率を計算する、というシンプルなモデルであった。
このモデルの問題点は、コーパスにある単語のつながりしかカウントでいないので、実際には出現する可能性があってもコーパスに出ていないというだけで単語の生起確率が0になってしまう、というものだった。
これを解消する手法が単語カウントに他の単語カウントから得られた適当な数を足して0をなくすというSmoothingであるが、これにもいろいろな問題があった(ここでは省略!)。とにかく、Ngramは汎化能力が無いという点で弱い。
この点、RNNは単語をカウントしているのではなく、重みを学ぶことで単語と単語のつながり方を学習しているようなものなので、汎化能力が非常に高い。つまり、コーパスに無い初見の単語でも確率0を与えないという汎化能力を持っている。
しかし、今回のようなモデルではone-hotベクトルを利用するという性質上ベクトルがスパースになるという(おなじみの)問題は避けられない。今回のRNNアーキテクチャは単語の分散表現(embedding)ベクトルでも応用可能なのか、少し考えてみる必要はあるかもしれない。
RNNは何も言語のために作られたモデルではないので、シーケンシャルなデータならなんでも応用できる。各タイムステップでの画像を入力にして動画を解析したり、株価の推移やFX取引のアルゴリズムとして使ったり、いろいろなことができそう。
今はフルスクラッチで書かなくても、Chainerのような便利なライブラリがあるので、実践で使うならそちらの方が便利だろう。ただ、基礎を理解するのはいつでも大切なので、原理を理解する意味でフルスクラッチしてみるのも悪く無いかもね。
[…] RNNで言語モデルを作る – 実装編 […]