「ゼロから作る Deep Learning 2 自然言語処理編」の読書まとめ。後で思い出すためのメモです。コードは以下。図は転記しません。本を片手に読む感じ。
5章 リカレントニューラルネットワーク(RNN)
時系列のデータを性質を学習するためのモデル。RNNは言語モデルとしての基礎となります。それらの解説の章です。
確率と言語モデル
まずはword2vecのCBOWで次に出てくる単語を推論するケースを考えてみます。これまでは両脇から中央の単語を推定するものでしたが、過去の単語から次の単語を推定することも原理的にはできそうです。もともとCBOWはコンテキストからターゲットをより正確に推測できるように学習されています。この学習の過程で単語の意味がエンコードされた「単語の分散表現」が得られています。
ややこしい確率の式が出てきますが、要点は有限のコンテキストで未知の単語を推定するのは限界があり、またCBOWでは単語の並びを無視しているというところに言語モデルとして使うことが難しい理由がある。という点です。もともと目的が違うってことですね。
RNNとは
ループする経路を持つネットワーク構造を持つモデル。過去の情報を記憶しながら最新のデータへと更新されます。出力が自分自身の入力になります。
\(x_0, x_1, x_2, … x_t …\)というデータが順に入力されるにあたり対応する\(h_0, h_1, h_2, … h_t, …\)が出力されます。\(x_t\)は例えばword2vecで解釈された各単語のベクトルデータで、例えば\(h_t\)はその次に出てくる単語を予測したもの。とかそんな感じです。この\(h_t\)を隠れ状態とか隠れ状態ベクトルと呼ばれるようです。式は
$$ h_t = \tanh(h_{t-1}W_h + x_tW_x + b) $$
で表現されます。よく見る式です。なぜか活性化関数はtanhを使うようです。重みは2つ。バイアスが1つ。
ループで書かれるとなじみがないですが、展開して横につなげるとよく見るネットワークと変わらず、誤差逆伝播も可能です。最初に順伝播を行い、続いて逆伝播を行うことできちんと勾配を求めることができます。「時間方向に展開したニューラルネットワークの誤差逆伝播法」ということで、Backpropagation Through TimeもしくはGPTTと呼ばれるようです。
ただこの誤差逆伝播ですが、時間方向に長く続くと層が深くなり計算やメモリの問題で大変になってきます。そこで途中でバツっと切って小さいネットワークにして学習させる方法をとるそうです。これがTruncated BPTTと呼ばれる手法です。これは計算上逆伝播のつながりを断つだけで、順伝播は普通に(バツっと切らず)流していきます。学習済みのデータWを使って推論することを考えれば特に違和感はないです。学習時のforwardでもつながりは維持します。順伝播の\(h_t\)はつなげ、勾配を過去にさかのぼらせないようにします。
当然バッチ学習も考える必要があります。これも別にややこしいことはなく、バッチサイズが2であれば、1000の入力を500ずつ2つに分け0番~499番、500番~999番までを並行して学習させるだけです。おそらく0と500に対してはそれより前の\(h_t\)は無い状態(オールゼロ)で学習開始となります。連続が大事なのでこれまでよく使われていたように、乱数とかで順番を狂わせるようなことはしてはいけません。
RNNの実装
横方向に同じモデルを繰り返したネットワークを作ればよいことになります。方針としてはRNNクラスを作って、それを横に複数(T)束ねたTime RNNクラスを作っていきます。
RNNクラスでは、前述の
$$ h_t = \tanh(h_{t-1}W_h + x_tW_x + b) $$
を実装すればよいです。バッチサイズNでこの式に従って作ればよく、
RNN:
def __init__(self, Wx, Wh, b):
self.param = [Wx, Wh, b]
self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
self.cach = 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.cahc = (x, h_prev, h_next)
return h_next
式の通りです。キャッシュするのは逆伝播の勾配算出用
逆伝播は
def backward(self, dh_next):
Wx, Wh, b = self.params
x, h_prev, h_next = self.cach
dt = dh_next * (1 - h_next ** 2) # tanhの微分に引数を乗算(tanh内の勾配)
db = np.sum(dt, axis=0) # b更新用の勾配、複数バッチ分SUM?
dWh = np.dot(h_prev.T, dt) # Wh更新用の勾配
dh_prev = np.dot(dt, dW.T) # 後ろに伝える勾配
dWx = np.dot(x.T, dt) # Wx更新用の勾配
dx = np.dot(dt, Wx.T) # 後ろに伝える勾配
self.grads[0][...] = dWx
self.grads[1][...] = dWh
self.grads[2][...] = db
return dx, dh_prev
これも本のグラフを見ながら読めば何とかついていけます。
Time RNNクラスは、前述のRNNをT個束ねたクラスです。
TimeRNN:
def __init__(self, Wx, Wh, b, stateful=False):
self.param = [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
self.stateful = stateful
def set_state(self, h):
self.h = h
このstateful変数は前の状態hを受け付けるか否かのフラグです。手前のTimeRNNの結果hはset_stateで受ける形になっています。
次に順伝播
def forward(self, xs):
Wx, Wh, b = self.params
N, T, D = xs.shape # Nがバッチサイズ、TがRNNの個数、Dが入力xのベクトルサイズ
D, H = Wx.shape # Hがhのベクトルサイズ
self.layers = []
hs = np.empty((N, T, H), dtype='f')
if not self.statefull or self.h is None:
self.h = np.zeros((N, H), dtype='f')
for t in range(T): # RNNの個数で順方向にループ
layer = RNN(*self.params)
self.h = layer.forward(xs[:, t, :], self.h) # 入力xのt番目とひとつ前のhをNバッチ分入力
hs[:, t, :] = self.h
self.layers.append(layer)
return hs
かろうじてついていけます。気を付けたいのはT個すべてのRNNで同じWx,Wh,bを使っている点です。なんとなく複数のレイヤで異なる重みを持つような感覚になっていましたが、同じ重みを使います。考えてみると当たり前で、文章の頭から入れた場合と、1文字後から入れた場合で同じ答えを推定してほしいですもんね。
続いて逆伝播
def backward(self, dhs):
Wx, Wh, b = self.params
N, T, H = dhs.shape # Nがバッチサイズ、TがRNNの個数、Hがhのベクトルサイズ
D, H = Wx.shape # Dが入力xのベクトルサイズ、Hがhのベクトルサイズ
dxs = np.empty((N, T, D), dtype='f')
dh = 0 # となりから逆伝播してこない(Truncated)
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): # Wh,Wx,bのループ
grads[i] += grad # 全RNNレイヤで同じWh,Wx,bだから全部足す。
for i, grad in enumerate(grads):
self.grads[i][...] = grad
self.dh = dh
return dxs
何とか理解はできました。
時系列データを扱うレイヤの実装
ひとまずT個のRNNを持つTimeRNNを実装できました。今度はこいつに前後の処理つないでRNN Language Model (RNNLM)を作ります。
最初の層はEmbeddingレイヤです。単語IDから単語の分散表現を得るための層です。
次の層はRNNレイヤです。
そして最後はAffine(行列の掛け算)をしてSoftmaxへ伝わります。
第一層のEmbeddingレイヤはシンプルにT個の単語IDを受けて、T個の分散表現を得るだけです。一つ持っていればT回繰り返すだけなので、重みは1つ。単語IDの数(語彙数)から分散表現へ変換。最終層も同じようなものです。ソースコードはかなりテクニカルな感じですが。
Softmaxの結果に対してそれぞれLossを求めて、Tで割る(平均を出す)ってので最終的なLossを計算します。いろんなところで平均の丸めが入っているので文章が長いとそれはそれでいい感じにならなそうな気がします。とはいえ文章が長くないと正確な推論もできないでしょうから。バランスが大事ですかね。
てっきりEmbeddingレイヤは学習済みのそれを使うのかと思いきやこれも一緒に学習させるようですね。
こんなしてRNNLMのモデルが完成します。今度は精度をどのようにして評価するかです。ここではパープレキシティという指標を使います。これは確率の逆数で小さければ小さいほど良いというものです。例えば80%の確率である単語Aである。という結果が出たとすればその逆数は1.25。逆に20%の確率である単語Aである。という結果であれば逆数で5。となります。これは意味合い的にほかにどれだけの候補があるか?を表す数字ととらえることができ、20%であれば候補が5単語あるよ。ってのは意味的にはしっくりきます。
これがN個の単語の場合はどうなるか?だいぶ式がややこしいです。
$$ L = -\frac{1}{N}\sum_n \sum_k t_{nk}\log y_{nk} \\perplexity = e^L $$
tはone-hot形式で、k個のなかのどれかしか1ではないデータです。N=1を入れると、\(e^{-(\log y_{nk})}\)になってその通りになるのでいったんよしとします。このLですが、ネットワークのクロスエントロピーLossそのものを意味しています。なのでこの結果をeに対して累乗してあげればよい。ということになります。いろいろなところがつながっています。何ならシンプルにLossの大小で評価すればいいのに。とも思いますが候補数って概念の方がわかりやすいからですかね。
学習部の実装に関しては省略。パープレキシティをプロットするとどんどん下がっていくことがわかります。
6章 ゲート付きRNN
RNNの改良版であるLSTMとGRUに関してです。RNNと言ったとき実際にはこのLSTMであるケースが多いです。これらはゲート付きRNNと呼ばれたりします。
RNNの問題点
これまでのRNNは長い記憶ができませんでした。長期の依存関係を学習しにくい仕組みになっているからです。逆伝播の途中で勾配が消失もしくは爆発してしまうことが主な理由です。
過去にさかのぼって意味のある勾配を伝播させることができにくい。というのが問題点になります。一部解釈が違うかもしれませんが、この例文(Tom was watching TV in his room. Mary came into the room. Mary said hi to [?])のようにTomという人物の固有名詞に関して、[?]まで覚えてられるの?というのを題材に考えてみます。
その前の単語がおそらく何か次に続くのが何らかの人物固有名詞だ、というところまではある程度推論できるのだと思います。そのうえでじゃあそれがTomだ。と断じるにはその手前の記憶をたどる必要があります。そこに向けてTom以外の人物名詞含め大きな誤差が出ることになると思います。
その誤差がtanh、MatMulを通って戻っていきます。まずこのtanhですが、グラフを見ると一目瞭然で、微分すると値が0~1でxが遠ければ遠いほど値が小さくなります。すなわちこれをT回通せばT乗で値がどんどん小さくなっていきます。これで勾配が消失してしまいます。
今度はMatMulですが、これも式を見ればわかるようにWhが次々と乗算されているので、逆伝播する際にはWhがT回乗算されていきます。同じWhが乗じられるということは、それが1より大きければどんどん大きくなっていくし、1より小さければどんどん小さくなっていきます。このどんどんは指数関数的にです。大きくなるのであれば爆発、小さくなるのであれば消失。厳密には行列なので若干意味合いは違いますが、行列の場合特異値(特異値の中の最大値)が1より大きいか否かで大きく振れるか小さく振れるかが変わってくる(そうです)。
勾配爆発への対応
こちらは比較的シンプルで、勾配クリッピングというやり方がメジャーだそうです。勾配のL2ノルムが閾値を超えた場合に 閾値/L2ノルム (必ず1より小さい)を勾配に乗じるというやり方で頭打ちさせます。
コードを見た方がわかりやすいです。
dW1 = np.random.rand(3, 3) * 10
dW2 = np.random.rand(3, 3) * 10
grads = [dW1, dW2]
max_norm = 5.0
def clip_grads(grads, max_norm):
total_norm = 0
# L2ノルム算出
for grad in grads:
total_norm += np.sum(grad ** 2)
total_norm = np.sqrt(total_norm)
# 先に割り算
rate = max_norm / (total_norm + 1e-6)
if rate < 1: # 閾値処理
for grad in grads:
grad *= rate
if文で若干混乱しますが、先に割り算済ませといてから1と比較してるだけです。
勾配消失とLSTM
こちらはちょっと難解でモデルを大きく変えます。だいぶややこしくなります。
RNNの基本は
$$ h_t = \tanh(h_{t-1}W_h + x_tW_x + b) $$
です。ここで過去から伝播してくるhともう一つcというのも過去から引っ張ってくることにします。このcは答えとして出すものではなく、過去の記憶をつなげるだけの役割です。過去の答えだけでなく記憶も引っ張ってくるという考え方です。答えは記憶に対してtanhして求める。というのが基本です。文章としてわかりやすくするためにhを答えcを記憶と記載します。
$$ \tanh (c_t) $$
さらにここへゲートという概念を入れます。出力(採用率)を0~1の間でコントロールするためのもので、ここではsigmoidが都合がよいのでそれを使います。過去の答えと入力から出力採用率oを求めます。
$$ o = sigmoid(x_tW_x^{(o)} + h_{t-1}W_h^{(o)} + b^{(o)}) $$
この\(o\)と\(\tanh(c_t)\)をアダマール積をとって絞り込みます。(sigmoidなので必ず1より小さくなるので絞り込むという表現を使います。)行列の積ではなく、要素ごとの積です。ゲートという概念はこういった振る舞いからそう呼ばれるようです。
続いて入力にも同じようにゲートをかけます。記憶\(c_{t-1}\)に対してどれだけ覚えておくか(忘れるか)をゲートで制御します。計算は同じ。forgetなのでfです。
$$ f = sigmoid(x_tW_x^{(f)} + h_{t-1}W_h^{(f)} + b^{(f)}) $$
仕組みは同じ。ゲートとして機能します。
続いて記憶の更新です。過去の記憶から現在の入力をもとに記憶を更新します。これはゲートで制御するのではなくtanh()の計算結果を加算することで実現させます。これは今度RNNのときの計算と同じように、過去の答えと入力から求めます。RNN的計算はここに出てきます。
$$ g = \tanh (x_tW_x^{(g)} + h_{t-1}W_h^{(g)} + b^{(g)}) $$
これで記憶の更新(追加)ができます。
さらにもう一つゲートを追加します。これはこの記憶の追加用の項に対してゲートをかけます。このゲートはinputのiをとります。このゲートを先の記憶の更新結果に対してかけます。どれだけ新しい記憶に今の入力や過去の答えを反映させるかをつかさどります。
$$ i = sigmoid(x_tW_x^{(i)} + h_{t-1}W_h^{(i)} + b^{(i)}) $$
大量の変数が出てきましたがいったんこれでLSTMの基本形は終わりです。
ではなぜこれで勾配消失が抑制できるか?ですが、記憶の部分に対する演算に着目するとその逆伝播の中には先ほど消失で問題になった行列積もtanhも出てこないからです。
cに対してだけ着目すると行われる計算はfによるゲートの乗算と記憶の更新用の加算だけです。しかもfのゲートは行列積ではなくアダマール積であり、またこの積算する値も入力xによって揺らぎます。
この時忘れていいと判断されるような過去からの記憶と入力の組み合わせの時にはゲートが狭くなり、逆伝播は起きにくく、逆に覚えておく必要がありそうな場合にはゲートが広がる。そんな動きをとることになります。
LSTMの実装
LSTMの4つの式を見ると皆同じ形をしています。これはおそらく狙ったのだと思います。というのも実装時にすべてまとめて計算できてしまうからです。
\(h_{t-1}\)とxに対しての重みという形はすべて同じなので、行列をすべて横に連結して掛け算をし、結果を横で切れば等価な演算になります。このテクニックを使って実装を高速化します。その後それぞれをsigmoidとtanhにかければよいことになります。コード上では
def forward(self, x, h_prev, c_prev):
Wx, Wh, b = self.params
N, H = h_prev.shape
A = np.dot(x, Wx) + np.dot(h_prev, Wh) + b
f = A[:, :H]
g = A[:, H:2*H]
i = A[:, 2*H:3*H]
o = A[:, 3*H:]
f = sigmoid(f)
g = np.tanh(g)
i = sigmoid(i)
o = sigmoid(o)
c_next = f * c_prev + g * i
h_next = o * np.tanh(c_next)
self.cache = (x, h_prev, c_prev, i, f, g, o, c_next)
return h_next, c_next
このようになっています。すでにWh,Wxはコンカチネートされていて、行列積をとった後にスライスして必要な個所を切り出しています。そのうえでsigmoidかけて、cとhを更新しています。
逆伝播は
def backward(self, dh_next, dc_next):
Wx, Wh, b = self.params
x, h_prev, c_prev, i, f, g, o, c_next = self.cache
:
dA = np.hstack((df, dg, di, do))
dWh = np.dot(h_prev.T, dA)
dWx = np.dot(x.T, dA)
db = dA.sum(axis=0)
self.grads[0][...] = dWx
self.grads[1][...] = dWh
self.grads[2][...] = db
dx = np.dot(dA, Wx.T)
dh_prev = np.dot(dA, Wh.T)
return dx, dh_prev, dc_prev
一部省略しましたが、hstackでコンカチネートさせてます。逆でややこしいですが、順伝播ではコンカチネート済みのWを使ってからスライスして計算。逆伝播ではそれぞれのWをコンカチネートしてから行列積。という流れになっています。
全体の実装はTimeRNNの時と同じようにT回で束ねて、TrancatedBPTTをさせられるようにしています。コンカチネート済みWを使っているので、見た目としてはcに関する部分が増えたのと、RNNの部分がLSTMに変わっただけです。よくできています。
LSTMを使った言語モデル
これもTimeRNNだったところをTimeLSTMに変えるだけです。この状態でもパープレキシティは136程度まで行くそうです。10000語の中から136語に絞り込みができたということになります。
RNNLMのさらなる改良
まだ手直しが必要です。大きく3つの点を直します。
まずはLSTMレイヤの多層化です。これはDeepLearningの基本ですかね。層を重ねて、cとhのセットを複数重ねることにより精度を上げます。下の層の答えhを上の層の入力につなげます。記憶はつなげません。せっかくの記憶のシンプルなルートが複雑になってしまい当初のもくてきだった勾配消失の問題が出てしまいますし。
続いてDropoutによる過学習の抑制です。これも基本ですかね。L2正則化も有効です。そのDropoutですが、どこに入れるかというと、深さ方向のレイヤの間に入れるのが基本だそうです。横の時間軸方向にDropoutさせるとせっかくの記憶の蓄積がランダムに失われノイズになってしまうからだそうです。ただそれも工夫で何とかなるらしく、変分Dropoutというので何とかなるらしいですが、詳細不明です。ちなみに同じ層で適用するDropoutパターン(マスク)は同じものをとるのが基本らしいです。どの時間でも同じようにネットワークが切られている。という状態が良いようです。直感的にはわかります。当然このパターンはイテレーション毎、ないしバッチ毎に違うのでしょう。
最後に重み共有です。これはしっくりこないのですが、AffineとEmbeddingで重みを共有するとよいそうです。確かにパラメータ数が減り更新が容易になって学習効率が上がり過学習が減る。というのはなんとなくわかるのですが、同じでいいんかい?というのはちょっとピンとこないです。
この3つを実装しますが、いやぁこれはもはやコードを追っかけるのが大変。説明通りにつながってそうですが、だいぶ複雑になってます。こいつを動かすとどうやらパープレキシティは76程度まで行くそうです。
ただ最先端はこんなもんじゃない。この本が執筆された当時のデータですが、52.8という数字まで出ているそうです。層の深さやDropoutの工夫などなどいろいろ駆使して到達しているものと思われます。
コメント