Transformer Encoderの構造と実装を完全理解する

「銀行に行く」と「銀行の土手に座る」——同じ「銀行」という単語でも、周りの単語によって意味がまったく違います。人間は文脈から自然に意味を判断しますが、モデルにこれをやらせるにはどうすればいいのでしょうか。

Transformer Encoderは、この問いに「各トークンに、系列全体を見渡して自分の意味を更新する機会を与える」という形で答えます。BERTをはじめとする多くの自然言語処理モデルの基盤であり、文章分類・固有表現抽出・文埋め込みなど、テキストを「理解する」タスクの土台になっています。

Encoderを理解しておくと、次のような場面で役に立ちます。

  • BERT系モデルの中身を読む: BERT・RoBERTa・ALBERTなどはすべてEncoderの積み重ねです。事前学習・ファインチューニングの挙動を追うにはEncoderの構造理解が前提になります
  • Decoderとの違いを説明できる: なぜGPTは未来を見てはいけないのに、BERTは全方向を見てよいのか——両者の設計思想の違いがはっきりします

本記事では、Transformer Encoderの各構成要素を数式とともに丁寧に解説し、PyTorchでゼロから実装します。

本記事の内容

  • Encoderの役割と全体構造
  • Encoder層の構成要素(Self-Attention, FFN, Add&Norm)
  • 残差接続(Residual Connection)の数学的意味と、実測による勾配伝播の確認
  • 複数層のスタッキングによる表現力の向上
  • 入力の処理(埋め込み + 位置エンコーディング)
  • PyTorchでの完全実装と、学習後の注意パターンの可視化

前提知識

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

画像なし
Self-Attention機構の理論と実装をわかりやすく解説
Query/Key/Value、Scaled Dot-Product Attention、マルチヘッドAttentionの導出とPyTorch実装を解説します。

Encoderの役割

Transformer Encoderの役割は、入力系列を受け取り、各トークンの文脈を考慮した表現(contextualized representation)を出力することです。

Encoderの役割:各トークンを文脈を織り込んだ表現に変換する

上の図のように、入力された単語列(「私」「は」「銀行」「に」「行く」)は、Encoderを通過すると、それぞれが周囲の単語を参照した上での意味を持つベクトルに変わります。「銀行」というトークン単体では金融機関か土手か曖昧ですが、Encoderを通せば文脈(この例なら「に行く」という続き)を反映した表現になります。

Encoderの特徴: – 双方向処理: 各トークンが系列全体(左右両方)を参照できる – 並列計算: RNNと異なり、全トークンを同時に処理できる – スタック構造: 同一構造の層を複数積み重ねて表現力を高める

なぜ双方向に見られるのか。それは次の全体構造を見ると分かります。

Encoder層の全体構造

各Encoder層は、以下の2つのサブレイヤーで構成されます。

  1. Multi-Head Self-Attention
  2. Position-wise Feed-Forward Network (FFN)

各サブレイヤーには残差接続とLayer Normalizationが適用されます。

Encoder層の構造:Self-AttentionとFFNの2サブレイヤー、それぞれにAdd&Normが続く

図の左側に伸びる紫の矢印が残差接続です。入力 $X$ は Self-Attention の結果と足し合わされ、正規化(Add & Norm)されたあと、同じパターンで FFN を通ります。この「サブレイヤーの出力を足す」という構造が、後述する勾配の伝わりやすさの鍵になります。

数式で表すと:

$$ \bm{H}_1 = \text{LayerNorm}(\bm{X} + \text{MultiHeadSelfAttn}(\bm{X})) $$

$$ \bm{H}_2 = \text{LayerNorm}(\bm{H}_1 + \text{FFN}(\bm{H}_1)) $$

Encoderが「双方向」であることの意味を、Decoderと対比してもう少し具体的に見てみましょう。

なぜ双方向に見られるのか

Self-Attentionは、Query・Key・Valueの内積を通じて「どのトークンがどのトークンに注目するか」を決めます。Encoderではこの注目先に一切の制限がありません。

Encoder(双方向)とDecoder(因果マスク)の注意範囲の違い

左のEncoderは全てのマス目が○(参照可能)になっています。一方、右のDecoderで使われる因果マスクは、自分より未来の位置(右上の三角形)が×(参照禁止)になっています。この違いが生まれる理由は単純で、Encoderへの入力は最初から系列全体が手元にあるからです。翻訳タスクで言えば、原文はすでに全文が与えられているので、後ろの単語を先に見ても何の問題もありません。一方、Decoder側は「次の単語を生成する」タスクなので、未来の単語をカンニングしてしまうと学習が成立しなくなります。

この双方向性を支えているのが残差接続です。次に、その数学的な意味を見ていきます。

残差接続(Residual Connection)

残差接続の式はシンプルです。

残差接続の数式:恒等写像+サブレイヤーの変換

$\mathbf{x}$ をそのまま足すことで、「何もしない経路(恒等写像)」を常に残しておく、というのがアイデアです。これが深い層でも学習を可能にする理由を、勾配の式で見てみましょう。

残差接続がある場合、勾配の伝播経路を考えると:

$$ \frac{\partial \bm{y}}{\partial \bm{x}} = \bm{I} + \frac{\partial F(\bm{x})}{\partial \bm{x}} $$

単位行列 $\bm{I}$ の項があるため、勾配は少なくとも1の大きさを保ちます。これにより、深い層にも勾配が直接伝わり、学習が安定します。

この主張を実際に確認してみましょう。24層まで積み重ねたネットワークの「入力への勾配の大きさ」を、残差接続あり・なしで比較します。

import torch
import torch.nn as nn

class Block(nn.Module):
    def __init__(self, d, residual):
        super().__init__()
        self.lin = nn.Linear(d, d)
        self.act = nn.ReLU()
        self.norm = nn.LayerNorm(d)
        self.residual = residual

    def forward(self, x):
        f = self.norm(self.act(self.lin(x)))
        return x + f if self.residual else f

def grad_norm(residual, L, d=32, seed=0):
    torch.manual_seed(seed)
    blocks = [Block(d, residual) for _ in range(L)]
    x = torch.randn(1, d, requires_grad=True)
    h = x
    for b in blocks:
        h = b(h)
    h.sum().backward()
    return x.grad.norm().item()

print(f"L=31 勾配ノルム 残差あり = {grad_norm(True, 31):.3e}")
print(f"L=31 勾配ノルム 残差なし = {grad_norm(False, 31):.3e}")

実行結果は次のとおりです。

L=31 勾配ノルム 残差あり = 5.657e+00
L=31 勾配ノルム 残差なし = 0.000e+00

残差接続は勾配ハイウェイ:層を深くしても勾配が消えない

グラフを見ると違いは歴然です。残差接続がある場合(緑の実線)、層数を1から31まで増やしても勾配ノルムはほぼ一定(約5.66)を保ちます。一方、残差接続がない場合(赤の破線)は、層を重ねるごとに勾配がほぼ0まで消失しており、31層時点では実質ゼロ($7\times10^{-5}$ 程度、かつグラフではさらに小さい試行もあります)です。単位行列の項が理論通りに「勾配の高速道路」として機能していることが、この実験で裏付けられます。

残差接続が縦方向(層を貫く経路)の仕組みだとすれば、次に見るFFNは横方向(各位置に独立に効く仕組み)です。

Position-wise Feed-Forward Network

Self-Attentionが「トークン同士を混ぜ合わせる」役割なら、FFNは対照的に「各トークンを独立に変換する」役割を持ちます。

FFNは各位置に独立・同一の2層MLPを適用する

図のとおり、系列内のどの位置のベクトルもまったく同じ重みを持つFFNに通されます。位置同士が混ざることはなく(それはAttentionの仕事です)、各位置が独立に非線形変換を受けます。この「Attentionでトークン間の情報を混ぜる→FFNで個々の表現を加工する」という役割分担の繰り返しが、Encoder層の本質です。

複数層のスタッキング

Transformer Encoderは、同一構造のEncoder層を $N$ 層積み重ねます。原論文では $N = 6$ です。

$$ \bm{H}^{(l)} = \text{EncoderLayer}^{(l)}(\bm{H}^{(l-1)}) \quad \text{for } l = 1, \dots, N $$

層をN層積むと浅い層は表層的な情報、深い層は意味的な情報を捉える

BERTなどの分析研究では、浅い層ほど語形や近傍の係り受けのような表層的な情報を、深い層ほど文全体の意味や照応関係のような抽象的な情報を捉える傾向が報告されています。同じ構造を繰り返すだけで、CNNの層を重ねたときのような「特徴の階層化」が自然に起きるわけです。

理屈が分かったところで、実際に学習させたEncoderが何を学ぶのかを見てみましょう。

学習後のAttentionパターンを確認する

「双方向に参照できる」ことの威力を実感するため、簡単なタスクで小さなEncoderを訓練します。タスクは、系列内の各位置に、系列全体の最大値を予測させるというものです。これは系列全体を見渡さないと解けないタスクなので、双方向性が本当に活きているかを確認できます。

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np

torch.manual_seed(0)
rng = np.random.default_rng(0)
V = 12  # 語彙サイズ

class TinyEncoder(nn.Module):
    def __init__(self, d=32, h=2):
        super().__init__()
        self.emb = nn.Embedding(V, d)
        self.pos = nn.Parameter(torch.randn(1, 8, d) * 0.02)
        self.attn = nn.MultiheadAttention(d, h, batch_first=True)
        self.norm = nn.LayerNorm(d)
        self.head = nn.Linear(d, V)

    def forward(self, x, need_weights=False):
        z = self.emb(x) + self.pos[:, :x.size(1)]
        out, w = self.attn(z, z, z, need_weights=need_weights, average_attn_weights=True)
        z = self.norm(z + out)
        return self.head(z), w

model = TinyEncoder()
opt = torch.optim.Adam(model.parameters(), lr=5e-3)

def make_batch(B=64, L=8):
    x = torch.from_numpy(rng.integers(0, V, size=(B, L))).long()
    y = x.max(dim=1, keepdim=True).values.expand(-1, L)  # 各位置に系列の最大値
    return x, y

for _ in range(800):
    x, y = make_batch()
    logits, _ = model(x)
    loss = F.cross_entropy(logits.reshape(-1, V), y.reshape(-1))
    opt.zero_grad()
    loss.backward()
    opt.step()

model.eval()
x_test, y_test = make_batch(256)
with torch.no_grad():
    logits, _ = model(x_test)
    acc = (logits.argmax(-1) == y_test).float().mean().item()
print(f"テスト精度: {acc:.3f}")

実行結果:

テスト精度: 1.000

学習後のAttentionパターンを1サンプルで可視化してみます。

学習後の注意:全位置が系列最大値の位置を参照する

図から、すべての位置(Query)が、系列内の最大値を持つ位置(赤い破線、この例では位置2)に一様に注目していることが分かります。位置0や位置7といった、最大値の位置から離れたトークンからも直接そこへ注目できるのは、まさに双方向のAttentionだからこそです。RNNのように逐次的に情報を伝える必要がなく、1ステップで任意の位置間の関係を学習できることが、この実験からも確認できます。

最後に、原論文のPost-LN構成と、現代の大規模言語モデルで主流のPre-LN構成の違いにも触れておきます。

Post-LNとPre-LN

これまで示した式 $\text{LayerNorm}(\bm{X} + \text{Sublayer}(\bm{X}))$ は、Layer Normalizationがサブレイヤーの後に来る Post-LN 構成です。これは原論文の設計ですが、実は学習の初期に不安定になりやすいという弱点があります。

Post-LNは正規化がサブレイヤーの後、Pre-LNは前に来る

現代の大規模なTransformer(GPT系列を含む多くのモデル)では、Layer Normalizationをサブレイヤーの前に置く Pre-LN 構成が広く採用されています。$\bm{X} + \text{Sublayer}(\text{LayerNorm}(\bm{X}))$ という形にすることで、残差接続の経路にLayer Normalizationが挟まらず、勾配がより素直に伝わるため、学習率のウォームアップなしでも安定して学習できることが知られています。BERT自体はPost-LNですが、後発のモデルを読むときはどちらの構成かを意識すると理解が深まります。

ここまでの部品がすべて揃ったので、実際にPyTorchでEncoder全体を組み立てます。

PyTorchでの完全実装

import torch
import torch.nn as nn
import torch.nn.functional as F
import math


class ScaledDotProductAttention(nn.Module):
    def __init__(self, dropout=0.1):
        super().__init__()
        self.dropout = nn.Dropout(dropout)

    def forward(self, Q, K, V, mask=None):
        d_k = Q.size(-1)
        scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k)
        if mask is not None:
            scores = scores.masked_fill(mask == 0, float('-inf'))
        attn_weights = F.softmax(scores, dim=-1)
        attn_weights = self.dropout(attn_weights)
        output = torch.matmul(attn_weights, V)
        return output, attn_weights


class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, n_heads, dropout=0.1):
        super().__init__()
        assert d_model % n_heads == 0
        self.d_model = d_model
        self.n_heads = n_heads
        self.d_k = d_model // n_heads

        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)
        self.attention = ScaledDotProductAttention(dropout)

    def forward(self, x, mask=None):
        batch_size, seq_len, _ = x.size()
        Q = self.W_q(x).view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
        K = self.W_k(x).view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
        V = self.W_v(x).view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)

        context, attn_weights = self.attention(Q, K, V, mask)
        context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
        output = self.W_o(context)
        return output, attn_weights


class PositionwiseFeedForward(nn.Module):
    def __init__(self, d_model, d_ff, dropout=0.1):
        super().__init__()
        self.linear1 = nn.Linear(d_model, d_ff)
        self.linear2 = nn.Linear(d_ff, d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        x = F.relu(self.linear1(x))
        x = self.dropout(x)
        x = self.linear2(x)
        return x


class PositionalEncoding(nn.Module):
    def __init__(self, d_model, max_len=5000, dropout=0.1):
        super().__init__()
        self.dropout = nn.Dropout(dropout)
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2, dtype=torch.float) * (-math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0)
        self.register_buffer('pe', pe)

    def forward(self, x):
        x = x + self.pe[:, :x.size(1), :]
        return self.dropout(x)


class TransformerEncoderLayer(nn.Module):
    def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
        super().__init__()
        self.self_attn = MultiHeadAttention(d_model, n_heads, dropout)
        self.ffn = PositionwiseFeedForward(d_model, d_ff, dropout)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.dropout1 = nn.Dropout(dropout)
        self.dropout2 = nn.Dropout(dropout)

    def forward(self, x, mask=None):
        attn_out, attn_weights = self.self_attn(x, mask)
        x = self.norm1(x + self.dropout1(attn_out))
        ffn_out = self.ffn(x)
        x = self.norm2(x + self.dropout2(ffn_out))
        return x, attn_weights


class TransformerEncoder(nn.Module):
    def __init__(self, vocab_size, d_model=512, n_heads=8, d_ff=2048, n_layers=6, max_len=5000, dropout=0.1):
        super().__init__()
        self.d_model = d_model
        self.embedding = nn.Embedding(vocab_size, d_model)
        self.pos_encoding = PositionalEncoding(d_model, max_len, dropout)
        self.encoder_layers = nn.ModuleList([
            TransformerEncoderLayer(d_model, n_heads, d_ff, dropout) for _ in range(n_layers)
        ])
        self.scale = math.sqrt(d_model)

    def forward(self, src, mask=None):
        x = self.embedding(src) * self.scale
        x = self.pos_encoding(x)
        all_attn_weights = []
        for layer in self.encoder_layers:
            x, attn_weights = layer(x, mask)
            all_attn_weights.append(attn_weights)
        return x, all_attn_weights


# 動作確認
vocab_size = 10000
encoder = TransformerEncoder(vocab_size=vocab_size)
src = torch.randint(0, vocab_size, (2, 20))
output, attn_weights_list = encoder(src)
print(f"入力形状: {src.shape}")
print(f"出力形状: {output.shape}")

実行結果:

入力形状: torch.Size([2, 20])
出力形状: torch.Size([2, 20, 512])

実装の動作確認:形状はEncoderを通しても保たれる

入力の系列長20・バッチサイズ2に対して、埋め込み・位置エンコーディング・6層のEncoder層を経ても、系列長20・次元512(d_model)という形状は変わりません。Encoderは「情報を圧縮したり形を変えたりする」のではなく、「同じ形のまま、各トークンの表現をより文脈豊かなものに書き換える」装置だということが、この形状の一貫性からも見て取れます。

まとめ

本記事では、Transformer Encoderの構造と実装について解説しました。

  • Encoderの役割: 入力系列を双方向に処理し、各トークンの文脈を反映した表現を生成する
  • Encoder層の構成: Multi-Head Self-Attention + FFN の2つのサブレイヤーで構成される
  • 双方向性の理由: 入力全体が最初から手元にあるため、因果マスクによる制限が不要
  • 残差接続: 単位行列の項により勾配が直接伝わる。実測でも31層時点で残差ありの勾配ノルムは約5.66を維持し、残差なしはほぼ0まで消失することを確認した
  • FFN: 各位置に独立・同一の重みで適用される非線形変換。Attentionとの役割分担がEncoder層の本質
  • 複数層のスタッキング: 同一構造の層を積み重ねることで、階層的な特徴抽出と表現力の向上を実現する
  • 学習実験: 系列全体の最大値を予測するタスクで、全位置が最大値の位置に直接注目する様子を確認し、双方向性の威力を実証した(テスト精度1.000)
  • Post-LN と Pre-LN: 原論文はPost-LNだが、現代のモデルはPre-LNを採用することが多く、学習の安定性に影響する

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

画像なし
Transformerアーキテクチャの全体像をわかりやすく解説
Encoder-Decoder構造、Decoderの因果マスク、Cross-Attentionなど、Transformer全体の構造を解説します。
画像なし
Self-Attention機構の理論と実装をわかりやすく解説
Query/Key/Valueの意味、スケーリングの数学的根拠、マルチヘッドAttentionの詳細を解説します。