「ゼロから作る Deep Learning 2 自然言語処理編」の読書まとめ。良著です。一度読んだものを後で思い出すためのメモです。コードは以下。図は転記しません。本を片手に読む感じ。
7章 RNNによる文章生成
基本はRNN(LSTM)を使って文章を生成させていきます。RNNでとある単語を入れた時にその次に出てくる単語をある程度推定できるようになりました。これを使って、機械翻訳や文章生成をすることを考えます。
まずはモデルに何か適当な文章を作ってもらいます。
RNNを使えばある単語の次に出てくるであろう単語が確率的に(Softmax的に)求められます。求まったそれを次の入力して、これを連続的に求めれば文章になります。
実装は元のクラスを継承してgenerate関数作ることで行います。やってることはpredictを連続的に呼んでいるだけです。predictした結果に対してsoftmaxして得られた確率分布に応じて回答をランダムにチョイスしています。これをsample_sizeに達するまで繰り返しています。
def generate(self, start_id, skip_ids=None, sample_size=100): word_ids = [start_id] x = start_id while len(word_ids) < sample_size: x = np.array(x).reshape(1, 1) score = self.predict(x) p = softmax(score.flatten()) sampled = np.random.choice(len(p), size=1, p=p) if (skip_ids is None) or (sampled not in skip_ids): x = sampled word_ids.append(int(x)) return word_ids
これに対して適当な重みでgenerateさせた時と、学習済みデータでgenerateさせた結果を比較しています。自然な文章に近づいているさまがわかります。
モデルをLSTMへ変えます。するともう少しよくなります。実装は同じようにLSTMクラスを継承してgenerateを付けるだけです。
seq2seq
RNN(LSTM)2つをEncoder/Decoderとして使って、とある文章に対しての返答をしてもらうモデルです。この例では翻訳のモデルが出ています。「吾輩は猫である」を「I am a cat」と訳してもらう例です。
Encoderに対して吾輩は猫であるを入れるとDecoderがI am a catを返してくれるようなものです。
Encoderは途中状態(「吾輩」や「猫」を入力した時)のhを出力しない(AffineとSoftmaxをしない)で最後のhを出力します。ここに吾輩は猫であるの情報が詰まっています。ポイントはhは固定長のベクトルであるという点。任意の長さの文章をRNNを使って固定長のベクトルに変換できた。ということになります。
続いてDecoderですが、これはhを受けて、続く単語を推論し、その推論結果を次の入力として続く単語を推論し、といった具合で文章を作っていきます。こちらはAffineとSoftmaxが必要です。この一連の流れがseq2seqになります。
この2つをくっつけて返答(この場合翻訳)の例を学習データとして用意し、ひたすら学習させるといい感じになります。この本は文章としての足し算を試しています。計算の文字一つ一つのシーケンスを文章として、式にあたる文章を入れ、答えにあたる文章を得るといった具合です。
実装ですが、EncoderとDecoderのクラスをそれぞれ作っています。差分は最後のAffineがあるかないかだけで、Lossを求めるためのSoftmaxはDecoder側には実装されていません。外のseq2seqのクラスで持っています。Encoderの順伝播時の出力は最後のhだけ。またDecoderの順伝播の振る舞いも学習時と推論時で異なります。学習時には入力xは与えられていますが、推論時は前の推論結果が次の入力になります。
Seq2seqクラスの実装はこのEncoderとDecoderをつなぎ合わせて、Softmax with Lossを使って損失を出しているだけです。
ただこの実装のまま評価しても結果がイマイチのようで、改良すべき点がまだあるそうです。
その一つが入力データの反転。うまくいく理由がイマイチしっくりきません。入力をひっくり返して、正解データはひっくり返さない。もちろんテストデータもひっくり返す。逆順に文章を入れたほうがそれらしく学習が進むということのようです。その場合もちろん推論時も逆から入れることになります。学習時の勾配が届きにくいのであれば、今回の足し算の文章のケースだったら、入力文章を後ろ詰めにしとけばよかったんじゃないか…?と思います。
もう一つが覗き見。Peekyと言うらしいです。こちらはしっくりきます。Encoderが出力するhをDecoder側のすべてのLSTMレイヤ、Affineレイヤにも入力してやる。という方法です。パラメータ数が増えるので規模も大きく、学習時間も伸びるでしょうが、効果はありそうです。ここまでやるとだいぶ正答率もよくなるようです。
このようにしてできたSeq2seqの応用例はいろいろあります。機械翻訳や自動応答、チャットボット、メールの自動返信など。変わったところではソースコードを入力とし、実行結果を返すようなモデルも考えられるようです。またCNNと合体させ、画像にキャプション(説明文)をつけるようなモデルもSeq2seqの応用の一つです。CNNの出力結果をDecoderのLSTMが受けられるhの形に直したうえで、同じようにDecodeすることで画像の解説文を生成させるようなモデルです。(im2txtと言うそうです)
8章 Attention
seq2seqにAttentionという仕組みをさらに導入していきます。Encoderから受け取る情報を増やして、そこに入力をもとにした注意を向けるといった仕組みです。
いろいろ改良を加えたseq2seqですが、まだ問題を残しています。これは入力がどんな長さのsequenceでも必ず固定長のベクトルにEncodeしてしまう点です。長い文章をEncodeしたときには情報が詰め込まれてしまい、情報がうまくDecoderに伝わりません。これをいくつかの手順で解決していきます。
手っ取り早く解決させる手段として、可変長のベクトルにEncodeすることを考えます。これまで捨てていた各時間Tでの隠れ状態hを「すべて」続くDecoderに伝えてやることを考えます。5つのセンテンスで作られた文章であれば5つのhをつなぎ合わせたhsを新たなエンコード結果として用いるようにします。
この5つのセンテンスがそれぞれ強く影響を受けた5つのhが出来上がることになります。「吾輩」「は」「猫」「で」「ある」の5つであれば、「吾輩」の影響を強く受けたh、「は」の影響を強く受けたh、「猫」の影響を受けたh、といった感じで5つがつながったhsがEncodeされます。これで可変長のベクトル出力ができるようになりました。
次にこの可変長ベクトルを受け取ったDecoderの改良です。これまで最後のセンテンスの固定長の隠れ状態hを受け取ってDecodeを開始していましたが、これはすなわちhsの最後の状態だけを受け取る形でした。今回可変長になっています。他のhもどうにかして反映させる必要があります。
Decode時には、hsの中からその時刻Tでの入力xと一番相関の高い形でEncodeされたセンテンスの隠れ状態hを選択して使うのが一番合理的です。例えば翻訳タスクであれば「吾輩」をエンコードしたhを「I」の入力で選択するのが一番よさそうです。この選択という動作は微分ができないので何か別の微分可能な手段で代替します。各hに対する重みaを乗じることでこれを実現させます。先の「I」に対して「吾輩」を選びたければ、「吾輩」に該当する1つ目のhに対してだけ1を乗じ、それ以外に対して0を乗じるようなaが理想です。ですが必ずしもそうはならず例えば0.8とか他に比べて十分大きい値になっていればひとまずよしです。このaの総和は1になります。このような重みで選ばれた(重み付き総和で求まった)ベクトルをc(コンテキストベクトル)と呼ぶことにします。
このコンテキストベクトルcには、ある時刻Tでの入力を翻訳するために必要な情報が含まれるようにデータから学習されます。
この重みaからコンテキストベクトルcを求めるコードは
T, H = 5, 4 hs = np.random.randn(T, H) # (5x4) a = np.array([0.8, 0.1, 0.03, 0.05, 0.02]) ar = a.reshape(T, 1).repeat(H, axis=1) # (5x4) t = hs * ar c = np.sum(t, axis=0)
こんなイメージ。aは学習の結果求まるパラメータ。このコードでは適当に決めています。aをhsと同じサイズの行列にしていますが、ここでの演算は行列の積ではなく、アダマール積です。そのうえでsumをとってもとのhと同じ大きさHのコンテキストベクトルcを求めています。
バッチ数Nを同時に計算する場合は
N, T, H = 10, 5, 4 hs = np.random.randn(N, T, H) # (10x5x4) a = np.randomrandn(N, T) ar = a.reshape(N, T, 1).repeat(H, axis=2) # (10x5x4) t = hs * ar c = np.sum(t, axis=1)
となり、ほぼ同じコードで実現できます。hsとarは逆伝播計算用にcacheします。
逆伝播はrepeatしたものはsum、sumしたものはrepeatなのでそのコードは
dt = dc.reshape(N, 1, H).repeat(T, axis=1) # sumの逆 dar = dt * hs # 逆をかける dhs = dt * ar da = np.sum(dar, axis=2) # repatの逆
となります。
では続いて重みaの算出に関してです。aは入力「I」と「同じような」特徴を持つ「吾輩」hをhsの中から選び出すための重みなので、入力「I」をもとに算出されたhと類似度の高いすなわち内積が一番大きい値を持つものがそれに該当しそうです。「同じような」を示せればよいので必ずしも内積である必要はないのですが、一番手っ取り早いのでこれを使います。内積なので計算も先のcを求めるそれと近く、リピートしてアダマール積をとった後に和をとればよいです。こうして得られたベクトルsは合計が1にはならないので、softmaxをとって正規化します。このようにしてaを求めることができます。
同じようにバッチサイズN、エンコーダの総時間T、hのサイズHとしてaを求めるコードは
N, T, H = 10, 5, 4 # TはEncoderのT hs = np.random.randn(N, T, H) # (10x5x4) h = np.randomrandn(N, H) # 入力「I」から求めたh hr = h.reshape(N, 1, H).repeat(T, axis=1) # (10x5x4) t = hs * hr s = np.sum(t, axis=2) # (10x5) a = softmax.forward(s) # (10x5)
先のcを求めるコードとよく似ています。
このaを求めるレイヤとcを求めるレイヤの2つをつなげたものをAttentionレイヤと呼ぶことにして、このレイヤの出力であるコンテキストベクトルcと、元の隠れ状態hを結合(コンカチネート)して後続のAffineへ繋げます。
これでもとのseq2seqに対してAttentionの追加が完了です。
この実装コードは本書記載の通り。続いて評価を行います。ただ翻訳タスクは学習データが膨大なので「日付フォーマット変換」タスクで性能確認します。
結果としては一目瞭然。Attentionの勝利。より複雑なタスクになればより差は開いていくでしょう。
Attentionの重みaの学習結果は可視化することができます。意味のある学習結果になっているか人が目で見て確認することができます。翻訳前後の単語で相関が高い(「吾輩」と「I」のように)位置で値が大きくなるかを確認することができます。
この日付フォーマット変換タスクにおいて正しく対応する語どうしで大きな値が出ていることが確認できます。
いったん実装と評価はここまでですが、まだいくつかAttentionに関しては余談があります。
その一つが双方向RNNです。これは比較的理解しやすいです。順方向で得られたhと逆方向で得られたhを結合して新たなhsとしてDecoderに渡すものです。結合だけでなく、和や平均でもよいそうです。意味合いが混ざればよいので。
また別の話として、今回の実装ではAttentionにより得られたcをAffine前でhと結合しましたが、その限りではなく、cとの結合を別な位置に配置することもアリです。例えば次の時刻の入力に継ぎ足したり。
また、多層化の話も追記があります。その際多層化によりLSTM層を複数重ねて、その間をskipコネクションでつなぐといった例です。skipコネクションでは通常加算を使い、逆伝播時の勾配消失をを防ぎます。加算は勾配をそのまま流すだけなので。
Attentionの応用事例も紹介されています。GNMT(Google Neural Machine Translation)とTransformer、NTM(Neural Turing Machine)です。
GNMTの方は実装としてはRNNベースでこれまでの集大成ともいえるような形で、層の深さ、skipコネクション、双方向RNNなどふんだんにテクニックを駆使したLSTMを基本とした構造です。計算量は半端なく、学習デーも半端ないGoogleだからできそうなモデルです。
TransformerではRNNのように前が確定しないと次が計算できない(並列演算ができない)といったRNNの構造的な課題を解決するために発案されたもののようで、その中で特徴的なのが、Self-Attentionといった技術です。自分自身のhsを使って、その単語によって計算されたhが他の要素hsのどこと強く関連を持っているかを導くようなものです。多分。これであれば並列演算が可能になり、GPUパワーを余すことなく使えます。いろいろなところで同期は必要になりそうですが。
NTMに関しては…、概念ですかね?LSTMから計算された結果をメモリにwriteする層とそこから次のLSTMがreadする層があって、そこでAttentionが使われるよ。って話のようです。メモリには前述のhsが入っているイメージですかね。その中から適切なhをreadするのかな。そんなイメージです。
以上。なんとなくわかった気になりました。
コメント