RNN(再帰型ニューラルネットワーク)の基礎を解説

ニューラルネットワークの中で、時系列データ自然言語 のような系列的なデータを扱うために設計されたアーキテクチャが RNN(Recurrent Neural Network、再帰型ニューラルネットワーク) です。通常のフィードフォワードネットワークは固定長の入力を受け取りますが、RNNは内部に 隠れ状態(hidden state) を持ち、過去の情報を記憶しながら可変長の系列データを処理できます。

RNNは自然言語処理、音声認識、時系列予測など幅広い分野の基盤となっており、後に登場するLSTMやGRU、さらにはTransformerを理解するための前提知識としても重要です。本記事では、RNNの構造と数式、BPTT(時間方向への逆伝播)の導出、そして勾配消失・爆発問題の数学的分析を丁寧に解説します。

本記事の内容

  • 系列データの特徴と、RNNが必要な理由
  • RNNの構造と順伝播の数式
  • 時間方向への展開(Unrolling)
  • BPTT(Backpropagation Through Time)の完全な導出
  • 勾配消失・勾配爆発問題の数学的分析(ヤコビ行列の固有値解析)
  • 勾配クリッピングと双方向RNN
  • Pythonでの単純RNNスクラッチ実装(numpy)と正弦波予測タスク

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

系列データとは

時間依存性のあるデータ

多くの実世界のデータは 時間的な順序 を持っています。

  • 自然言語: 「私は犬が好きです」— 単語の順序が意味を規定する
  • 音声信号: 時間方向のサンプル列
  • 株価: 過去の値動きが未来に影響する
  • センサデータ: 温度、加速度などの時系列計測値

これらに共通するのは、現在のデータが過去のデータに依存する という性質です。フィードフォワードネットワークは各入力を独立に処理するため、この時間依存性を捉えることができません。

なぜRNNが必要か

系列データ $\bm{x}_1, \bm{x}_2, \ldots, \bm{x}_T$ の $t$ 番目の出力 $\bm{y}_t$ は、一般に $\bm{x}_1, \bm{x}_2, \ldots, \bm{x}_t$ の全てに依存します。つまり

$$ \bm{y}_t = f(\bm{x}_1, \bm{x}_2, \ldots, \bm{x}_t) $$

という可変長の入力を扱う必要があります。RNNは 隠れ状態 $\bm{h}_t$ を介して過去の情報を圧縮・保持することで、この問題を解決します。

RNNの構造

基本構造

RNN(Elman Network)の基本構造は、次の2つの式で定義されます。

隠れ状態の更新:

$$ \bm{h}_t = f(\bm{W}_h \bm{h}_{t-1} + \bm{W}_x \bm{x}_t + \bm{b}_h) $$

出力の計算:

$$ \bm{y}_t = g(\bm{W}_y \bm{h}_t + \bm{b}_y) $$

ここで各記号の意味は以下の通りです。

記号 サイズ 意味
$\bm{x}_t$ $d \times 1$ 時刻 $t$ の入力ベクトル
$\bm{h}_t$ $n \times 1$ 時刻 $t$ の隠れ状態ベクトル
$\bm{y}_t$ $m \times 1$ 時刻 $t$ の出力ベクトル
$\bm{W}_x$ $n \times d$ 入力から隠れ状態への重み行列
$\bm{W}_h$ $n \times n$ 隠れ状態から隠れ状態への重み行列(再帰重み)
$\bm{W}_y$ $m \times n$ 隠れ状態から出力への重み行列
$\bm{b}_h$ $n \times 1$ 隠れ層のバイアス
$\bm{b}_y$ $m \times 1$ 出力層のバイアス
$f$ 隠れ層の活性化関数(通常 $\tanh$)
$g$ 出力層の活性化関数(タスク依存)

重みの共有

RNNの重要な特徴は、全ての時刻で同じ重み $\bm{W}_h, \bm{W}_x, \bm{W}_y$ を共有する ことです。これにより

  1. 任意の長さの系列を処理できる(パラメータ数が系列長に依存しない)
  2. 異なる時刻で同じパターンを認識できる(時間方向の並進不変性)

時間方向への展開(Unrolling)

RNNの再帰構造を時間方向に「展開(Unroll)」すると、深いフィードフォワードネットワークと見なせます。

$$ \bm{h}_0 \xrightarrow{\bm{x}_1} \bm{h}_1 \xrightarrow{\bm{x}_2} \bm{h}_2 \xrightarrow{\bm{x}_3} \cdots \xrightarrow{\bm{x}_T} \bm{h}_T $$

各時刻で入力 $\bm{x}_t$ を受け取り、隠れ状態 $\bm{h}_t$ を更新していきます。展開後のネットワークは $T$ 層の深さを持つため、通常の逆伝播法を適用して勾配を計算できます。

損失関数

系列全体に対する損失関数は、各時刻の損失の和として定義されます。

$$ L = \sum_{t=1}^{T} L_t $$

ここで $L_t$ は時刻 $t$ の損失です。回帰タスクでは二乗誤差

$$ L_t = \frac{1}{2} \| \bm{y}_t – \bm{d}_t \|^2 $$

分類タスクでは交差エントロピーが使われます。ここで $\bm{d}_t$ は時刻 $t$ の教師信号です。

BPTT(Backpropagation Through Time)の導出

概要

BPTTは、RNNの時間方向に展開したグラフに対して、通常の誤差逆伝播法を適用するアルゴリズムです。以下では、各パラメータに対する勾配を鎖律(Chain Rule)を用いて丁寧に導出します。

表記の簡略化のため、活性化関数適用前の値を

$$ \bm{a}_t = \bm{W}_h \bm{h}_{t-1} + \bm{W}_x \bm{x}_t + \bm{b}_h $$

と置きます。すると $\bm{h}_t = f(\bm{a}_t)$ です。

出力層の勾配

まず、出力層の重み $\bm{W}_y$ に対する勾配は、時刻 $t$ の損失 $L_t$ のみに依存するため単純です。

$$ \frac{\partial L}{\partial \bm{W}_y} = \sum_{t=1}^{T} \frac{\partial L_t}{\partial \bm{W}_y} = \sum_{t=1}^{T} \frac{\partial L_t}{\partial \bm{y}_t} \frac{\partial \bm{y}_t}{\partial \bm{W}_y} $$

$\bm{y}_t = g(\bm{W}_y \bm{h}_t + \bm{b}_y)$ なので、$\delta_t^{(y)} = \frac{\partial L_t}{\partial \bm{y}_t} \odot g'(\bm{W}_y \bm{h}_t + \bm{b}_y)$ と置くと

$$ \frac{\partial L}{\partial \bm{W}_y} = \sum_{t=1}^{T} \delta_t^{(y)} \bm{h}_t^\top $$

隠れ層の勾配(核心部分)

$\bm{W}_h$ に対する勾配の計算がBPTTの核心です。$\bm{W}_h$ は全ての時刻で共有されているため、$\bm{h}_1, \bm{h}_2, \ldots, \bm{h}_T$ の全てに影響します。

まず、時刻 $t$ の損失 $L_t$ の $\bm{h}_t$ に対する勾配を定義します。

$$ \bm{\delta}_t^{(h)} = \frac{\partial L_t}{\partial \bm{h}_t} = \bm{W}_y^\top \delta_t^{(y)} $$

次に、$\bm{h}_t$ は $\bm{h}_{t-1}$ に依存するため、時刻 $\tau > t$ の損失 $L_\tau$ も $\bm{h}_t$ に間接的に依存します。鎖律を適用すると

$$ \frac{\partial L_\tau}{\partial \bm{h}_t} = \frac{\partial L_\tau}{\partial \bm{h}_\tau} \prod_{k=t+1}^{\tau} \frac{\partial \bm{h}_k}{\partial \bm{h}_{k-1}} $$

ここで、$\bm{h}_k = f(\bm{W}_h \bm{h}_{k-1} + \bm{W}_x \bm{x}_k + \bm{b}_h)$ の $\bm{h}_{k-1}$ に対するヤコビ行列は

$$ \frac{\partial \bm{h}_k}{\partial \bm{h}_{k-1}} = \text{diag}(f'(\bm{a}_k)) \, \bm{W}_h $$

です。ここで $\text{diag}(f'(\bm{a}_k))$ は活性化関数の微分を対角要素に持つ対角行列です。

全ての時刻の損失を合計した $\bm{h}_t$ に対する勾配は

$$ \frac{\partial L}{\partial \bm{h}_t} = \sum_{\tau=t}^{T} \frac{\partial L_\tau}{\partial \bm{h}_t} = \sum_{\tau=t}^{T} \left( \prod_{k=t+1}^{\tau} \text{diag}(f'(\bm{a}_k)) \, \bm{W}_h \right)^\top \bm{\delta}_\tau^{(h)} $$

$\bm{W}_h$ に対する勾配

$\bm{W}_h$ は各時刻の $\bm{a}_t = \bm{W}_h \bm{h}_{t-1} + \bm{W}_x \bm{x}_t + \bm{b}_h$ に現れるため

$$ \frac{\partial L}{\partial \bm{W}_h} = \sum_{t=1}^{T} \frac{\partial L}{\partial \bm{a}_t} \frac{\partial \bm{a}_t}{\partial \bm{W}_h} $$

$\frac{\partial L}{\partial \bm{a}_t} = \frac{\partial L}{\partial \bm{h}_t} \odot f'(\bm{a}_t)$ であり、$\frac{\partial \bm{a}_t}{\partial \bm{W}_h} = \bm{h}_{t-1}^\top$ なので

$$ \frac{\partial L}{\partial \bm{W}_h} = \sum_{t=1}^{T} \left( \frac{\partial L}{\partial \bm{h}_t} \odot f'(\bm{a}_t) \right) \bm{h}_{t-1}^\top $$

$\bm{W}_x$ に対する勾配

同様に

$$ \frac{\partial L}{\partial \bm{W}_x} = \sum_{t=1}^{T} \left( \frac{\partial L}{\partial \bm{h}_t} \odot f'(\bm{a}_t) \right) \bm{x}_t^\top $$

BPTTの計算手順

以上をまとめると、BPTTは以下の手順で実行されます。

  1. 順伝播($t = 1, 2, \ldots, T$): $\bm{h}_t$ と $\bm{y}_t$ を前から計算
  2. 逆伝播($t = T, T-1, \ldots, 1$): $\frac{\partial L}{\partial \bm{h}_t}$ を後ろから計算
  3. 勾配の集約: 全時刻の勾配を合計して $\frac{\partial L}{\partial \bm{W}_h}, \frac{\partial L}{\partial \bm{W}_x}$ 等を得る

勾配消失・勾配爆発問題

問題の本質

BPTTの導出で見たように、時刻 $\tau$ の損失が時刻 $t$($t < \tau$)に影響を与える際、ヤコビ行列の積が現れます。

$$ \prod_{k=t+1}^{\tau} \frac{\partial \bm{h}_k}{\partial \bm{h}_{k-1}} = \prod_{k=t+1}^{\tau} \text{diag}(f'(\bm{a}_k)) \, \bm{W}_h $$

$\tau – t$ が大きくなる(長い時間依存性を学ぶ)と、この積が 指数的に小さく(勾配消失) または 指数的に大きく(勾配爆発) なります。

数学的分析: 固有値による評価

簡略化のため、$f'(\bm{a}_k) \approx \text{const}$ と仮定すると、ヤコビ行列の積は

$$ \prod_{k=t+1}^{\tau} \text{diag}(f'(\bm{a}_k)) \, \bm{W}_h \approx (\gamma \bm{W}_h)^{\tau – t} $$

の形になります($\gamma$ は $f’$ の代表値)。$\bm{W}_h$ の固有値分解 $\bm{W}_h = \bm{P} \bm{\Lambda} \bm{P}^{-1}$ を用いると

$$ (\gamma \bm{W}_h)^{\tau – t} = \bm{P} (\gamma \bm{\Lambda})^{\tau – t} \bm{P}^{-1} $$

$\bm{\Lambda} = \text{diag}(\lambda_1, \lambda_2, \ldots, \lambda_n)$ なので

$$ (\gamma \bm{\Lambda})^{\tau – t} = \text{diag}((\gamma \lambda_1)^{\tau – t}, (\gamma \lambda_2)^{\tau – t}, \ldots, (\gamma \lambda_n)^{\tau – t}) $$

ここから

  • $|\gamma \lambda_i| < 1$ のとき: $(\gamma \lambda_i)^{\tau - t} \to 0$(指数的に減衰 → 勾配消失
  • $|\gamma \lambda_i| > 1$ のとき: $(\gamma \lambda_i)^{\tau – t} \to \infty$(指数的に増大 → 勾配爆発

$\tanh$ の場合 $|f'(x)| \leq 1$ なので $\gamma \leq 1$ です。$\bm{W}_h$ のスペクトル半径(最大固有値の絶対値)$\rho(\bm{W}_h)$ を用いると

  • $\rho(\bm{W}_h) < 1/\gamma$: 勾配消失が支配的
  • $\rho(\bm{W}_h) > 1/\gamma$: 勾配爆発が支配的

具体例

$n = 1$(スカラー)の場合で具体的に見てみましょう。$h_t = \tanh(w_h h_{t-1} + w_x x_t)$ とすると

$$ \frac{\partial h_k}{\partial h_{k-1}} = (1 – h_k^2) \cdot w_h $$

$T$ ステップの積は

$$ \prod_{k=t+1}^{T} (1 – h_k^2) \cdot w_h = w_h^{T-t} \prod_{k=t+1}^{T} (1 – h_k^2) $$

$|h_k| \leq 1$ なので $(1 – h_k^2) \leq 1$ であり、$T – t$ が大きくなるほどこの積は急速にゼロに近づきます。

勾配問題への対策

勾配クリッピング(Gradient Clipping)

勾配爆発への最もシンプルな対策が 勾配クリッピング です。勾配ベクトルのノルムが閾値 $c$ を超えた場合に、ノルムが $c$ になるよう縮小します。

$$ \bm{g} \leftarrow \begin{cases} \bm{g} & \text{if } \|\bm{g}\| \leq c \\ \frac{c}{\|\bm{g}\|} \bm{g} & \text{if } \|\bm{g}\| > c \end{cases} $$

この操作は勾配の 方向は保持 したまま 大きさのみを制限 します。

双方向RNN

通常のRNNは過去から未来への一方向のみ情報を伝播しますが、双方向RNN(Bidirectional RNN) は2つのRNNを使います。

  • 順方向RNN: $\overrightarrow{\bm{h}}_t = f(\bm{W}_{\overrightarrow{h}} \overrightarrow{\bm{h}}_{t-1} + \bm{W}_{\overrightarrow{x}} \bm{x}_t + \bm{b}_{\overrightarrow{h}})$
  • 逆方向RNN: $\overleftarrow{\bm{h}}_t = f(\bm{W}_{\overleftarrow{h}} \overleftarrow{\bm{h}}_{t+1} + \bm{W}_{\overleftarrow{x}} \bm{x}_t + \bm{b}_{\overleftarrow{h}})$

最終的な隠れ状態は両方を結合します。

$$ \bm{h}_t = [\overrightarrow{\bm{h}}_t; \overleftarrow{\bm{h}}_t] $$

これにより、時刻 $t$ の出力は過去と未来の両方のコンテキストを利用できます。

その他の対策

  • 勾配消失: LSTM、GRUなどのゲート付きRNNで根本的に解決(次の記事で解説)
  • 重みの初期化: 直交初期化($\bm{W}_h$ を直交行列に初期化)で初期の勾配伝播を安定化
  • Truncated BPTT: 逆伝播を $k$ ステップで打ち切り、計算コストを削減

Pythonでの実装: 単純RNNのスクラッチ実装

numpyのみで単純RNNを実装し、正弦波予測タスクで動作を確認します。

import numpy as np
import matplotlib.pyplot as plt

class SimpleRNN:
    """単純RNNのスクラッチ実装(numpy)"""
    def __init__(self, input_size, hidden_size, output_size, learning_rate=0.01):
        self.hidden_size = hidden_size
        self.lr = learning_rate

        # 重みの初期化(Xavier初期化)
        scale_xh = np.sqrt(2.0 / (input_size + hidden_size))
        scale_hh = np.sqrt(2.0 / (hidden_size + hidden_size))
        scale_hy = np.sqrt(2.0 / (hidden_size + output_size))

        self.Wx = np.random.randn(hidden_size, input_size) * scale_xh
        self.Wh = np.random.randn(hidden_size, hidden_size) * scale_hh
        self.Wy = np.random.randn(output_size, hidden_size) * scale_hy
        self.bh = np.zeros((hidden_size, 1))
        self.by = np.zeros((output_size, 1))

    def forward(self, xs):
        """順伝播: xs は (T, input_size) の入力系列"""
        T = len(xs)
        self.xs = xs
        self.hs = {}  # 隠れ状態を保存
        self.hs[-1] = np.zeros((self.hidden_size, 1))  # 初期隠れ状態
        self.ys = {}  # 出力を保存
        self.as_pre = {}  # 活性化前の値を保存

        for t in range(T):
            x_t = xs[t].reshape(-1, 1)
            # 隠れ状態の更新: h_t = tanh(Wh * h_{t-1} + Wx * x_t + bh)
            a_t = self.Wh @ self.hs[t-1] + self.Wx @ x_t + self.bh
            self.as_pre[t] = a_t
            self.hs[t] = np.tanh(a_t)
            # 出力: y_t = Wy * h_t + by(線形出力、回帰タスク用)
            self.ys[t] = self.Wy @ self.hs[t] + self.by

        return self.ys

    def backward(self, targets):
        """逆伝播(BPTT)"""
        T = len(targets)

        # 勾配の初期化
        dWx = np.zeros_like(self.Wx)
        dWh = np.zeros_like(self.Wh)
        dWy = np.zeros_like(self.Wy)
        dbh = np.zeros_like(self.bh)
        dby = np.zeros_like(self.by)

        # 隠れ状態に対する勾配(後の時刻から伝播する分)
        dh_next = np.zeros((self.hidden_size, 1))

        loss = 0

        for t in reversed(range(T)):
            target_t = targets[t].reshape(-1, 1)

            # 出力層の勾配(二乗誤差: L_t = 0.5 * ||y_t - d_t||^2)
            dy = self.ys[t] - target_t
            loss += 0.5 * np.sum(dy ** 2)

            # Wy, by の勾配
            dWy += dy @ self.hs[t].T
            dby += dy

            # 隠れ状態に対する勾配
            dh = self.Wy.T @ dy + dh_next

            # tanh の逆伝播: dh/da = 1 - tanh^2(a)
            da = dh * (1 - self.hs[t] ** 2)

            # Wh, Wx, bh の勾配
            dWh += da @ self.hs[t-1].T
            dWx += da @ self.xs[t].reshape(1, -1)
            dbh += da

            # 前の時刻への勾配を伝播
            dh_next = self.Wh.T @ da

        # 勾配クリッピング
        for grad in [dWx, dWh, dWy, dbh, dby]:
            np.clip(grad, -5, 5, out=grad)

        # パラメータ更新
        self.Wx -= self.lr * dWx
        self.Wh -= self.lr * dWh
        self.Wy -= self.lr * dWy
        self.bh -= self.lr * dbh
        self.by -= self.lr * dby

        return loss

    def predict(self, xs):
        """推論(勾配計算なし)"""
        T = len(xs)
        h = np.zeros((self.hidden_size, 1))
        predictions = []

        for t in range(T):
            x_t = xs[t].reshape(-1, 1)
            a = self.Wh @ h + self.Wx @ x_t + self.bh
            h = np.tanh(a)
            y = self.Wy @ h + self.by
            predictions.append(y.flatten()[0])

        return np.array(predictions)

# --- 正弦波予測タスク ---

# データ生成
np.random.seed(42)
t_total = np.linspace(0, 8 * np.pi, 500)
data = np.sin(t_total)

# 学習データの作成(入力: 過去seq_lenステップ、出力: 次の1ステップ)
seq_len = 25
X_all, Y_all = [], []
for i in range(len(data) - seq_len):
    X_all.append(data[i:i+seq_len])
    Y_all.append(data[i+seq_len])

X_all = np.array(X_all)
Y_all = np.array(Y_all)

# 訓練データとテストデータに分割
train_size = 300
X_train = X_all[:train_size]
Y_train = Y_all[:train_size]
X_test = X_all[train_size:]
Y_test = Y_all[train_size:]

# RNNモデルの構築と学習
rnn = SimpleRNN(input_size=1, hidden_size=16, output_size=1, learning_rate=0.001)

n_epochs = 100
losses = []

for epoch in range(n_epochs):
    epoch_loss = 0
    # ミニバッチ学習(バッチサイズ1)
    indices = np.random.permutation(train_size)
    for idx in indices:
        xs = X_train[idx].reshape(-1, 1)  # (seq_len, 1)
        ys = np.array([Y_train[idx]])      # (1,)

        # 順伝播
        rnn.forward(xs)

        # 逆伝播
        # ターゲットは最後の時刻のみ使用
        targets = [np.zeros(1)] * (seq_len - 1) + [ys]
        # 簡略化: 最後の出力のみ損失を計算
        dy = rnn.ys[seq_len-1] - ys.reshape(-1, 1)
        loss_val = 0.5 * np.sum(dy ** 2)
        epoch_loss += loss_val

        # 手動で最終出力のみBPTT
        dWy_temp = dy @ rnn.hs[seq_len-1].T
        dby_temp = dy.copy()
        dh = rnn.Wy.T @ dy
        dWh_temp = np.zeros_like(rnn.Wh)
        dWx_temp = np.zeros_like(rnn.Wx)
        dbh_temp = np.zeros_like(rnn.bh)

        for t in reversed(range(seq_len)):
            da = dh * (1 - rnn.hs[t] ** 2)
            dWh_temp += da @ rnn.hs[t-1].T
            dWx_temp += da @ xs[t].reshape(1, -1)
            dbh_temp += da
            dh = rnn.Wh.T @ da

        # 勾配クリッピング
        for grad in [dWx_temp, dWh_temp, dWy_temp, dbh_temp, dby_temp]:
            np.clip(grad, -1, 1, out=grad)

        rnn.Wx -= rnn.lr * dWx_temp
        rnn.Wh -= rnn.lr * dWh_temp
        rnn.Wy -= rnn.lr * dWy_temp
        rnn.bh -= rnn.lr * dbh_temp
        rnn.by -= rnn.lr * dby_temp

    avg_loss = epoch_loss / train_size
    losses.append(avg_loss)
    if (epoch + 1) % 20 == 0:
        print(f"Epoch {epoch+1}/{n_epochs}, Loss: {avg_loss:.6f}")

# テストデータでの予測
predictions = []
for i in range(len(X_test)):
    pred = rnn.predict(X_test[i].reshape(-1, 1))
    predictions.append(pred[-1])
predictions = np.array(predictions)

# 可視化
fig, axes = plt.subplots(2, 2, figsize=(16, 10))

# 学習曲線
axes[0, 0].plot(losses, linewidth=2)
axes[0, 0].set_xlabel('Epoch', fontsize=12)
axes[0, 0].set_ylabel('Average Loss', fontsize=12)
axes[0, 0].set_title('Training Loss', fontsize=14)
axes[0, 0].grid(True, alpha=0.3)

# 予測結果
axes[0, 1].plot(Y_test, label='Ground Truth', linewidth=2)
axes[0, 1].plot(predictions, label='RNN Prediction', linewidth=2, linestyle='--')
axes[0, 1].set_xlabel('Time Step', fontsize=12)
axes[0, 1].set_ylabel('Value', fontsize=12)
axes[0, 1].set_title('Sine Wave Prediction (Test)', fontsize=14)
axes[0, 1].legend(fontsize=12)
axes[0, 1].grid(True, alpha=0.3)

# 全体の系列と訓練/テスト分割
axes[1, 0].plot(range(train_size), data[seq_len:seq_len+train_size],
                label='Training Data', linewidth=2)
axes[1, 0].plot(range(train_size, train_size + len(Y_test)), Y_test,
                label='Test Data', linewidth=2)
axes[1, 0].plot(range(train_size, train_size + len(predictions)), predictions,
                label='Prediction', linewidth=2, linestyle='--')
axes[1, 0].axvline(x=train_size, color='r', linestyle=':', alpha=0.5)
axes[1, 0].set_xlabel('Time Step', fontsize=12)
axes[1, 0].set_ylabel('Value', fontsize=12)
axes[1, 0].set_title('Full Sequence with Train/Test Split', fontsize=14)
axes[1, 0].legend(fontsize=12)
axes[1, 0].grid(True, alpha=0.3)

# 勾配消失の可視化
# ヤコビ行列の固有値の積を時間ステップに対してプロット
gradient_norms = []
h = np.zeros((16, 1))
test_seq = X_test[0].reshape(-1, 1)
jacobian_prod = np.eye(16)
for t in range(seq_len):
    x_t = test_seq[t].reshape(-1, 1)
    a = rnn.Wh @ h + rnn.Wx @ x_t + rnn.bh
    h = np.tanh(a)
    # ヤコビ行列: diag(1 - h^2) @ Wh
    J = np.diag((1 - h**2).flatten()) @ rnn.Wh
    jacobian_prod = J @ jacobian_prod
    gradient_norms.append(np.linalg.norm(jacobian_prod))

axes[1, 1].plot(gradient_norms, 'o-', linewidth=2, markersize=4)
axes[1, 1].set_xlabel('Time Steps Back', fontsize=12)
axes[1, 1].set_ylabel('||Jacobian Product||', fontsize=12)
axes[1, 1].set_title('Gradient Flow Through Time (Vanishing Gradient)', fontsize=14)
axes[1, 1].set_yscale('log')
axes[1, 1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# 数値結果
mse = np.mean((predictions - Y_test) ** 2)
print(f"\nテストMSE: {mse:.6f}")
print(f"Whのスペクトル半径: {np.max(np.abs(np.linalg.eigvals(rnn.Wh))):.4f}")

結果の考察

正弦波予測タスクでの実験から、以下の知見が得られます。

学習曲線: エポックが進むにつれて損失が減少し、RNNが正弦波のパターンを学習していることが確認できます。ただし、学習率や隠れ層のサイズによっては収束が遅くなったり、発散したりすることがあります。

勾配の減衰: ヤコビ行列の積のノルムが時間ステップを遡るにつれて指数的に減少する様子が観察できます。これが勾配消失問題であり、25ステップ程度でも顕著な減衰が起こります。

$\bm{W}_h$ のスペクトル半径: 学習後の $\bm{W}_h$ のスペクトル半径が1未満であれば、勾配消失が支配的であることを意味します。

まとめ

本記事では、RNN(再帰型ニューラルネットワーク)の基礎を解説しました。

  • RNNは隠れ状態 $\bm{h}_t = f(\bm{W}_h \bm{h}_{t-1} + \bm{W}_x \bm{x}_t + \bm{b}_h)$ を通じて過去の情報を保持し、可変長の系列データを処理できます
  • 全時刻で 重みを共有 するため、パラメータ数が系列長に依存しません
  • BPTT は時間方向に展開したRNNに逆伝播を適用するアルゴリズムで、鎖律による勾配計算が核心です
  • 勾配消失・爆発 はヤコビ行列 $\text{diag}(f'(\bm{a}_k)) \bm{W}_h$ の積が指数的に変化することに起因し、$\bm{W}_h$ の固有値で特性が決まります
  • 勾配クリッピング は勾配爆発への実用的な対策です

次のステップとして、以下の記事も参考にしてください。