テキストや時系列データには「順序」がある。「私は猫が好きだ」と「猫は私が好きだ」は同じ単語の集合でも意味が異なる。この順序の情報を扱えるのが再帰型ニューラルネットワーク(RNN)であり、その発展形である LSTM だ。TensorFlow の Keras API を使えば、これらのモデルも Sequential の枠組みで簡潔に構築できる。
RNN の基本構造
通常の Dense 層はすべての入力を一度に受け取って処理する。一方、RNN は時系列データを 1 ステップずつ順番に処理し、各ステップの出力を次のステップに引き渡す。この引き渡される情報を「隠れ状態」と呼ぶ。
ステップ 1 の入力 → 隠れ状態 h1 を生成
ステップ 2 の入力 + h1 → 隠れ状態 h2 を生成
ステップ 3 の入力 + h2 → 隠れ状態 h3 を生成
最後の隠れ状態を出力として使用
過去の情報を隠れ状態に蓄積しながら処理を進めるため、系列の文脈を考慮した判断が可能になる。
SimpleRNN を使った最小構成
TensorFlow では SimpleRNN 層で基本的な RNN を構築できる。ここでは長さ 50、各ステップ 1 次元の時系列データを分類する例を示す。
import tensorflow as tf from tensorflow import keras from tensorflow.keras import layers model = keras.Sequential([ layers.SimpleRNN(64, input_shape=(50, 1)), layers.Dense(1, activation="sigmoid"), ]) model.compile( optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"], ) model.summary()
input_shape の (50, 1) は「50 タイムステップ、各ステップ 1 特徴量」を意味する。SimpleRNN(64) は隠れ状態のサイズを 64 次元に設定している。デフォルトでは最後のタイムステップの隠れ状態のみが出力される。
RNN の入力形状
RNN 層に渡すデータは 3 次元テンソルでなければならない。
1 回の更新で処理するサンプル数。fit の batch_size で指定するため、input_shape には含めない。
系列の長さ。文章なら単語数、株価なら日数に相当する。
各タイムステップでの入力の次元数。単語の埋め込みベクトルが 128 次元なら 128 になる。
たとえば「30 日分の株価データで、各日に始値・終値・出来高の 3 特徴量がある」場合、input_shape は (30, 3) となる。
SimpleRNN の限界
SimpleRNN には「長期依存性の問題」がある。系列が長くなると、初期のステップの情報が後方に伝わるにつれて急速に薄れてしまう現象だ。
この問題の原因は勾配消失にある。逆伝播の際にタイムステップを遡るたびに勾配が掛け算で小さくなり、初期のステップまで有意な勾配が届かなくなる。
勾配が 0 に近づくことで、そのステップの重みがほとんど更新されなくなる現象。
10〜20 ステップ程度の短い系列なら SimpleRNN でも十分機能するが、数百ステップの長い系列ではまともに学習できないことが多い。この問題を解決するために考案されたのが LSTM だ。
LSTM の仕組み
LSTM(Long Short-Term Memory)は、隠れ状態に加えて「セル状態」という長期記憶を持つ。3 つのゲートが情報の取捨選択を制御することで、長い系列でも初期の情報を保持できるようになっている。
セル状態のどの情報を捨てるかを決める。過去の情報のうち、もう不要になった部分を選択的に消去する。
新しい情報のうち、どれをセル状態に書き込むかを決める。現在のステップで重要な情報を長期記憶に追加する。
セル状態のどの部分を隠れ状態として出力するかを決める。次のステップや最終出力に渡す情報を選別する。
これら 3 つのゲートはいずれもシグモイド関数で 0〜1 の値を出力し、情報を「どの程度通すか」を連続的に制御する。
LSTM を使ったテキスト分類
映画レビューの感情分析(ポジティブ / ネガティブ)を LSTM で実装してみる。TensorFlow に組み込みの IMDB データセットを使う。
from tensorflow.keras.datasets import imdb from tensorflow.keras.preprocessing.sequence import pad_sequences # データの読み込み(上位 10000 語のみ使用) vocab_size = 10000 (x_train, y_train), (x_test, y_test) = imdb.load_data(num_words=vocab_size) # 系列の長さを 200 に統一(短いものはパディング、長いものはカット) maxlen = 200 x_train = pad_sequences(x_train, maxlen=maxlen) x_test = pad_sequences(x_test, maxlen=maxlen) # モデル構築 model = keras.Sequential([ layers.Embedding(vocab_size, 128, input_length=maxlen), layers.LSTM(64), layers.Dense(1, activation="sigmoid"), ]) model.compile( optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"], ) model.fit(x_train, y_train, epochs=5, batch_size=64, validation_split=0.2) test_loss, test_acc = model.evaluate(x_test, y_test) print(f"テスト精度: {test_acc:.4f}")
Embedding 層は単語のインデックスを 128 次元のベクトルに変換する。その後 LSTM が系列全体を処理し、最後の隠れ状態を Dense 層に渡して 2 クラス分類を行う。この構成で 85% 前後の精度が出る。
SimpleRNN と LSTM の比較
構造が単純で計算が速い。短い系列なら十分だが、長い系列では勾配消失により学習が困難になる。
ゲート機構とセル状態により長期依存性を捕捉できる。計算コストは高いが、ほとんどの実用場面で SimpleRNN より優れた結果を出す。
実務では SimpleRNN を選ぶ理由はほぼなく、LSTM または後述の GRU が標準的な選択肢になっている。
GRU - LSTM の軽量版
GRU(Gated Recurrent Unit)は LSTM を簡素化した変種で、ゲートの数を 3 つから 2 つに減らしている。セル状態を持たず、隠れ状態だけで情報を管理する。
model = keras.Sequential([ layers.Embedding(vocab_size, 128, input_length=maxlen), layers.GRU(64), layers.Dense(1, activation="sigmoid"), ])
ゲート 3 つ、パラメータ多め。長い系列や複雑なパターンに強い。
ゲート 2 つ、パラメータ少なめ。LSTM と同等の精度を出しつつ訓練が速いことが多い。
どちらが優れるかはタスク依存であり、一般的にはまず LSTM を試し、速度が問題になったら GRU に切り替えるというアプローチが取られる。
return_sequences で全ステップの出力を得る
デフォルトでは RNN 層は最後のタイムステップの隠れ状態だけを返す。return_sequences=True を指定すると、全タイムステップの隠れ状態が出力される。これは RNN を複数段重ねる場合に必要になる。
model = keras.Sequential([ layers.Embedding(vocab_size, 128, input_length=maxlen), layers.LSTM(64, return_sequences=True), layers.LSTM(32), layers.Dense(1, activation="sigmoid"), ])
1 層目の LSTM は全ステップの出力を返し、2 層目がそれを受け取って最終的な隠れ状態を出力する。層を重ねることでより複雑なパターンを捕捉できるが、過学習のリスクも高まるため Dropout との併用が推奨される。
Bidirectional で双方向に読む
通常の RNN は系列を先頭から末尾へ一方向に処理する。Bidirectional ラッパーを使うと、逆方向からも同時に処理し、両方の結果を結合して出力する。
model = keras.Sequential([ layers.Embedding(vocab_size, 128, input_length=maxlen), layers.Bidirectional(layers.LSTM(64)), layers.Dense(1, activation="sigmoid"), ])
「彼は銀行に行った」という文の「銀行」が金融機関なのか川岸なのかは、前後の文脈を見ないと判断できない。双方向 LSTM なら文末から遡る情報も利用できるため、こうした曖昧性の解消に役立つ。出力の次元は順方向と逆方向の結合で 64 × 2 = 128 になる。
RNN と Dropout
RNN での過学習を抑えるには、LSTM 層自体が持つ dropout 引数を使う方法がある。
model = keras.Sequential([ layers.Embedding(vocab_size, 128, input_length=maxlen), layers.LSTM(64, dropout=0.3, recurrent_dropout=0.3), layers.Dense(1, activation="sigmoid"), ])
入力に対する通常の Dropout。各タイムステップの入力ベクトルにランダムにゼロを挿入する。
隠れ状態に対する Dropout。タイムステップ間の情報伝達にランダムにゼロを挿入する。
両者を組み合わせることで、入力側と再帰側の両方で過学習を防ぐことができる。ただし recurrent_dropout を使うと GPU での高速演算(cuDNN)が無効になり、訓練速度が大幅に低下する点には注意が必要だ。
LSTM が SimpleRNN より長い系列を扱えるのは、どの仕組みのおかげか?
- Dense 層との接続
- ゲート機構とセル状態による長期記憶の保持
- バッチ正規化
- Embedding 層による単語のベクトル化