Seq2Seq(Encoder-Decoder)モデルの理論と実装を解説

自然言語処理における機械翻訳、テキスト要約、対話システムなど、多くのタスクでは「可変長の入力系列から可変長の出力系列への変換」が求められます。従来のRNNは固定長の入力に対して固定長の出力を返す構造であり、入力と出力の長さが異なるタスクには直接適用できませんでした。

Seq2Seq(Sequence-to-Sequence)モデル、別名Encoder-Decoderモデルは、Sutskever et al.(2014)およびCho et al.(2014)により提案された、可変長系列間の変換を可能にするアーキテクチャです。本記事では、Seq2Seqの理論的基盤を条件付き確率の定式化から丁寧に導出し、Teacher Forcingやビームサーチなどのテクニックについても解説します。最後にPythonで数列変換タスクへのSeq2Seq実装を行います。

本記事の内容

  • Seq2Seqの動機と問題設定
  • 条件付き確率 $P(\bm{y}|\bm{x})$ の定式化
  • Encoderの構造と役割
  • Decoderの構造と自己回帰生成
  • Teacher Forcingによる効率的な学習
  • 貪欲デコーディングとビームサーチ
  • ボトルネック問題とAttention機構への動機付け
  • Pythonでの数列逆順出力タスクの実装

前提知識

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

Seq2Seqの問題設定

可変長入力から可変長出力への変換

入力系列 $\bm{x} = (x_1, x_2, \dots, x_{T_x})$ と出力系列 $\bm{y} = (y_1, y_2, \dots, y_{T_y})$ があり、一般に $T_x \neq T_y$ です。例として以下のようなタスクがあります。

タスク 入力 出力 長さの関係
機械翻訳 英語文 “I love you” 日本語文 “私はあなたが好きです” $T_x \neq T_y$
テキスト要約 長い文書 短い要約文 $T_x \gg T_y$
対話 ユーザの発話 システムの応答 $T_x \neq T_y$

このような系列変換問題を、1つのモデルでend-to-endに解くのがSeq2Seqの目標です。

確率的定式化

Seq2Seqは、入力系列 $\bm{x}$ が与えられた下での出力系列 $\bm{y}$ の条件付き確率 $P(\bm{y}|\bm{x})$ を最大化するようにモデルを学習します。

確率の連鎖律(chain rule)を適用すると、

$$ P(\bm{y}|\bm{x}) = P(y_1, y_2, \dots, y_{T_y}|\bm{x}) $$

これを逐次的に分解します。

$$ \begin{align} P(\bm{y}|\bm{x}) &= P(y_1|\bm{x}) \cdot P(y_2|y_1, \bm{x}) \cdot P(y_3|y_1, y_2, \bm{x}) \cdots P(y_{T_y}|y_1, \dots, y_{T_y-1}, \bm{x}) \\ &= \prod_{t=1}^{T_y} P(y_t | y_1, \dots, y_{t-1}, \bm{x}) \\ &= \prod_{t=1}^{T_y} P(y_t | \bm{y}_{

ここで $\bm{y}_{

この分解により、出力系列の生成を「各時刻 $t$ で、過去の出力と入力全体に基づいて次のトークンを予測する」という自己回帰的なプロセスとして定式化できます。

学習の目的関数

学習データ $\{(\bm{x}^{(n)}, \bm{y}^{(n)})\}_{n=1}^{N}$ に対して、対数尤度を最大化します。

$$ \mathcal{L} = \sum_{n=1}^{N} \log P(\bm{y}^{(n)}|\bm{x}^{(n)}) = \sum_{n=1}^{N} \sum_{t=1}^{T_y^{(n)}} \log P(y_t^{(n)} | \bm{y}_{

実際にはこの符号を反転させた クロスエントロピー損失 を最小化します。

$$ \text{Loss} = -\frac{1}{N} \sum_{n=1}^{N} \sum_{t=1}^{T_y^{(n)}} \log P(y_t^{(n)} | \bm{y}_{

Encoderの構造

入力系列の圧縮

Encoderは入力系列 $\bm{x} = (x_1, \dots, x_{T_x})$ を固定長のベクトルに圧縮する役割を担います。一般にRNN(LSTM/GRU)が用いられます。

各時刻 $t = 1, \dots, T_x$ において、Encoderは以下の再帰計算を行います。

$$ \bm{h}_t^{\text{enc}} = f_{\text{enc}}(\bm{x}_t, \bm{h}_{t-1}^{\text{enc}}) $$

ここで $f_{\text{enc}}$ はRNNセル(LSTM/GRU)の更新関数、$\bm{h}_t^{\text{enc}} \in \mathbb{R}^{h_{\text{enc}}}$ はEncoder の隠れ状態です。初期状態は $\bm{h}_0^{\text{enc}} = \bm{0}$ とします。

コンテキストベクトル

入力系列全体の情報は、最終時刻の隠れ状態に集約されます。

$$ \bm{c} = \bm{h}_{T_x}^{\text{enc}} $$

このベクトル $\bm{c} \in \mathbb{R}^{h_{\text{enc}}}$ を コンテキストベクトル(context vector)と呼びます。$\bm{c}$ は入力系列 $\bm{x}$ の「要約」として機能し、Decoderに渡されます。

LSTMを用いる場合、セル状態 $\bm{c}_{T_x}^{\text{enc}}$ もDecoderの初期セル状態として渡すのが一般的です。

$$ \bm{h}_0^{\text{dec}} = \bm{h}_{T_x}^{\text{enc}}, \quad \bm{c}_0^{\text{dec}} = \bm{c}_{T_x}^{\text{enc}} $$

EncoderとDecoderの隠れ次元が異なる場合は、線形変換を挟みます。

$$ \bm{h}_0^{\text{dec}} = \bm{W}_{\text{init}} \bm{h}_{T_x}^{\text{enc}} + \bm{b}_{\text{init}} $$

ここで $\bm{W}_{\text{init}} \in \mathbb{R}^{h_{\text{dec}} \times h_{\text{enc}}}$ です。

Decoderの構造

自己回帰生成

Decoderは、コンテキストベクトル $\bm{c}$ を初期状態として受け取り、出力系列を1トークンずつ自己回帰的に生成します。

各時刻 $t = 1, \dots, T_y$ において、

$$ \bm{h}_t^{\text{dec}} = f_{\text{dec}}(\bm{y}_{t-1}, \bm{h}_{t-1}^{\text{dec}}) $$

ここで $\bm{y}_{t-1}$ は前の時刻に出力されたトークン(の埋め込みベクトル)、$f_{\text{dec}}$ はDecoderのRNNセルです。$t = 1$ では $\bm{y}_0$ として特別な開始トークン $\langle \text{SOS} \rangle$ を使います。

出力確率の計算

Decoderの隠れ状態 $\bm{h}_t^{\text{dec}}$ から、出力語彙 $\mathcal{V}$ 上の確率分布を計算します。

$$ P(y_t | \bm{y}_{

ここで $\bm{W}_{\text{out}} \in \mathbb{R}^{|\mathcal{V}| \times h_{\text{dec}}}$ は出力射影行列です。softmax関数は以下のように定義されます。

$$ \text{softmax}(\bm{a})_k = \frac{\exp(a_k)}{\sum_{j=1}^{|\mathcal{V}|} \exp(a_j)} $$

生成の終了条件

生成は特別な終了トークン $\langle \text{EOS} \rangle$ が出力されたとき、または最大出力長に達したときに終了します。

Teacher Forcing

訓練時の問題

Decoderは自己回帰的に、前のステップの出力 $\hat{y}_{t-1}$ を次のステップの入力として使います。しかし、学習初期にはモデルの予測 $\hat{y}_{t-1}$ は正解とは大きくずれています。誤った予測を次の入力として使うと、誤差が累積的に増大し(exposure bias)、学習が不安定になります。

Teacher Forcingの仕組み

Teacher Forcingでは、訓練時にDecoderの入力として、モデルの予測 $\hat{y}_{t-1}$ ではなく正解ラベル $y_{t-1}^{*}$ を使います。

$$ \bm{h}_t^{\text{dec}} = f_{\text{dec}}(y_{t-1}^{*}, \bm{h}_{t-1}^{\text{dec}}) \quad \text{(訓練時)} $$

これにより、各時刻のDecoderは常に正しい文脈を受け取るため、学習が安定し高速に収束します。

Teacher Forcingの問題点

Teacher Forcingには exposure bias という問題があります。訓練時には常に正解が入力されますが、推論時にはモデル自身の予測(誤りを含む可能性がある)が入力されます。この訓練と推論のギャップがモデルの性能を低下させることがあります。

対策として、Scheduled Sampling(Bengio et al., 2015)があります。これは訓練中に正解ラベルを使う確率を徐々に下げ、モデル自身の予測を使う確率を上げていく手法です。

確率 $\epsilon_i$ をエポック $i$ に応じて減衰させます。

$$ \epsilon_i = \frac{k}{k + \exp(i / k)} $$

ここで $k$ はハイパーパラメータです。確率 $\epsilon_i$ で正解ラベルを使い、$1 – \epsilon_i$ でモデルの予測を使います。

デコーディング戦略

貪欲デコーディング(Greedy Decoding)

最も単純な方法は、各時刻で最も確率の高いトークンを選択することです。

$$ \hat{y}_t = \arg\max_{y \in \mathcal{V}} P(y | \hat{\bm{y}}_{

計算量は $O(T_y \cdot |\mathcal{V}|)$ で効率的ですが、局所最適解に陥りやすいという問題があります。

ビームサーチ(Beam Search)

ビームサーチは、上位 $B$ 個の候補(ビーム幅 $B$)を維持しながら探索を進める方法です。

アルゴリズム:

  1. 初期化: $\langle \text{SOS} \rangle$ のみからなる候補集合 $\mathcal{B}_0 = \{(\langle \text{SOS} \rangle, 0)\}$
  2. 各時刻 $t$ で: – 各候補 $(\hat{\bm{y}}_{
  3. $\langle \text{EOS} \rangle$ が出力された候補を完了候補に移動
  4. 全候補が完了、または最大長に達したら終了

スコアの長さ正規化: 短い系列のスコアが不当に有利にならないよう、長さで正規化します。

$$ \text{score}(\bm{y}) = \frac{1}{T_y^{\alpha}} \sum_{t=1}^{T_y} \log P(y_t | \bm{y}_{

$\alpha$ は通常0.6〜0.7程度に設定します。$\alpha = 0$ なら正規化なし、$\alpha = 1$ なら完全な平均です。

貪欲法とビームサーチの比較

ビーム幅 $B = 1$ が貪欲デコーディングに一致します。$B$ を大きくすると探索空間が広がりますが、計算量は $O(T_y \cdot B \cdot |\mathcal{V}|)$ に増加します。実用上は $B = 4 \sim 10$ 程度がよく使われます。

ボトルネック問題

固定長ベクトルの限界

Seq2Seqの根本的な問題は、入力系列の全情報を固定長のコンテキストベクトル $\bm{c} \in \mathbb{R}^{h}$ に圧縮しなければならない点です。

情報理論的に考えると、入力系列の長さ $T_x$ が増加すると、$\bm{c}$ に詰め込むべき情報量も増加します。しかし $\bm{c}$ の次元 $h$ は固定であるため、長い入力系列では情報の損失が避けられません。

実験的にも、Cho et al.(2014)は入力文の長さが20〜30語を超えるとBLEUスコアが急激に低下することを報告しています。

RNNの忘却特性

RNNの隠れ状態は逐次的に更新されるため、系列の先頭付近の情報は後方の更新によって徐々に上書きされます。たとえLSTM/GRUのゲート機構があっても、非常に長い系列の先頭情報を完全に保持することは困難です。

数学的には、$T_x$ ステップ後の隠れ状態 $\bm{h}_{T_x}$ における初期入力 $x_1$ の影響は、ゲート値の連続積によって決まります。

$$ \frac{\partial \bm{h}_{T_x}}{\partial \bm{x}_1} \propto \prod_{t=2}^{T_x} \frac{\partial \bm{h}_t}{\partial \bm{h}_{t-1}} $$

ゲート値が1に近い次元を除けば、この勾配は指数的に減衰します。

Attention機構への動機付け

ボトルネック問題を解決するために提案されたのがAttention機構(Bahdanau et al., 2015)です。Attentionでは、Decoderの各時刻 $t$ で、Encoderの全隠れ状態 $\bm{h}_1^{\text{enc}}, \dots, \bm{h}_{T_x}^{\text{enc}}$ に対して動的に重み(注意)を計算し、コンテキストベクトルを時刻ごとに変えます。

$$ \bm{c}_t = \sum_{s=1}^{T_x} \alpha_{t,s} \bm{h}_s^{\text{enc}} $$

これにより、Decoderは入力系列の任意の部分に直接アクセスでき、固定長ベクトルのボトルネックを回避できます。Attention機構の詳細は後続の記事で解説します。

Pythonでの実装

問題設定: 数列の逆順出力

Seq2Seqの動作を確認するために、数列の逆順出力というシンプルなタスクを実装します。例えば入力 [1, 3, 5, 2] に対して [2, 5, 3, 1] を出力するタスクです。

語彙は0〜9の数字と、特殊トークン SOS(開始)、EOS(終了)、PAD(パディング)です。

import numpy as np
import matplotlib.pyplot as plt

# ----------------------------
# 定数と特殊トークン
# ----------------------------
PAD = 0
SOS = 10
EOS = 11
VOCAB_SIZE = 12  # 0-9, SOS, EOS

# ----------------------------
# データ生成: 数列逆順タスク
# ----------------------------
def generate_data(n_samples, min_len=3, max_len=6):
    """ランダムな数列とその逆順をペアで生成"""
    data = []
    for _ in range(n_samples):
        length = np.random.randint(min_len, max_len + 1)
        seq = np.random.randint(1, 10, size=length).tolist()  # 1-9の数列
        target = seq[::-1]  # 逆順
        data.append((seq, target))
    return data

# ----------------------------
# ユーティリティ関数
# ----------------------------
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))
    return e / np.sum(e)

def one_hot(idx, size):
    """ワンホットベクトルを生成"""
    vec = np.zeros((size, 1))
    vec[idx] = 1.0
    return vec

# ----------------------------
# GRUセル(Encoder/Decoder共通)
# ----------------------------
class GRUCell:
    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))

        self.Wz = np.random.randn(hidden_dim, input_dim) * scale_ih
        self.Uz = np.random.randn(hidden_dim, hidden_dim) * scale_hh
        self.bz = np.zeros((hidden_dim, 1))
        self.Wr = np.random.randn(hidden_dim, input_dim) * scale_ih
        self.Ur = np.random.randn(hidden_dim, hidden_dim) * scale_hh
        self.br = np.zeros((hidden_dim, 1))
        self.Wh = np.random.randn(hidden_dim, input_dim) * scale_ih
        self.Uh = np.random.randn(hidden_dim, hidden_dim) * scale_hh
        self.bh = np.zeros((hidden_dim, 1))

    def forward(self, x, h_prev):
        """1ステップの順伝播"""
        z = sigmoid(self.Wz @ x + self.Uz @ h_prev + self.bz)
        r = sigmoid(self.Wr @ x + self.Ur @ h_prev + self.br)
        h_tilde = np.tanh(self.Wh @ x + self.Uh @ (r * h_prev) + self.bh)
        h = z * h_prev + (1 - z) * h_tilde
        return h, z, r, h_tilde

    def get_params(self):
        return [self.Wz, self.Uz, self.bz, self.Wr, self.Ur, self.br,
                self.Wh, self.Uh, self.bh]

# ----------------------------
# Seq2Seqモデル
# ----------------------------
class Seq2Seq:
    def __init__(self, vocab_size, hidden_dim, lr=0.01):
        self.vocab_size = vocab_size
        self.hidden_dim = hidden_dim
        self.lr = lr

        # Encoder GRUセル
        self.encoder = GRUCell(vocab_size, hidden_dim)

        # Decoder GRUセル
        self.decoder = GRUCell(vocab_size, hidden_dim)

        # 出力層: 隠れ状態 → 語彙分布
        scale = np.sqrt(2.0 / (hidden_dim + vocab_size))
        self.W_out = np.random.randn(vocab_size, hidden_dim) * scale
        self.b_out = np.zeros((vocab_size, 1))

    def encode(self, input_seq):
        """Encoder: 入力系列 → コンテキストベクトル"""
        h = np.zeros((self.hidden_dim, 1))
        self.enc_states = []

        for token in input_seq:
            x = one_hot(token, self.vocab_size)
            h, z, r, h_tilde = self.encoder.forward(x, h)
            self.enc_states.append({
                'x': x, 'h': h, 'z': z, 'r': r, 'h_tilde': h_tilde,
                'h_prev': self.enc_states[-1]['h'] if self.enc_states else np.zeros_like(h)
            })

        return h  # コンテキストベクトル

    def decode_train(self, context, target_seq):
        """Decoder(Teacher Forcing使用): 学習時"""
        h = context
        self.dec_states = []
        loss = 0.0

        # SOS + target_seq がDecoderへの入力
        input_tokens = [SOS] + target_seq

        for t in range(len(target_seq)):
            x = one_hot(input_tokens[t], self.vocab_size)
            h_prev = h
            h, z, r, h_tilde = self.decoder.forward(x, h)

            # 出力確率の計算
            logits = self.W_out @ h + self.b_out
            probs = softmax(logits)

            # クロスエントロピー損失
            target_token = target_seq[t]
            loss -= np.log(probs[target_token, 0] + 1e-12)

            self.dec_states.append({
                'x': x, 'h': h, 'h_prev': h_prev, 'z': z, 'r': r,
                'h_tilde': h_tilde, 'logits': logits, 'probs': probs,
                'target': target_token
            })

        return loss

    def decode_inference(self, context, max_len=10):
        """Decoder(推論時): 貪欲デコーディング"""
        h = context
        output = []
        token = SOS

        for _ in range(max_len):
            x = one_hot(token, self.vocab_size)
            h, _, _, _ = self.decoder.forward(x, h)
            logits = self.W_out @ h + self.b_out
            probs = softmax(logits)
            token = int(np.argmax(probs))

            if token == EOS:
                break
            output.append(token)

        return output

    def backward(self, input_seq, target_seq):
        """逆伝播: 数値勾配で簡易実装"""
        eps = 1e-5
        all_params = (self.encoder.get_params()
                     + self.decoder.get_params()
                     + [self.W_out, self.b_out])
        grads = []

        for param in all_params:
            grad = np.zeros_like(param)
            it = np.nditer(param, flags=['multi_index'])
            while not it.finished:
                idx = it.multi_index

                # f(x + eps)
                param[idx] += eps
                context_plus = self.encode(input_seq)
                loss_plus = self.decode_train(context_plus, target_seq)

                # f(x - eps)
                param[idx] -= 2 * eps
                context_minus = self.encode(input_seq)
                loss_minus = self.decode_train(context_minus, target_seq)

                # 中心差分
                grad[idx] = (loss_plus - loss_minus) / (2 * eps)
                param[idx] += eps  # 元に戻す

                it.iternext()
            grads.append(grad)

        # パラメータ更新
        for param, grad in zip(all_params, grads):
            np.clip(grad, -5, 5, out=grad)
            param -= self.lr * grad

    def train_analytic(self, input_seq, target_seq):
        """解析的な逆伝播による学習(出力層のみ高速化)"""
        # 順伝播
        context = self.encode(input_seq)
        loss = self.decode_train(context, target_seq)

        # Decoder出力層の解析的勾配
        dW_out = np.zeros_like(self.W_out)
        db_out = np.zeros_like(self.b_out)

        # Decoder隠れ状態の勾配
        dh_next = np.zeros((self.hidden_dim, 1))

        # 各Decoderタイムステップを逆順に辿る
        dec_dhs = []
        for t in reversed(range(len(self.dec_states))):
            state = self.dec_states[t]

            # softmaxの勾配
            dy = state['probs'].copy()
            dy[state['target']] -= 1.0  # ∂L/∂logits

            dW_out += dy @ state['h'].T
            db_out += dy
            dh = self.W_out.T @ dy + dh_next

            # GRUの逆伝播
            z = state['z']
            r = state['r']
            h_tilde = state['h_tilde']
            h_prev = state['h_prev']

            dz = dh * (h_prev - h_tilde)
            dz_raw = dz * z * (1 - z)

            dh_tilde = dh * (1 - z)
            dh_tilde_raw = dh_tilde * (1 - h_tilde ** 2)

            dr = (self.decoder.Uh.T @ dh_tilde_raw) * h_prev
            dr_raw = dr * r * (1 - r)

            # Decoder GRUのパラメータ勾配
            self.decoder.Wz -= self.lr * (dz_raw @ state['x'].T)
            self.decoder.Uz -= self.lr * (dz_raw @ h_prev.T)
            self.decoder.bz -= self.lr * dz_raw
            self.decoder.Wr -= self.lr * (dr_raw @ state['x'].T)
            self.decoder.Ur -= self.lr * (dr_raw @ h_prev.T)
            self.decoder.br -= self.lr * dr_raw
            self.decoder.Wh -= self.lr * (dh_tilde_raw @ state['x'].T)
            self.decoder.Uh -= self.lr * (dh_tilde_raw @ (r * h_prev).T)
            self.decoder.bh -= self.lr * dh_tilde_raw

            # 前時刻への勾配伝播
            dh_next = (dh * z
                      + self.decoder.Uz.T @ dz_raw
                      + self.decoder.Ur.T @ dr_raw
                      + (self.decoder.Uh.T @ dh_tilde_raw) * r)

            dec_dhs.append(dh_next)

        # 出力層パラメータ更新
        np.clip(dW_out, -5, 5, out=dW_out)
        np.clip(db_out, -5, 5, out=db_out)
        self.W_out -= self.lr * dW_out
        self.b_out -= self.lr * db_out

        # Encoderの逆伝播(dh_nextをEncoderに伝播)
        dh_enc = dh_next
        for t in reversed(range(len(self.enc_states))):
            state = self.enc_states[t]
            z = state['z']
            r = state['r']
            h_tilde = state['h_tilde']
            h_prev = state['h_prev']

            dz = dh_enc * (h_prev - h_tilde)
            dz_raw = dz * z * (1 - z)

            dh_tilde = dh_enc * (1 - z)
            dh_tilde_raw = dh_tilde * (1 - h_tilde ** 2)

            dr = (self.encoder.Uh.T @ dh_tilde_raw) * h_prev
            dr_raw = dr * r * (1 - r)

            self.encoder.Wz -= self.lr * (dz_raw @ state['x'].T)
            self.encoder.Uz -= self.lr * (dz_raw @ h_prev.T)
            self.encoder.bz -= self.lr * dz_raw
            self.encoder.Wr -= self.lr * (dr_raw @ state['x'].T)
            self.encoder.Ur -= self.lr * (dr_raw @ h_prev.T)
            self.encoder.br -= self.lr * dr_raw
            self.encoder.Wh -= self.lr * (dh_tilde_raw @ state['x'].T)
            self.encoder.Uh -= self.lr * (dh_tilde_raw @ (r * h_prev).T)
            self.encoder.bh -= self.lr * dh_tilde_raw

            dh_enc = (dh_enc * z
                     + self.encoder.Uz.T @ dz_raw
                     + self.encoder.Ur.T @ dr_raw
                     + (self.encoder.Uh.T @ dh_tilde_raw) * r)

        return loss

# ----------------------------
# 学習の実行
# ----------------------------
np.random.seed(42)

# ハイパーパラメータ
hidden_dim = 32
lr = 0.005
n_epochs = 100
n_train = 500
n_test = 100

# データ生成
train_data = generate_data(n_train, min_len=3, max_len=5)
test_data = generate_data(n_test, min_len=3, max_len=5)

# モデル初期化
model = Seq2Seq(VOCAB_SIZE, hidden_dim, lr=lr)

# 学習ループ
losses = []
accuracies = []

for epoch in range(n_epochs):
    epoch_loss = 0.0
    np.random.shuffle(train_data)

    for input_seq, target_seq in train_data:
        target_with_eos = target_seq + [EOS]
        loss = model.train_analytic(input_seq, target_with_eos)
        epoch_loss += loss

    avg_loss = epoch_loss / n_train
    losses.append(avg_loss)

    # テスト精度の計算
    if (epoch + 1) % 10 == 0:
        correct = 0
        for input_seq, target_seq in test_data:
            context = model.encode(input_seq)
            pred = model.decode_inference(context, max_len=len(target_seq) + 2)
            if pred == target_seq:
                correct += 1
        acc = correct / n_test
        accuracies.append((epoch + 1, acc))
        print(f"Epoch {epoch+1}/{n_epochs}  Loss: {avg_loss:.4f}  "
              f"Test Accuracy: {acc:.2%}")

# ----------------------------
# 学習曲線の可視化
# ----------------------------
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# 損失曲線
ax1.plot(range(1, n_epochs + 1), losses, linewidth=2)
ax1.set_xlabel("Epoch")
ax1.set_ylabel("Cross-Entropy Loss")
ax1.set_title("Training Loss")
ax1.grid(True, alpha=0.3)

# 精度曲線
if accuracies:
    epochs_acc, accs = zip(*accuracies)
    ax2.plot(epochs_acc, accs, 'o-', linewidth=2, markersize=8)
    ax2.set_xlabel("Epoch")
    ax2.set_ylabel("Sequence Accuracy")
    ax2.set_title("Test Accuracy (Exact Match)")
    ax2.set_ylim([0, 1.05])
    ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# ----------------------------
# 推論例の表示
# ----------------------------
print("\n--- 推論例 ---")
for i in range(10):
    input_seq, target_seq = test_data[i]
    context = model.encode(input_seq)
    pred = model.decode_inference(context, max_len=len(target_seq) + 2)
    status = "OK" if pred == target_seq else "NG"
    print(f"入力: {input_seq}  正解: {target_seq}  予測: {pred}  [{status}]")

このコードでは、GRUベースのEncoder-Decoderモデルを実装しています。Encoderが入力数列を固定長ベクトルに圧縮し、Decoderが自己回帰的に逆順の数列を生成します。Teacher Forcingを使った学習と、貪欲デコーディングによる推論を行います。

学習を進めると、短い数列(長さ3〜5)の逆順出力タスクについて、徐々に正解率が向上していく様子を確認できます。

まとめ

本記事では、Seq2Seq(Encoder-Decoder)モデルについて解説しました。

  • Seq2Seqは、可変長の入力系列を固定長のコンテキストベクトルに圧縮(Encoder)し、そこから可変長の出力系列を自己回帰的に生成(Decoder)するアーキテクチャです
  • 出力系列の生成は、条件付き確率 $P(\bm{y}|\bm{x}) = \prod_t P(y_t|\bm{y}_{
  • Teacher Forcingにより学習を安定・高速化しますが、訓練時と推論時のギャップ(exposure bias)が生じます
  • ビームサーチにより、貪欲法よりも良い出力系列を探索できます
  • コンテキストベクトルのボトルネック問題は、Attention機構により解決されます

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