RNNとLSTMを1から理解する — Transformer時代になぜ学ぶのか
系列を「状態を持って1つずつ読む」という発想から出発し、同じ行列を何度も掛けることがなぜ勾配を消すのか、LSTMの3つのゲートが何を解いたのかを式と動く図で追います。Transformerに置き換わった理由と、それでもこの再帰の考え方が生き残る場所まで。
比喩: メモ1枚で長編小説を読む
長い小説を1ページ目から読むとき、手元にあるのは本文と、筋書きを書き留めたメモ1枚だけです。次のページを読んだら、その内容とメモを突き合わせてメモを書き換える。読み終えたときのあなたの理解は、そのメモに凝縮されています。
これが再帰型ニューラルネットワーク(RNN)の全部です。系列を先頭から1つずつ読み、読むたびに状態という名のメモを更新する。メモの大きさは最初から決まっていて、100ページ読もうが1000ページ読もうが増えません。この「固定サイズのメモ」という一点が、RNNの強さと弱さの両方を生みます。
定義: 同じ関数を、自分に何度も適用する
時刻 の入力を 、その時点の状態を と書きます。もっとも素朴なRNNは1行です。
は1つ前の状態(最初の は普通ゼロベクトル)、 は状態を次の状態へ移す行列、 は入力を状態の空間へ持ち込む行列、 はバイアス、 は値を から に押し込める活性化関数です。出力は状態から で取り出します。
つまりこの1行は、「新しいメモ=ひとつ前のメモと、いま読んだ1ページを混ぜ合わせ、値が大きくなりすぎないように から の枠へ押し込んだもの」と言っているだけです。掛け算が2つと足し算が1つ、あとはそれを毎ステップ繰り返す。それ以上のことは何もしていません。
見落としてはいけないのは、時刻ごとに別の重みがあるわけではないことです。 は全時刻で使い回します。だからパラメータ数が系列長に依存せず、5語の文にも500語の文にも同じネットワークを当てられる。可変長を扱えるのは、この重み共有のおかげです。
import numpy as np
def rnn_step(x, h, W_x, W_h, b):
return np.tanh(x @ W_x + h @ W_h + b) # 状態を1歩進める
def run(xs, h, W_x, W_h, b):
for x in xs: # 系列を、順番に、1つずつ
h = rnn_step(x, h, W_x, W_h, b)
return h # 読み終えた時点の「メモ」
学習: 時間方向に展開すると、ただの深いネットになる
この再帰を時刻の数だけ横に並べて描き直すと、50ステップの系列は50層の深いネットワークになります。違いは全層が同じ重みを共有していることだけ。だから普通の誤差逆伝播がそのまま使え、それを時間方向に適用したものをBPTT(Backpropagation Through Time)と呼びます。名前は仰々しいですが、中身は展開図への連鎖律です。
なぜ勾配が消えるのか: 同じ行列を何度も掛けるから
最後の時刻 での損失が、ずっと前の状態 にどう依存するかを追うと、連鎖律で次の積が出てきます。
は時刻 での活性化関数の傾きを対角に並べた行列です。式の見た目より構造が大事です。同じ が 回、繰り返し掛かっている。
つまり左辺は「遠い過去のメモを少しだけ書き換えたら、読み終えた時点のメモがどれだけ変わるか」という感度で、右辺はそれが1ステップぶんの伝わり方を 回かけ算したものだ、ということです。伝言ゲームで各人が声を0.8倍にして次へ渡すなら、10人先に届く量は 倍——構造としてはこれと同じものを見ています。
スカラーに縮めると本質が見えます。 は なら指数的に0へ、 なら指数的に無限へ飛ぶ。行列でも同じで、支配するのは の最大特異値(伸ばす力の最大値)と活性化の傾きの積です。1を下回れば勾配は指数的に消え、上回れば爆発する。「だいたい1」に留まる保証はどこにもありません。
しかも活性化関数が味方をしてくれません。 の傾きは原点で最大1、シグモイド は最大でも 、飽和域に入ればほぼ0。 は多くの場合1未満の数が並んだ行列で、掛けるたびに縮む方向に働きます。
結果として素のRNNは、数ステップ前なら学べても、遠く離れた時刻どうしの関係を学ぶのが極端に苦手になります。「メモを書き換え続けると最初に書いたことが薄れる」という比喩の直感は、この積で説明できます。
逆側の事故もあります。特異値が1を超えると勾配が指数的に膨らみ、1回の更新で重みが吹き飛んで損失がNaNになる。勾配爆発です。対策は単純で、勾配ベクトルのノルムが閾値を超えたら閾値まで縮める勾配クリッピング。RNNではほぼ必須装備です。
LSTM: 勾配のための一本道を通す
1997年に提案されたLSTM(Long Short-Term Memory)は、この問題を「活性化関数を工夫する」のではなく、情報の通り道を作り直すことで解きました。
鍵は状態を2本に分けることです。外に出す隠れ状態 と、内部の記憶であるセル状態 。セル状態の更新式はこれだけです。
は要素ごとの掛け算です。 は忘却ゲート(前の記憶をどれだけ残すか)、 は入力ゲート(新しい候補をどれだけ入れるか)、 は候補の中身。
つまりこの式は「いまの記憶=前の記憶のうち残すと決めた分+新しく書き込むと決めた分」と言っているだけです。前の記憶は行列で混ぜ直されるのではなく、開度を掛けて薄められ、そこへ新しい分が足されるだけ。この足し算が、次の段落の主役になります。
ここが核心です。素のRNNでは が必ず密行列 を通ってから次へ行きました。LSTMのセル状態は、行列を通らずに を掛けて足されるだけです。よって は対角行列 そのもの。忘却ゲートが1に近い次元では、勾配が減衰も回転もせずに素通りします。残差接続が深いネットで果たす役割を、時間方向にやっていると思ってください。
ゲートたちは、いずれも直前の状態と現在の入力から作られます。
つまり上の2本は、「ひとつ前のメモ といま読んだ を見て、前の記憶をどれだけ捨てるか()と、新しい話をどれだけ入れるか()を、その場で0から1の開度として決める」ということです。
下の2本も材料はまったく同じで、これは「いまどれだけ外に見せるか」()と「新しく書き込む中身の候補」()を決めるということ。4本とも入力は同じで、違うのは掛ける重みだけ——同じ材料から4通りの判断を並行して引き出しています。
は2つのベクトルを連結したもの、 はシグモイドです。そして最終的な出力は
は出力ゲートで、「内部に貯めた記憶のうち、今この瞬間に外へ見せる部分」を選びます。
つまり「外に出す答え=内部にためた記憶を から に整えたうえで、出力ゲートが開いている次元だけを取り出したもの」ということです。記憶そのもの()と、外に見せる要約()を別々に持っているのが、この式の言いたいことになります。
ゲートにシグモイドが使われているのは偶然ではありません。出力が0から1なので、ゲートは弁として振る舞います。0なら完全に閉じ(捨てる・入れない・見せない)、1なら全開。上の図のとおりシグモイドは中間で緩やかに、両端で飽和する。この「連続的な開度」が欲しかったから選ばれている——活性化関数の選択がそのままアーキテクチャの意味になっている珍しい例です。
def lstm_step(x, h, c, W, b):
z = np.concatenate([x, h]) @ W + b # 4つ分をまとめて1回の行列積で
f, i, o, g = np.split(z, 4)
f, i, o = sigmoid(f), sigmoid(i), sigmoid(o)
c = f * c + i * np.tanh(g) # ここが加算=勾配の一本道
h = o * np.tanh(c)
return h, c
実装上の定番として、忘却ゲートのバイアス を正の値で初期化する手があります。最初は「忘れない」側に倒しておき、学習初期にセル状態が毎ステップ潰れるのを避けるためです。
GRUは、ゲートを2つに減らして と を1本に統合した簡略版です。軽い代わりに、記憶の出し入れを別々に制御する自由度は失われます。
なぜTransformerに置き換わったのか
理由は精度そのものよりも、学習の並列化にあります。
第一に、逐次性です。 を計算するには が要る。系列長 に対して 回の逐次ステップが必ず要るので、GPUという並列計算機を使い切れません。自己注意は全時刻をまとめて1回の行列積で処理するため、学習時は系列内が完全に並列化できます。
第二に、距離です。RNNでは先頭の情報が末尾に届くまで系列長ぶんの変換を通ります。LSTMで改善されたとはいえペナルティは残る。自己注意では任意の2時刻が1ステップで直結します。
第三に、その帰結として大規模化と相性がよかった。並列化できる=もっと大きく学習できる、だからです。
代償もあります。自己注意は系列長の2乗の計算量とメモリを要し、生成時のKVキャッシュは伸び続けます。RNNの推論メモリは、系列がどれだけ長くても状態1本ぶんで一定です。
それでもRNN系が残る場面
超長系列。 2乗の計算量は、数十万ステップの規模では単純に成立しません。RNNは長さに比例するだけです。
ストリーミング。 1サンプル来るたびに状態を1回更新すれば、常に「現時点での答え」を持っていられます。リアルタイム音声認識、センサの異常検知、常時待機のキーワード検出のように入力が終わらない用途では、この定数コストの更新が本質的に効きます。Transformerで同じことをするには窓を切り直すか、伸び続けるキャッシュを管理しなければなりません。
組込み・エッジ。 状態が固定サイズであることは、メモリ使用量の上限が事前に確定することを意味します。RAMがキロバイト単位の機器では、この予測可能性が採否を決めます。
そしてこの考え方自体がまだ現役です。近年の状態空間モデル系は、再帰を線形にすることで「学習時は並列に計算し、推論時は定数メモリの再帰として動かす」両取りを狙っています。RNNの弱点だった並列化だけを外科的に取り除いた設計で、系譜としてはこの記事の直系です。
現場ではこう使う
RNN・LSTMを実際に触るのは、時系列の異常検知やセンサ処理をやる人、音声やキーワード検出をエッジで動かす人、そして既存システムを保守する人です。最後を軽く見ないでください。数年前に組まれた需要予測や音声のパイプラインは、いまもLSTMのまま動いていることが珍しくありません。
触るパラメータは、PyTorchなら nn.LSTM の hidden_size(メモの大きさ)、num_layers(縦に積む段数)、bidirectional、batch_first、dropout。学習ループ側には torch.nn.utils.clip_grad_norm_(params, max_norm) がほぼ必ず入ります。可変長は pack_padded_sequence / pad_packed_sequence のペア。Kerasなら LSTM(units, return_sequences=..., stateful=...) が同じ役どころです。
落とし穴を4つ。
パディングをそのまま食わせる。 短い系列を0で埋めてバッチにすると、モデルはパディングも律儀に読んで状態を更新します。pack_padded_sequence を通すか、最後の有効時刻の状態を取り出す。ここを外すと、系列長がばらつくデータで精度が静かに落ちます。
bidirectional=True をストリーミングで使う。 双方向LSTMは後ろからも読む=未来を見ています。オフラインのバッチ処理では強力ですが、リアルタイム推論には原理的に使えません。オフラインで検証してから気付く、という失敗が定番です。
状態の持ち越しを管理しない。 stateful な使い方ではバッチ間で状態が引き継がれます。データの区切りでリセットを忘れると、無関係な系列の記憶が混入します。
勾配クリッピングを外す。 数百ステップ順調で、あるとき突然損失がNaNになる。ほぼこれです。
面接や設計レビューで問われるのは主に2つ。「なぜLSTMは勾配消失に強いのか」——セル状態が密行列を通らず、忘却ゲートを掛けて加算されるだけの経路を持つから。もう1つは「RNNとTransformerを系列長 の計算量とメモリで比較せよ」——学習はRNNが 回の逐次で並列化不可、自己注意は だが並列化可能。生成時の状態はRNNが定数、Transformerはキャッシュが に比例して伸びる。この2軸で言えれば、なぜ置き換わったのかと、なぜ消えないのかを同時に説明できます。
まとめ
- RNNは「固定サイズの状態を、同じ関数で1つずつ更新する」だけの仕組み。重み共有が可変長を可能にする
- 勾配消失の正体は、同じ行列と1未満の活性化の傾きが時刻の数だけ掛け合わされること
- LSTMはセル状態という加算経路で、勾配が減衰せずに通れる一本道を確保した。3つのゲートはシグモイドの「0から1の開度」で弁として働く
- Transformerが勝ったのは主に学習の並列化と距離の短さ。代償は2乗の計算量と伸びるキャッシュ
- 超長系列・ストリーミング・組込みでは、定数メモリで進む再帰がいまも効く
次は、この再帰が本来の主戦場としてきた領域——時系列予測を、古典手法から順に見ていきます。
コメント
コメントにはログインが必要です