双方向RNN(Bidirectional RNN)の理論と実装を解説

単方向のRNNは、系列を左から右へ順に処理するため、時刻 $t$ の隠れ状態 $\bm{h}_t$ は過去の入力 $(x_1, \dots, x_t)$ の情報のみを含みます。しかし、品詞タグ付け(POS tagging)や固有表現認識(NER)などの系列ラベリングタスクでは、ある位置のラベルを正確に予測するために未来の文脈(右側の情報)も重要です。

双方向RNN(Bidirectional RNN, BiRNN)は、Schuster & Paliwal(1997)によって提案された、順方向と逆方向の2つのRNNを組み合わせることで、各時刻で過去と未来の両方の文脈を利用するアーキテクチャです。本記事では、双方向RNNの理論を数式で丁寧に導出し、BiLSTM/BiGRUへの拡張、学習アルゴリズム、適用場面と制約、そしてPythonでのスクラッチ実装を解説します。

本記事の内容

  • 単方向RNNの限界と双方向RNNの動機
  • 双方向RNNの構造と数式
  • 出力層の定式化
  • BiLSTMとBiGRUへの拡張
  • 両方向のBPTTによる学習アルゴリズム
  • 双方向RNNの適用場面と制約
  • Pythonでの系列ラベリングタスクの実装

前提知識

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

単方向RNNの限界

過去の文脈のみでは不十分な例

次の英文の品詞タグ付けを考えます。

“The bank of the river was steep.”

“bank” という単語は「銀行」と「川岸」の2つの意味を持ちますが、後に続く “river” を見れば「川岸」であると判断できます。しかし、単方向(左→右)のRNNでは “bank” を処理する時点で “river” はまだ入力されておらず、文脈情報が不足します。

数式で表すと、単方向RNNの時刻 $t$ での隠れ状態は

$$ \overrightarrow{\bm{h}}_t = f(\bm{x}_t, \overrightarrow{\bm{h}}_{t-1}) $$

であり、$\overrightarrow{\bm{h}}_t$ は $\bm{x}_1, \dots, \bm{x}_t$ の情報のみを含みます。時刻 $t$ 以降の情報 $\bm{x}_{t+1}, \dots, \bm{x}_T$ は反映されません。

逆方向RNNだけでも不十分

系列を右から左に処理する逆方向RNNを使えば、

$$ \overleftarrow{\bm{h}}_t = f(\bm{x}_t, \overleftarrow{\bm{h}}_{t+1}) $$

となり、$\overleftarrow{\bm{h}}_t$ は $\bm{x}_t, \bm{x}_{t+1}, \dots, \bm{x}_T$ の情報を含みます。しかし今度は過去の文脈が失われます。

理想的には、各時刻 $t$ で入力系列全体 $(\bm{x}_1, \dots, \bm{x}_T)$ の情報を利用したいのです。

双方向RNNの構造

順方向RNNと逆方向RNN

双方向RNNは、2つの独立したRNNを用います。

順方向RNN: 時刻 $t = 1, 2, \dots, T$ の順に処理

$$ \overrightarrow{\bm{h}}_t = f_{\overrightarrow{}}(\bm{W}_{\overrightarrow{x}} \bm{x}_t + \bm{W}_{\overrightarrow{h}} \overrightarrow{\bm{h}}_{t-1} + \bm{b}_{\overrightarrow{}}) $$

初期状態: $\overrightarrow{\bm{h}}_0 = \bm{0}$

逆方向RNN: 時刻 $t = T, T-1, \dots, 1$ の逆順に処理

$$ \overleftarrow{\bm{h}}_t = f_{\overleftarrow{}}(\bm{W}_{\overleftarrow{x}} \bm{x}_t + \bm{W}_{\overleftarrow{h}} \overleftarrow{\bm{h}}_{t+1} + \bm{b}_{\overleftarrow{}}) $$

初期状態: $\overleftarrow{\bm{h}}_{T+1} = \bm{0}$

ここで、順方向と逆方向のパラメータ $(\bm{W}_{\overrightarrow{x}}, \bm{W}_{\overrightarrow{h}}, \bm{b}_{\overrightarrow{}})$ と $(\bm{W}_{\overleftarrow{x}}, \bm{W}_{\overleftarrow{h}}, \bm{b}_{\overleftarrow{}})$ はそれぞれ独立に学習されます。

$\overrightarrow{\bm{h}}_t \in \mathbb{R}^{h}$ は過去の文脈 $(\bm{x}_1, \dots, \bm{x}_t)$ を、$\overleftarrow{\bm{h}}_t \in \mathbb{R}^{h}$ は未来の文脈 $(\bm{x}_t, \dots, \bm{x}_T)$ をそれぞれ捉えています。

隠れ状態の結合

各時刻 $t$ で、順方向と逆方向の隠れ状態を結合(concatenate)して、双方向の文脈情報を含む表現を作ります。

$$ \bm{h}_t = [\overrightarrow{\bm{h}}_t ; \overleftarrow{\bm{h}}_t] \in \mathbb{R}^{2h} $$

ここで $[; ]$ はベクトルの結合(concatenation)を表します。この結合された $\bm{h}_t$ は、系列の過去と未来の両方の文脈情報を含んでいます。

結合以外の方法として、要素ごとの和 $\overrightarrow{\bm{h}}_t + \overleftarrow{\bm{h}}_t$ や要素ごとの積 $\overrightarrow{\bm{h}}_t \odot \overleftarrow{\bm{h}}_t$ を使うこともありますが、結合が最も一般的です。和を使う場合は次元が変わらない ($\mathbb{R}^h$) というメリットがありますが、情報の混合が起きるデメリットがあります。

出力層の定式化

結合された隠れ状態 $\bm{h}_t$ から出力 $\bm{y}_t$ を計算します。系列ラベリングタスク(分類問題)の場合、

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

ここで $\bm{W}_y \in \mathbb{R}^{K \times 2h}$、$\bm{b}_y \in \mathbb{R}^K$ は出力層のパラメータ、$K$ はラベル数です。$g$ は活性化関数で、分類の場合はsoftmaxを用います。

$$ P(y_t = k | \bm{x}_1, \dots, \bm{x}_T) = \text{softmax}(\bm{W}_y [\overrightarrow{\bm{h}}_t ; \overleftarrow{\bm{h}}_t] + \bm{b}_y)_k $$

重要な点は、$P(y_t | \cdot)$ が入力系列全体 $(\bm{x}_1, \dots, \bm{x}_T)$ に条件付けられていることです。これが単方向RNN($P(y_t | \bm{x}_1, \dots, \bm{x}_t)$ のみ)との本質的な違いです。

BiLSTMとBiGRUへの拡張

BiLSTM

バニラRNNの代わりにLSTMセルを使ったものがBiLSTM(Bidirectional LSTM)です。

順方向LSTM: 時刻 $t = 1, \dots, T$ の順に、

$$ \begin{align} \overrightarrow{\bm{f}}_t &= \sigma(\bm{W}_{\overrightarrow{f}} \bm{x}_t + \bm{U}_{\overrightarrow{f}} \overrightarrow{\bm{h}}_{t-1} + \bm{b}_{\overrightarrow{f}}) \\ \overrightarrow{\bm{i}}_t &= \sigma(\bm{W}_{\overrightarrow{i}} \bm{x}_t + \bm{U}_{\overrightarrow{i}} \overrightarrow{\bm{h}}_{t-1} + \bm{b}_{\overrightarrow{i}}) \\ \overrightarrow{\bm{o}}_t &= \sigma(\bm{W}_{\overrightarrow{o}} \bm{x}_t + \bm{U}_{\overrightarrow{o}} \overrightarrow{\bm{h}}_{t-1} + \bm{b}_{\overrightarrow{o}}) \\ \overrightarrow{\tilde{\bm{c}}}_t &= \tanh(\bm{W}_{\overrightarrow{c}} \bm{x}_t + \bm{U}_{\overrightarrow{c}} \overrightarrow{\bm{h}}_{t-1} + \bm{b}_{\overrightarrow{c}}) \\ \overrightarrow{\bm{c}}_t &= \overrightarrow{\bm{f}}_t \odot \overrightarrow{\bm{c}}_{t-1} + \overrightarrow{\bm{i}}_t \odot \overrightarrow{\tilde{\bm{c}}}_t \\ \overrightarrow{\bm{h}}_t &= \overrightarrow{\bm{o}}_t \odot \tanh(\overrightarrow{\bm{c}}_t) \end{align} $$

逆方向LSTM: 時刻 $t = T, \dots, 1$ の逆順に、同一の構造(ただし独立したパラメータ)で処理します。

$$ \begin{align} \overleftarrow{\bm{f}}_t &= \sigma(\bm{W}_{\overleftarrow{f}} \bm{x}_t + \bm{U}_{\overleftarrow{f}} \overleftarrow{\bm{h}}_{t+1} + \bm{b}_{\overleftarrow{f}}) \\ \overleftarrow{\bm{i}}_t &= \sigma(\bm{W}_{\overleftarrow{i}} \bm{x}_t + \bm{U}_{\overleftarrow{i}} \overleftarrow{\bm{h}}_{t+1} + \bm{b}_{\overleftarrow{i}}) \\ \overleftarrow{\bm{o}}_t &= \sigma(\bm{W}_{\overleftarrow{o}} \bm{x}_t + \bm{U}_{\overleftarrow{o}} \overleftarrow{\bm{h}}_{t+1} + \bm{b}_{\overleftarrow{o}}) \\ \overleftarrow{\tilde{\bm{c}}}_t &= \tanh(\bm{W}_{\overleftarrow{c}} \bm{x}_t + \bm{U}_{\overleftarrow{c}} \overleftarrow{\bm{h}}_{t+1} + \bm{b}_{\overleftarrow{c}}) \\ \overleftarrow{\bm{c}}_t &= \overleftarrow{\bm{f}}_t \odot \overleftarrow{\bm{c}}_{t+1} + \overleftarrow{\bm{i}}_t \odot \overleftarrow{\tilde{\bm{c}}}_t \\ \overleftarrow{\bm{h}}_t &= \overleftarrow{\bm{o}}_t \odot \tanh(\overleftarrow{\bm{c}}_t) \end{align} $$

出力は結合して $\bm{h}_t = [\overrightarrow{\bm{h}}_t ; \overleftarrow{\bm{h}}_t] \in \mathbb{R}^{2h}$ となります。

BiLSTMはNLPの多くのタスクで標準的なモデルとして使われてきました。特に固有表現認識(NER)では、BiLSTM-CRFモデル(BiLSTMの上にCRF層を追加)が長らく最先端の性能を示していました。

BiGRU

同様に、GRUセルを使ったBiGRU(Bidirectional GRU)も定義できます。

順方向GRU:

$$ \begin{align} \overrightarrow{\bm{z}}_t &= \sigma(\bm{W}_{\overrightarrow{z}} \bm{x}_t + \bm{U}_{\overrightarrow{z}} \overrightarrow{\bm{h}}_{t-1} + \bm{b}_{\overrightarrow{z}}) \\ \overrightarrow{\bm{r}}_t &= \sigma(\bm{W}_{\overrightarrow{r}} \bm{x}_t + \bm{U}_{\overrightarrow{r}} \overrightarrow{\bm{h}}_{t-1} + \bm{b}_{\overrightarrow{r}}) \\ \overrightarrow{\tilde{\bm{h}}}_t &= \tanh(\bm{W}_{\overrightarrow{h}} \bm{x}_t + \bm{U}_{\overrightarrow{h}} (\overrightarrow{\bm{r}}_t \odot \overrightarrow{\bm{h}}_{t-1}) + \bm{b}_{\overrightarrow{h}}) \\ \overrightarrow{\bm{h}}_t &= \overrightarrow{\bm{z}}_t \odot \overrightarrow{\bm{h}}_{t-1} + (1 – \overrightarrow{\bm{z}}_t) \odot \overrightarrow{\tilde{\bm{h}}}_t \end{align} $$

逆方向GRU も同様の構造で、独立したパラメータを持ちます。

パラメータ数の比較

入力次元 $d$、片方向の隠れ次元 $h$ とした場合のパラメータ数:

モデル RNN部分 出力層 合計
単方向LSTM $4h(d+h+1)$ $Kh + K$ $4h(d+h+1) + K(h+1)$
BiLSTM $2 \times 4h(d+h+1)$ $2Kh + K$ $8h(d+h+1) + K(2h+1)$
単方向GRU $3h(d+h+1)$ $Kh + K$ $3h(d+h+1) + K(h+1)$
BiGRU $2 \times 3h(d+h+1)$ $2Kh + K$ $6h(d+h+1) + K(2h+1)$

双方向モデルはRNN部分のパラメータが2倍になりますが、出力層の入力次元も $h$ から $2h$ に増加する点に注意してください。

学習アルゴリズム: 両方向のBPTT

順伝播

双方向RNNの順伝播は以下の手順で行います。

ステップ1: 順方向RNNを $t = 1, \dots, T$ の順に実行

$$ \overrightarrow{\bm{h}}_1, \overrightarrow{\bm{h}}_2, \dots, \overrightarrow{\bm{h}}_T $$

ステップ2: 逆方向RNNを $t = T, \dots, 1$ の逆順に実行

$$ \overleftarrow{\bm{h}}_T, \overleftarrow{\bm{h}}_{T-1}, \dots, \overleftarrow{\bm{h}}_1 $$

ステップ3: 各時刻で結合して出力を計算

$$ \bm{y}_t = \text{softmax}(\bm{W}_y [\overrightarrow{\bm{h}}_t ; \overleftarrow{\bm{h}}_t] + \bm{b}_y), \quad t = 1, \dots, T $$

ステップ1とステップ2は互いに独立であるため、並列に実行可能です。

逆伝播

損失関数を $L = \sum_{t=1}^{T} L_t$ とします($L_t$ は時刻 $t$ でのクロスエントロピー損失)。

出力層の勾配: 各時刻 $t$ で、

$$ \frac{\partial L_t}{\partial \bm{h}_t} = \bm{W}_y^\top (\bm{y}_t – \bm{e}_{y_t^*}) $$

ここで $\bm{e}_{y_t^*}$ は正解ラベルのone-hotベクトル、$\bm{y}_t$ はsoftmax出力です。

結合された $\bm{h}_t$ の勾配を順方向と逆方向に分解します。$\bm{h}_t = [\overrightarrow{\bm{h}}_t ; \overleftarrow{\bm{h}}_t]$ なので、

$$ \delta_t = \frac{\partial L_t}{\partial \bm{h}_t} \in \mathbb{R}^{2h} $$

の上半分が $\frac{\partial L_t}{\partial \overrightarrow{\bm{h}}_t} \in \mathbb{R}^h$、下半分が $\frac{\partial L_t}{\partial \overleftarrow{\bm{h}}_t} \in \mathbb{R}^h$ に対応します。

順方向のBPTT: $t = T, T-1, \dots, 1$ の順に、通常のBPTTと同じ手順で勾配を伝播します。

$$ \frac{\partial L}{\partial \overrightarrow{\bm{h}}_t} = \frac{\partial L_t}{\partial \overrightarrow{\bm{h}}_t} + \frac{\partial L}{\partial \overrightarrow{\bm{h}}_{t+1}} \cdot \frac{\partial \overrightarrow{\bm{h}}_{t+1}}{\partial \overrightarrow{\bm{h}}_t} $$

第1項は時刻 $t$ の損失からの直接の勾配、第2項は $t+1$ から伝播してきた勾配です。

逆方向のBPTT: $t = 1, 2, \dots, T$ の順に(逆方向RNNの処理順とは逆に)勾配を伝播します。

$$ \frac{\partial L}{\partial \overleftarrow{\bm{h}}_t} = \frac{\partial L_t}{\partial \overleftarrow{\bm{h}}_t} + \frac{\partial L}{\partial \overleftarrow{\bm{h}}_{t-1}} \cdot \frac{\partial \overleftarrow{\bm{h}}_{t-1}}{\partial \overleftarrow{\bm{h}}_t} $$

重要なのは、順方向と逆方向のBPTTは互いに独立であるということです。順方向のパラメータの勾配は順方向の隠れ状態の勾配のみから計算され、逆方向についても同様です。これは、2つのRNNが出力層でのみ結合し、内部的には独立しているためです。

パラメータの勾配

出力層のパラメータ勾配は全時刻の寄与を合計します。

$$ \begin{align} \frac{\partial L}{\partial \bm{W}_y} &= \sum_{t=1}^{T} (\bm{y}_t – \bm{e}_{y_t^*}) \bm{h}_t^\top \\ \frac{\partial L}{\partial \bm{b}_y} &= \sum_{t=1}^{T} (\bm{y}_t – \bm{e}_{y_t^*}) \end{align} $$

順方向・逆方向RNNの各パラメータの勾配は、それぞれのBPTTで計算されたものを合計します。

適用場面と制約

双方向RNNが有効な場面

タスク 説明 双方向が有効な理由
品詞タグ付け 各単語に品詞ラベルを付与 前後の文脈が品詞の曖昧性解消に必要
固有表現認識 人名・地名・組織名を識別 後続の単語(”Inc.”, “大学”等)が手がかりになる
音声認識 音声フレーム列を文字列に変換 後続の音素が先行する音素の認識を助ける
感情分析 文全体から感情を判定 文末の否定語が文全体の極性を反転させる
機械読解 文書から質問への回答を抽出 回答箇所の前後の文脈が必要

双方向RNNが使えない場面

リアルタイム予測(オンライン予測): 双方向RNNは入力系列全体が揃って初めて計算できます。逆方向RNNの実行に系列全体が必要なためです。

例えば以下のタスクでは使用できません。

  • リアルタイム音声認識: 発話が終わる前に認識結果を返す必要がある場合
  • 株価予測: 未来のデータはまだ存在しない
  • ロボットの逐次制御: 将来のセンサ入力を待てない

これらのタスクでは単方向RNN(またはTransformerの因果的マスキング)を使う必要があります。

自己回帰生成タスク: Seq2SeqのDecoderや言語モデルのように、トークンを逐次的に生成するタスクでは、生成時に未来のトークンは存在しないため双方向RNNは使えません。ただし、Seq2SeqのEncoderには双方向RNNを使うのが一般的です。

Pythonでの実装

問題設定: 系列ラベリングタスク

文字レベルの「母音判定タスク」を実装します。入力文字列の各文字に対して、「母音(a, e, i, o, u)= 1」「子音 = 0」のラベルを付与するタスクです。このタスクは文字単体で判定可能ですが、前後の文脈を使うモデルがより効率よく学習できることを示します。

import numpy as np
import matplotlib.pyplot as plt

# ----------------------------
# ユーティリティ関数
# ----------------------------
def sigmoid(x):
    return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500)))

def softmax(x):
    e = np.exp(x - np.max(x, axis=0, keepdims=True))
    return e / np.sum(e, axis=0, keepdims=True)

# ----------------------------
# LSTMセルの実装
# ----------------------------
class LSTMCell:
    """LSTMセル(1ステップ分の順伝播・逆伝播)"""
    def __init__(self, input_dim, hidden_dim):
        self.hidden_dim = hidden_dim
        scale_ih = np.sqrt(2.0 / (input_dim + hidden_dim))
        scale_hh = np.sqrt(2.0 / (hidden_dim + hidden_dim))

        # 4つのゲートのパラメータ(f, i, o, c)
        self.Wf = np.random.randn(hidden_dim, input_dim) * scale_ih
        self.Uf = np.random.randn(hidden_dim, hidden_dim) * scale_hh
        self.bf = np.ones((hidden_dim, 1))  # 忘却ゲートは1で初期化

        self.Wi = np.random.randn(hidden_dim, input_dim) * scale_ih
        self.Ui = np.random.randn(hidden_dim, hidden_dim) * scale_hh
        self.bi = np.zeros((hidden_dim, 1))

        self.Wo = np.random.randn(hidden_dim, input_dim) * scale_ih
        self.Uo = np.random.randn(hidden_dim, hidden_dim) * scale_hh
        self.bo = np.zeros((hidden_dim, 1))

        self.Wc = np.random.randn(hidden_dim, input_dim) * scale_ih
        self.Uc = np.random.randn(hidden_dim, hidden_dim) * scale_hh
        self.bc = np.zeros((hidden_dim, 1))

    def forward(self, x, h_prev, c_prev):
        f = sigmoid(self.Wf @ x + self.Uf @ h_prev + self.bf)
        i = sigmoid(self.Wi @ x + self.Ui @ h_prev + self.bi)
        o = sigmoid(self.Wo @ x + self.Uo @ h_prev + self.bo)
        c_tilde = np.tanh(self.Wc @ x + self.Uc @ h_prev + self.bc)
        c = f * c_prev + i * c_tilde
        h = o * np.tanh(c)
        cache = (x, h_prev, c_prev, f, i, o, c_tilde, c, h)
        return h, c, cache

    def backward(self, dh, dc_next, cache):
        x, h_prev, c_prev, f, i, o, c_tilde, c, h = cache
        tanh_c = np.tanh(c)

        do = dh * tanh_c
        dc = dh * o * (1 - tanh_c ** 2) + dc_next

        df = dc * c_prev
        di = dc * c_tilde
        dc_tilde = dc * i

        df_raw = df * f * (1 - f)
        di_raw = di * i * (1 - i)
        do_raw = do * o * (1 - o)
        dc_tilde_raw = dc_tilde * (1 - c_tilde ** 2)

        # パラメータ勾配
        grads = {
            'Wf': df_raw @ x.T, 'Uf': df_raw @ h_prev.T, 'bf': df_raw,
            'Wi': di_raw @ x.T, 'Ui': di_raw @ h_prev.T, 'bi': di_raw,
            'Wo': do_raw @ x.T, 'Uo': do_raw @ h_prev.T, 'bo': do_raw,
            'Wc': dc_tilde_raw @ x.T, 'Uc': dc_tilde_raw @ h_prev.T, 'bc': dc_tilde_raw,
        }

        # 前時刻への勾配
        dh_prev = (self.Uf.T @ df_raw + self.Ui.T @ di_raw
                  + self.Uo.T @ do_raw + self.Uc.T @ dc_tilde_raw)
        dc_prev = dc * f

        return dh_prev, dc_prev, grads

# ----------------------------
# BiLSTMモデル
# ----------------------------
class BiLSTM:
    """双方向LSTM(系列ラベリング用)"""
    def __init__(self, input_dim, hidden_dim, output_dim, lr=0.01):
        self.hidden_dim = hidden_dim
        self.lr = lr

        # 順方向・逆方向のLSTMセル
        self.fwd_cell = LSTMCell(input_dim, hidden_dim)
        self.bwd_cell = LSTMCell(input_dim, hidden_dim)

        # 出力層: 2h → output_dim
        scale = np.sqrt(2.0 / (2 * hidden_dim + output_dim))
        self.Wy = np.random.randn(output_dim, 2 * hidden_dim) * scale
        self.by = np.zeros((output_dim, 1))

    def forward(self, xs):
        """順伝播: xs は (T, input_dim, 1) のリスト"""
        T = len(xs)
        h_dim = self.hidden_dim

        # 順方向
        self.fwd_caches = []
        fwd_hs = []
        h = np.zeros((h_dim, 1))
        c = np.zeros((h_dim, 1))
        for t in range(T):
            h, c, cache = self.fwd_cell.forward(xs[t], h, c)
            self.fwd_caches.append(cache)
            fwd_hs.append(h)

        # 逆方向
        self.bwd_caches = []
        bwd_hs = [None] * T
        h = np.zeros((h_dim, 1))
        c = np.zeros((h_dim, 1))
        for t in range(T - 1, -1, -1):
            h, c, cache = self.bwd_cell.forward(xs[t], h, c)
            self.bwd_caches.insert(0, cache)
            bwd_hs[t] = h

        # 結合と出力
        self.fwd_hs = fwd_hs
        self.bwd_hs = bwd_hs
        self.concat_hs = []
        self.outputs = []

        for t in range(T):
            h_concat = np.vstack([fwd_hs[t], bwd_hs[t]])  # (2h, 1)
            self.concat_hs.append(h_concat)
            logits = self.Wy @ h_concat + self.by
            probs = softmax(logits)
            self.outputs.append(probs)

        return self.outputs

    def compute_loss(self, outputs, targets):
        """クロスエントロピー損失"""
        loss = 0.0
        for t in range(len(targets)):
            loss -= np.log(outputs[t][targets[t], 0] + 1e-12)
        return loss

    def backward(self, targets):
        """逆伝播"""
        T = len(targets)
        h_dim = self.hidden_dim

        # 出力層の勾配
        dWy = np.zeros_like(self.Wy)
        dby = np.zeros_like(self.by)

        fwd_dhs = [None] * T
        bwd_dhs = [None] * T

        for t in range(T):
            dy = self.outputs[t].copy()
            dy[targets[t]] -= 1.0  # softmax + CEの勾配

            dWy += dy @ self.concat_hs[t].T
            dby += dy

            dh_concat = self.Wy.T @ dy  # (2h, 1)
            fwd_dhs[t] = dh_concat[:h_dim]
            bwd_dhs[t] = dh_concat[h_dim:]

        # 順方向のBPTT
        fwd_grads_accum = {}
        dh_next = np.zeros((h_dim, 1))
        dc_next = np.zeros((h_dim, 1))
        for t in range(T - 1, -1, -1):
            dh = fwd_dhs[t] + dh_next
            dh_prev, dc_prev, grads = self.fwd_cell.backward(dh, dc_next, self.fwd_caches[t])
            dh_next = dh_prev
            dc_next = dc_prev
            for key, val in grads.items():
                if key not in fwd_grads_accum:
                    fwd_grads_accum[key] = np.zeros_like(val)
                fwd_grads_accum[key] += val

        # 逆方向のBPTT(順方向に勾配を伝播)
        bwd_grads_accum = {}
        dh_next = np.zeros((h_dim, 1))
        dc_next = np.zeros((h_dim, 1))
        for t in range(T):  # t=0,1,...,T-1(逆方向RNNの逆順)
            dh = bwd_dhs[t] + dh_next
            dh_prev, dc_prev, grads = self.bwd_cell.backward(dh, dc_next, self.bwd_caches[t])
            dh_next = dh_prev
            dc_next = dc_prev
            for key, val in grads.items():
                if key not in bwd_grads_accum:
                    bwd_grads_accum[key] = np.zeros_like(val)
                bwd_grads_accum[key] += val

        # 勾配クリッピングとパラメータ更新
        for key, grad in fwd_grads_accum.items():
            np.clip(grad, -5, 5, out=grad)
            param = getattr(self.fwd_cell, key)
            param -= self.lr * grad

        for key, grad in bwd_grads_accum.items():
            np.clip(grad, -5, 5, out=grad)
            param = getattr(self.bwd_cell, key)
            param -= self.lr * grad

        np.clip(dWy, -5, 5, out=dWy)
        np.clip(dby, -5, 5, out=dby)
        self.Wy -= self.lr * dWy
        self.by -= self.lr * dby

# ----------------------------
# 単方向LSTMモデル(比較用)
# ----------------------------
class UniLSTM:
    """単方向LSTM(系列ラベリング用)"""
    def __init__(self, input_dim, hidden_dim, output_dim, lr=0.01):
        self.hidden_dim = hidden_dim
        self.lr = lr
        self.cell = LSTMCell(input_dim, hidden_dim)

        scale = np.sqrt(2.0 / (hidden_dim + output_dim))
        self.Wy = np.random.randn(output_dim, hidden_dim) * scale
        self.by = np.zeros((output_dim, 1))

    def forward(self, xs):
        T = len(xs)
        self.caches = []
        self.hs = []
        self.outputs = []

        h = np.zeros((self.hidden_dim, 1))
        c = np.zeros((self.hidden_dim, 1))
        for t in range(T):
            h, c, cache = self.cell.forward(xs[t], h, c)
            self.caches.append(cache)
            self.hs.append(h)
            logits = self.Wy @ h + self.by
            probs = softmax(logits)
            self.outputs.append(probs)

        return self.outputs

    def compute_loss(self, outputs, targets):
        loss = 0.0
        for t in range(len(targets)):
            loss -= np.log(outputs[t][targets[t], 0] + 1e-12)
        return loss

    def backward(self, targets):
        T = len(targets)
        dWy = np.zeros_like(self.Wy)
        dby = np.zeros_like(self.by)

        grads_accum = {}
        dh_next = np.zeros((self.hidden_dim, 1))
        dc_next = np.zeros((self.hidden_dim, 1))

        for t in range(T - 1, -1, -1):
            dy = self.outputs[t].copy()
            dy[targets[t]] -= 1.0

            dWy += dy @ self.hs[t].T
            dby += dy
            dh = self.Wy.T @ dy + dh_next

            dh_prev, dc_prev, grads = self.cell.backward(dh, dc_next, self.caches[t])
            dh_next = dh_prev
            dc_next = dc_prev

            for key, val in grads.items():
                if key not in grads_accum:
                    grads_accum[key] = np.zeros_like(val)
                grads_accum[key] += val

        for key, grad in grads_accum.items():
            np.clip(grad, -5, 5, out=grad)
            param = getattr(self.cell, key)
            param -= self.lr * grad

        np.clip(dWy, -5, 5, out=dWy)
        np.clip(dby, -5, 5, out=dby)
        self.Wy -= self.lr * dWy
        self.by -= self.lr * dby

# ----------------------------
# データ生成
# ----------------------------
np.random.seed(42)

# 英小文字の語彙
CHARS = list('abcdefghijklmnopqrstuvwxyz')
VOWELS = set('aeiou')
CHAR_TO_IDX = {c: i for i, c in enumerate(CHARS)}
VOCAB_SIZE = len(CHARS)
N_CLASSES = 2  # 0: 子音, 1: 母音

def generate_sequence(min_len=5, max_len=15):
    """ランダムな文字列とそのラベル(母音=1, 子音=0)を生成"""
    length = np.random.randint(min_len, max_len + 1)
    seq = [CHARS[np.random.randint(0, VOCAB_SIZE)] for _ in range(length)]
    labels = [1 if c in VOWELS else 0 for c in seq]
    return seq, labels

def seq_to_onehot(seq):
    """文字列をone-hotベクトルのリストに変換"""
    result = []
    for c in seq:
        vec = np.zeros((VOCAB_SIZE, 1))
        vec[CHAR_TO_IDX[c]] = 1.0
        result.append(vec)
    return result

# データセット生成
n_train = 500
n_test = 200

train_data = [generate_sequence() for _ in range(n_train)]
test_data = [generate_sequence() for _ in range(n_test)]

# ----------------------------
# 学習と評価
# ----------------------------
hidden_dim = 16
lr = 0.005
n_epochs = 30

bilstm = BiLSTM(VOCAB_SIZE, hidden_dim, N_CLASSES, lr=lr)
unilstm = UniLSTM(VOCAB_SIZE, hidden_dim, N_CLASSES, lr=lr)

bi_losses = []
uni_losses = []
bi_accs = []
uni_accs = []

for epoch in range(n_epochs):
    bi_epoch_loss = 0.0
    uni_epoch_loss = 0.0

    # シャッフル
    indices = np.random.permutation(n_train)

    for idx in indices:
        seq, labels = train_data[idx]
        xs = seq_to_onehot(seq)

        # BiLSTM
        bi_out = bilstm.forward(xs)
        bi_loss = bilstm.compute_loss(bi_out, labels)
        bilstm.backward(labels)
        bi_epoch_loss += bi_loss

        # 単方向LSTM
        uni_out = unilstm.forward(xs)
        uni_loss = unilstm.compute_loss(uni_out, labels)
        unilstm.backward(labels)
        uni_epoch_loss += uni_loss

    bi_losses.append(bi_epoch_loss / n_train)
    uni_losses.append(uni_epoch_loss / n_train)

    # テスト精度の計算
    bi_correct = 0
    uni_correct = 0
    total = 0

    for seq, labels in test_data:
        xs = seq_to_onehot(seq)

        bi_out = bilstm.forward(xs)
        uni_out = unilstm.forward(xs)

        for t in range(len(labels)):
            bi_pred = int(np.argmax(bi_out[t]))
            uni_pred = int(np.argmax(uni_out[t]))
            if bi_pred == labels[t]:
                bi_correct += 1
            if uni_pred == labels[t]:
                uni_correct += 1
            total += 1

    bi_accs.append(bi_correct / total)
    uni_accs.append(uni_correct / total)

    if (epoch + 1) % 5 == 0:
        print(f"Epoch {epoch+1}/{n_epochs}  "
              f"BiLSTM Loss: {bi_losses[-1]:.4f} Acc: {bi_accs[-1]:.4f}  "
              f"UniLSTM Loss: {uni_losses[-1]:.4f} Acc: {uni_accs[-1]:.4f}")

# ----------------------------
# 結果の可視化
# ----------------------------
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# 損失曲線
ax1.plot(range(1, n_epochs + 1), bi_losses, label="BiLSTM", linewidth=2)
ax1.plot(range(1, n_epochs + 1), uni_losses, label="UniLSTM", linewidth=2, linestyle="--")
ax1.set_xlabel("Epoch")
ax1.set_ylabel("Cross-Entropy Loss")
ax1.set_title("Training Loss: BiLSTM vs UniLSTM")
ax1.legend()
ax1.grid(True, alpha=0.3)

# 精度曲線
ax2.plot(range(1, n_epochs + 1), bi_accs, label="BiLSTM", linewidth=2)
ax2.plot(range(1, n_epochs + 1), uni_accs, label="UniLSTM", linewidth=2, linestyle="--")
ax2.set_xlabel("Epoch")
ax2.set_ylabel("Accuracy")
ax2.set_title("Test Accuracy: BiLSTM vs UniLSTM")
ax2.legend()
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# ----------------------------
# 推論例の表示
# ----------------------------
print("\n--- 推論例(BiLSTM)---")
for i in range(5):
    seq, labels = test_data[i]
    xs = seq_to_onehot(seq)
    outputs = bilstm.forward(xs)

    preds = [int(np.argmax(outputs[t])) for t in range(len(seq))]
    print(f"入力: {''.join(seq)}")
    print(f"正解: {labels}")
    print(f"予測: {preds}")
    print(f"一致: {['o' if p == l else 'x' for p, l in zip(preds, labels)]}")
    print()

このコードでは、BiLSTMと単方向LSTMの両方を母音判定タスクで学習し、性能を比較しています。BiLSTMは各位置で前後の文脈を利用できるため、より速く高い精度に到達することが確認できます。

まとめ

本記事では、双方向RNN(Bidirectional RNN)について解説しました。

  • 単方向RNNは過去の文脈のみを利用しますが、双方向RNNは順方向と逆方向の2つのRNNを組み合わせることで、各時刻で過去と未来の両方の文脈を利用できます
  • 出力は $\bm{y}_t = g(\bm{W}_y [\overrightarrow{\bm{h}}_t ; \overleftarrow{\bm{h}}_t] + \bm{b}_y)$ のように、結合された隠れ状態から計算されます
  • BiLSTM・BiGRUはNLPの系列ラベリングタスク(品詞タグ付け、固有表現認識など)で広く使われてきました
  • 学習時は順方向と逆方向のBPTTを独立に実行でき、並列化が可能です
  • 入力系列全体が必要なため、リアルタイム予測や自己回帰生成には使用できないという制約があります

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