RNN

Definition:

リカレントニューラルネットワーク(Recurrent Neural Network, RNN)とは、過去の入力から得られた内部状態を保持し、それを後続の計算へ再帰的に利用することによって系列データを処理するニューラルネットワークである。

通常のフィードフォワードニューラルネットワークは各入力を独立なものとして扱う。しかし自然言語・音声・動画・センサーデータ・株価時系列などは時間的順序を持つため、過去の情報を考慮しなければ適切な予測や認識を行えない。RNNは内部状態を介して過去の情報を蓄積することにより、この問題に対処する。

例えば日本語文

私は昨日、本屋で本を買った

を左から順に処理する場合、「買った」という語の解釈には「私」「昨日」「本屋」「本」といった先行情報が必要となる。RNNはそれらの情報を内部状態として保持しながら逐次的に処理を行う。

基本構造

RNNは入力層・隠れ層・出力層から構成される。時刻 $t$ における入力ベクトルを $x_t$、隠れ状態を $h_t$、出力を $y_t$ とすると、隠れ状態は前時刻の状態を利用して次のように更新される。

\[h_t = \phi(W_{xh}x_t + W_{hh}h_{t-1} + b_h)\]

ここで $W_{xh}$ は入力から隠れ層への重み行列、$W_{hh}$ は隠れ層から隠れ層への重み行列、$b_h$ はバイアス、$\phi$ は活性化関数である。出力は

\[y_t = \psi(W_{hy}h_t + b_y)\]

によって求められる。$W_{hy}$ は隠れ層から出力層への重み行列、$b_y$ は出力層のバイアス、$\psi$ は出力関数である。

再帰構造の意味

RNNの最大の特徴は時間方向への再帰構造にある。時刻 $t$ における隠れ状態 $h_t$ は $h_{t-1}$ に依存し、さらに $h_{t-1}$ は $h_{t-2}$ に依存する。この連鎖を辿れば、$h_t$ は理論上すべての過去情報を含む。展開すると

\[h_t = f(x_t, x_{t-1}, x_{t-2}, \ldots)\]

となる。この性質によって系列全体の文脈を扱えるのである。

時間方向への展開

RNNはしばしば時間方向へ展開して理解される。長さ $T$ の系列に対して

\[h_1 \rightarrow h_2 \rightarrow h_3 \rightarrow \cdots \rightarrow h_T\]

という深いネットワークとして表現できる。ここで重要なのは、各時刻で同じ重み $W_{xh}, W_{hh}, W_{hy}$ を共有する点である。これによって系列長が変化しても同一のモデルを利用でき、パラメータ数を系列長に依存させないという実用上の利点が生まれる。

学習アルゴリズム:BPTT

RNNは誤差逆伝播法を時間方向へ拡張したackpropagation Through Time(BPTT)によって学習される。系列全体の損失関数を $L = \sum_{t=1}^{T} L_t$ とすると、勾配は時間方向に展開された計算グラフ上で計算される。隠れ状態に関する勾配は

\[\frac{\partial L}{\partial h_t}= \frac{\partial L}{\partial h_T}\prod_{k=t+1}^{T} \frac{\partial h_k}{\partial h_{k-1}}\]

となる。この積の形がRNNの長所と短所の両方を生み出している。

勾配消失問題

RNNの歴史において最も重要な問題の一つが勾配消失問題である。隠れ状態の更新では $W_{hh}$ が繰り返し掛け合わされるため、逆伝播時には $W_{hh}^n$ が現れる。$W_{hh}$ の最大固有値の絶対値が1未満の場合、

\[W_{hh}^n \rightarrow 0 \quad (n \rightarrow \infty)\]

となり、過去の時刻に到達する前に勾配がほぼ消滅してしまう。結果として、長期依存関係を学習できなくなる。

例えば「太郎は本屋に行った。そして数時間後に彼は帰宅した。」という文で「彼」が誰を指すかを学習するには長距離の依存関係を保持する必要があるが、単純RNNではこれが困難である。

勾配爆発問題

逆に固有値の絶対値が1より大きい場合、$W_{hh}^n \rightarrow \infty$ となり、勾配が急激に増大して学習が不安定になる。これを勾配爆発問題という。対策として広く利用されるのが勾配クリッピングであり、勾配のノルム $\|g\|$ がしきい値 $\tau$ を超えた場合に

\[g \leftarrow \frac{\tau}{\|g\|} g\]

と再スケールすることで安定性を保つ。

LSTMの登場

1997年、Sepp Hochreiter と Jürgen Schmidhuber は勾配消失問題への根本的な解決策として Long Short-Term Memory(LSTM)を提案した。LSTMはセル状態 $c_t$ と呼ばれる専用の記憶機構を導入しており、その更新式は

\[c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t\]

で表される。ここで $f_t$ は忘却ゲート、$i_t$ は入力ゲート、$\tilde{c}_t$ は候補状態である。忘却ゲートが「どの情報を捨てるか」を制御し、入力ゲートが「どの情報を新たに書き込むか」を制御する。この構造によって勾配は長期にわたって適切に保持され、長距離依存の学習が可能となった。なお、隠れ状態 $h_t$ は出力ゲート $o_t$ を介してセル状態から

\[h_t = o_t \odot \tanh(c_t)\]

として計算される。

GRU

2014年には Kyunghyun Cho らによって Gated Recurrent Unit(GRU)が提案された。GRUはLSTMを簡略化したアーキテクチャであり、セル状態と隠れ状態を統合し、ゲートを更新ゲートとリセットゲートの二つに絞ることでパラメータ数を削減した。計算量が小さく実装が容易であるため、LSTMと並んで現在でも広く利用されている。タスクや規模によってはLSTMと同等以上の性能を発揮することも報告されており、実務では両者を比較して選択することが多い。

RNNの歴史

RNNの原型は1980年代にまで遡る。1982年に John Hopfield が再帰結合を持つニューラルネットワーク(ホップフィールドネット)を提案し、連想記憶の観点からその挙動を理論的に分析した。1986年には David Rumelhart らによって誤差逆伝播法が広く知られるようになり、多層ネットワークの学習に道が開かれた。1990年には Jeffrey Elman が現在の単純RNNに近い Elman Network を提案し、系列処理への応用が本格化した。

1997年にLSTMが発表されると長期依存問題への実用的な解決策が与えられたが、当初は計算資源の制約から大規模応用が難しかった。2010年代に入ってGPUの発達と大規模データセットの利用が進むと、LSTMやGRUを用いた深層RNNが音声認識・機械翻訳・言語モデルなどで大きな成功を収め、深層学習ブームの一翼を担った。

Transformerとの関係

2017年に Vaswani らによって発表された論文「Attention Is All You Need」によって Transformerが登場した。Transformerは自己注意機構(Self-Attention)を基盤とするため、RNNのような逐次計算を必要としない。これにより並列計算が容易になり、長距離依存を扱いやすく、大規模学習に適するという利点が生まれた。現在の大規模言語モデルの多くはTransformer系であり、系列モデルとしてのRNNは主役の座を譲った形となっている。

しかしRNNが時系列解析・制御工学・組み込み機器・小規模モデルといった領域で依然として利用されていることは見逃せない。なかでも近年注目されている Mamba などの状態空間モデル(SSM)はRNNの設計思想を継承しつつ、長系列処理における効率性を改善したアーキテクチャとして研究が進んでいる。こうした動向は、逐次的な内部状態という考え方そのものが今も有効であることを示している。

系列データ処理という概念を深層学習へ本格的に導入したという点で、RNNは現代AIの発展において極めて重要な歴史的意義を持つ。LSTMやGRUが示した「ゲートによる記憶の制御」という発想は、Transformerの注意機構とも根底でつながっており、現代の深層学習理論を理解するうえで避けて通れない基盤となっている。

Pythonによる実装

#日本語対応
from matplotlib import font_manager

fonts = sorted(set(f.name for f in font_manager.fontManager.ttflist))
for f in fonts:
print(f)


# ライブラリのインポート(NumPyのみでRNN実装!)
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from IPython.display import clear_output

np.random.seed(42)
print('✅ NumPy version:', np.__version__)
print('準備完了!')

データ準備(文字レベル言語モデル)

# 学習テキスト(日本語版)
text = """
吾輩は猫である名前はまだ無い
春はあけぼのやうやう白くなりゆく山ぎは
古池や蛙飛びこむ水の音
雨ニモマケズ風ニモマケズ
祇園精舎の鐘の声諸行無常の響きあり
""".strip()

# 文字の語彙を作成
chars = sorted(list(set(text)))
vocab_size = len(chars)

# 文字 ↔ インデックスの変換辞書
char_to_idx = {ch: i for i, ch in enumerate(chars)}
idx_to_char = {i: ch for i, ch in enumerate(chars)}

# テキストをインデックス列に変換
data = [char_to_idx[ch] for ch in text]

print("文字数:", len(text))
print("語彙数:", vocab_size)
print("語彙:", "".join(chars))

RNN クラスの実装(スクラッチ)

class RNN:
"""
Vanilla RNN — NumPyのみで実装

順伝播:
h_t = tanh(Wxh @ x_t + Whh @ h_{t-1} + bh)
y_t = Why @ h_t + by

学習: BPTT (Backpropagation Through Time)
最適化: Adagrad
"""

def __init__(self, vocab_size, hidden_size, seq_length=25, lr=1e-1):
self.vocab_size = vocab_size
self.hidden_size = hidden_size
self.seq_length = seq_length
self.lr = lr

# --- 重みの初期化(Xavier初期化) ---
scale = 0.01
self.Wxh = np.random.randn(hidden_size, vocab_size) * scale # 入力→隠れ
self.Whh = np.random.randn(hidden_size, hidden_size) * scale # 隠れ→隠れ
self.Why = np.random.randn(vocab_size, hidden_size) * scale # 隠れ→出力
self.bh = np.zeros((hidden_size, 1)) # 隠れ層バイアス
self.by = np.zeros((vocab_size, 1)) # 出力層バイアス

# Adagrad用メモリ(勾配の二乗和)
self.mWxh = np.zeros_like(self.Wxh)
self.mWhh = np.zeros_like(self.Whh)
self.mWhy = np.zeros_like(self.Why)
self.mbh = np.zeros_like(self.bh)
self.mby = np.zeros_like(self.by)

def forward(self, inputs, h_prev):
"""
順伝播
inputs: インデックスのリスト (seq_length,)
h_prev: 前の隠れ状態 (hidden_size, 1)
戻り値: (損失, 隠れ状態辞書, 出力確率辞書, 最後の隠れ状態)
"""
xs, hs, ys, ps = {}, {}, {}, {}
hs[-1] = np.copy(h_prev)
loss = 0

for t in range(len(inputs)):
# one-hotエンコーディング
xs[t] = np.zeros((self.vocab_size, 1))
xs[t][inputs[t]] = 1

# 隠れ状態の更新: h_t = tanh(Wxh @ x_t + Whh @ h_{t-1} + bh)
hs[t] = np.tanh(self.Wxh @ xs[t] + self.Whh @ hs[t-1] + self.bh)

# 出力スコア: y_t = Why @ h_t + by
ys[t] = self.Why @ hs[t] + self.by

# Softmax → 確率
exp_y = np.exp(ys[t] - np.max(ys[t])) # 数値安定化
ps[t] = exp_y / np.sum(exp_y)

# クロスエントロピー損失(次の文字のインデックスは inputs[t+1] だが
# targets[t] として外から渡す設計。ここでは呼び出し元で処理)

return xs, hs, ys, ps

def loss_and_grads(self, inputs, targets, h_prev):
"""
損失計算 + BPTT(時間方向逆伝播)
"""
xs, hs, ys, ps = self.forward(inputs, h_prev)

# --- 損失計算 ---
loss = 0
for t in range(len(inputs)):
loss += -np.log(ps[t][targets[t], 0] + 1e-10)

# --- BPTT: 時間を逆向きに勾配を流す ---
dWxh = np.zeros_like(self.Wxh)
dWhh = np.zeros_like(self.Whh)
dWhy = np.zeros_like(self.Why)
dbh = np.zeros_like(self.bh)
dby = np.zeros_like(self.by)
dh_next = np.zeros_like(hs[0])

for t in reversed(range(len(inputs))):
# 出力層の勾配
dy = np.copy(ps[t])
dy[targets[t]] -= 1 # softmax + cross-entropy の微分

dWhy += dy @ hs[t].T
dby += dy

# 隠れ層の勾配
dh = self.Why.T @ dy + dh_next # 時間方向の勾配を加算
dh_raw = (1 - hs[t]**2) * dh # tanh の微分

dbh += dh_raw
dWxh += dh_raw @ xs[t].T
dWhh += dh_raw @ hs[t-1].T

dh_next = self.Whh.T @ dh_raw

# 勾配クリッピング(爆発する勾配を防ぐ)
for dparam in [dWxh, dWhh, dWhy, dbh, dby]:
np.clip(dparam, -5, 5, out=dparam)

return loss, dWxh, dWhh, dWhy, dbh, dby, hs[len(inputs)-1]

def update(self, dWxh, dWhh, dWhy, dbh, dby):
"""Adagradパラメータ更新"""
for param, dparam, mem in [
(self.Wxh, dWxh, self.mWxh),
(self.Whh, dWhh, self.mWhh),
(self.Why, dWhy, self.mWhy),
(self.bh, dbh, self.mbh),
(self.by, dby, self.mby),
]:
mem += dparam * dparam
param -= self.lr * dparam / (np.sqrt(mem) + 1e-8)

def sample(self, h, seed_idx, n):
"""
文字を生成(サンプリング)
h: 初期隠れ状態
seed_idx: 最初の文字インデックス
n: 生成文字数
"""
x = np.zeros((self.vocab_size, 1))
x[seed_idx] = 1
result = []

for _ in range(n):
h = np.tanh(self.Wxh @ x + self.Whh @ h + self.bh)
y = self.Why @ h + self.by
exp_y = np.exp(y - np.max(y))
p = exp_y / np.sum(exp_y)

# 確率分布からサンプリング
idx = np.random.choice(range(self.vocab_size), p=p.ravel())
result.append(idx)

x = np.zeros((self.vocab_size, 1))
x[idx] = 1

return result

print('✅ RNN クラス定義完了!')

学習ループ

# ハイパーパラメータ
HIDDEN_SIZE = 64 # 隠れ層のサイズ
SEQ_LENGTH = 25 # 一度に処理する文字数(時間ステップ数)
LEARNING_RATE = 1e-1 # 学習率
MAX_ITER = 5000 # 学習イテレーション数
SAMPLE_EVERY = 500 # 何イテレーションごとに文字を生成するか

# モデルの初期化
rnn = RNN(vocab_size, HIDDEN_SIZE, SEQ_LENGTH, LEARNING_RATE)

# 学習記録
loss_history = []
smooth_loss = -np.log(1.0 / vocab_size) * SEQ_LENGTH # 初期損失
samples_log = []

# 学習ループ
n_iter, pos = 0, 0
h_prev = np.zeros((HIDDEN_SIZE, 1))

print('🏋️ 学習開始!')
print(f' hidden_size={HIDDEN_SIZE}, seq_length={SEQ_LENGTH}, lr={LEARNING_RATE}')
print(f' vocab_size={vocab_size}, max_iter={MAX_ITER}')
print('='*60)

for n_iter in range(MAX_ITER):
# エポック境界でリセット
if pos + SEQ_LENGTH + 1 >= len(data):
h_prev = np.zeros((HIDDEN_SIZE, 1))
pos = 0

inputs = data[pos : pos + SEQ_LENGTH]
targets = data[pos + 1 : pos + SEQ_LENGTH + 1]

# 損失計算 + 逆伝播
loss, dWxh, dWhh, dWhy, dbh, dby, h_prev = rnn.loss_and_grads(inputs, targets, h_prev)

# パラメータ更新
rnn.update(dWxh, dWhh, dWhy, dbh, dby)

# スムージング(指数移動平均)
smooth_loss = smooth_loss * 0.999 + loss * 0.001
loss_history.append(smooth_loss)

# サンプリング & 進捗表示
if n_iter % SAMPLE_EVERY == 0:
sample_idxs = rnn.sample(h_prev, inputs[0], 80)
sample_text = ''.join([idx_to_char[i] for i in sample_idxs])
samples_log.append((n_iter, smooth_loss, sample_text))
print(f'\n[Iter {n_iter:5d}] loss={smooth_loss:.4f}')
print(f' 生成: "{sample_text}"')

pos += SEQ_LENGTH

print('\n' + '='*60)
print(f'✅ 学習完了! 最終損失: {smooth_loss:.4f}')

Mathematics is the language with which God has written the universe.





















数理統計学 機械学習