STEP 14:Position EncodingとFeed-Forward

📍 STEP 14: Position EncodingとFeed-Forward

位置情報の埋め込みと位置ごとの全結合層を理解します

📋 このステップで学ぶこと

  • Self-Attentionの位置情報欠如問題
  • Positional Encoding(位置エンコーディング)の必要性
  • 正弦波・余弦波による位置埋め込みの数式と計算
  • Feed-Forward Network(FFN)の役割と構造
  • 残差結合(Residual Connection)の重要性
  • Layer Normalizationの仕組み
  • 完全なEncoder層の実装

練習問題: 4問

💻 実行環境について

このステップのコードはGoogle Colabで実行できます。
PyTorchは最初から入っているので、追加インストールは不要です。

📍 1. 位置情報の必要性

STEP 13で学んだSelf-Attentionは強力ですが、 単語の順序(位置情報)を考慮しないという根本的な問題があります。

1-1. Self-Attentionの致命的な問題

⚠️ 順序を無視してしまう問題

Self-Attentionは「どの単語がどの単語と関連するか」は計算できますが、 「どの順番で並んでいるか」は全く考慮しません。

【実験】同じ単語を並び替えてみる 文1: “The cat sat on the mat” (猫がマットの上に座った) 文2: “The mat sat on the cat” (マットが猫の上に座った – 意味が逆!) 文3: “cat The mat on sat the” (文法的に意味不明) 【Self-Attentionの計算結果】 Self-Attentionは各単語間の関連度を計算: ・”cat” ↔ “sat” の関連度 ・”cat” ↔ “mat” の関連度 ・”on” ↔ “the” の関連度 … 問題: 上記3つの文は、単語の集合が同じなので Self-Attentionの計算結果は同じになってしまう! 文1の “cat” と 文2の “cat” は、 どちらも同じAttention Weightを持つ → 意味が全然違うのに区別できない!

1-2. なぜ位置情報が重要なのか

【例1: 主語と目的語の判断】 “Dog bites man” (犬が人を噛んだ – 日常的な出来事) “Man bites dog” (人が犬を噛んだ – ニュース!) 位置によって: ・最初の名詞 = 主語(動作をする側) ・最後の名詞 = 目的語(動作を受ける側) 位置情報なしでは: “Dog”, “bites”, “man” という単語の集合 → 誰が誰を噛んだか判断できない 【例2: 否定の位置】 “I do not like cats”(私は猫が好きではない) “I like not do cats”(文法的に意味不明) “not”の位置が重要: ・動詞の直前 → 正しい否定 ・それ以外 → 文法エラー 【例3: 形容詞の修飾対象】 “The big red ball” ・”big” と “red” は “ball” を修飾 ・位置で修飾関係が決まる “The red big ball” ・自然な語順ではないが許容される ・位置が意味に影響 【結論】 言語では位置が意味を決定する重要な要素 → Self-Attentionには位置情報を追加する必要がある

1-3. RNN/LSTMとの比較

【なぜRNN/LSTMは位置情報を持つのか】 RNN/LSTMの処理: 時刻1: “The” → h₁ 時刻2: “cat” → h₂ = f(h₁, “cat”) ← h₁の情報を含む 時刻3: “sat” → h₃ = f(h₂, “sat”) ← h₂の情報を含む 時刻4: “on” → h₄ = f(h₃, “on”) ← h₃の情報を含む … ポイント: ・逐次処理により、どの時刻で処理されたか暗黙的に分かる ・h₃ には「3番目に処理された」という情報が含まれる ・位置情報が自然に組み込まれる 【Self-Attentionの処理】 Self-Attentionの処理: ┌─────┬─────┬─────┬─────┐ │ The │ cat │ sat │ on │ ← 全て同時に処理 └─────┴─────┴─────┴─────┘ ポイント: ・全単語を同時に処理(並列化の利点) ・「何番目に現れたか」の情報がない ・明示的に位置情報を追加する必要がある 【解決策: Positional Encoding】 各単語の埋め込みに「位置を表すベクトル」を加算 入力 = 単語埋め込み + 位置エンコーディング “cat”(位置1) の入力 ≠ “cat”(位置5) の入力 → 同じ単語でも位置が違えば異なる表現に!
💡 まとめ: 位置情報が必要な理由
  • Self-Attentionは単語の順序を考慮しない
  • 言語では位置が意味を決定する重要な要素
  • RNN/LSTMは逐次処理により暗黙的に位置情報を持つ
  • Self-Attentionには明示的な位置情報の追加が必要

🌊 2. Positional Encoding(位置エンコーディング)

Positional Encodingは、各単語の埋め込みベクトルに 位置を表すベクトルを加算することで位置情報を追加します。

2-1. 基本的なアイデア

【Positional Encodingの仕組み】 入力文: “I love you” (3単語) ステップ1: 各単語を埋め込みベクトルに変換 “I” → [0.5, -0.3, 0.8, …, 0.2] (512次元) “love” → [0.1, 0.7, -0.2, …, 0.9] (512次元) “you” → [0.3, 0.4, 0.6, …, -0.1] (512次元) ステップ2: 各位置のPositional Encodingを計算 位置0 → PE(0) = [0.0, 1.0, 0.0, …, 0.5] (512次元) 位置1 → PE(1) = [0.84, 0.54, 0.01, …, 0.3] (512次元) 位置2 → PE(2) = [0.91, -0.42, 0.02, …, 0.1] (512次元) ステップ3: 埋め込み + 位置エンコーディング(加算!) “I”(位置0) = [0.5, -0.3, 0.8, …] + [0.0, 1.0, 0.0, …] = [0.5, 0.7, 0.8, …] “love”(位置1) = [0.1, 0.7, -0.2, …] + [0.84, 0.54, 0.01, …] = [0.94, 1.24, -0.19, …] “you”(位置2) = [0.3, 0.4, 0.6, …] + [0.91, -0.42, 0.02, …] = [1.21, -0.02, 0.62, …] 【重要なポイント】 ・結合(concatenate)ではなく加算(add) ・次元数は変わらない(512のまま) ・同じ単語でも位置が違えば異なるベクトルになる

2-2. 正弦波・余弦波による位置エンコーディング

💡 なぜ正弦波と余弦波を使うのか?

Transformerの原論文では、sin(正弦波)cos(余弦波)を 使った位置エンコーディングを提案しています。 単純な位置番号(0, 1, 2…)ではなく、周期関数を使う理由があります。

【位置エンコーディングの公式】 PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model)) 【パラメータの意味】 pos : 単語の位置(0, 1, 2, 3, …) i : 次元のペア番号(0, 1, 2, …, d_model/2 – 1) d_model: モデルの次元数(例: 512) 【ルール】 偶数次元(0, 2, 4, …): sin関数を使用 奇数次元(1, 3, 5, …): cos関数を使用 【周波数の違い】 i=0 の場合: pos / 10000^0 = pos / 1 = pos → 周波数が高い(細かく変化) i=255 (d_model=512の場合): pos / 10000^1 = pos / 10000 → 周波数が低い(ゆっくり変化) 低次元: 高周波(短い周期)→ 近い位置の違いを表現 高次元: 低周波(長い周期)→ 遠い位置の違いを表現

2-3. 計算例(手計算で理解)

【具体的な計算例】 設定: d_model = 4(簡略化のため) 位置: 0, 1, 2 の3つを計算 ■ 位置0 のPositional Encoding ——————————— 次元0(偶数、i=0): PE(0, 0) = sin(0 / 10000^(0/4)) = sin(0 / 10000^0) = sin(0 / 1) = sin(0) = 0.0 次元1(奇数、i=0): PE(0, 1) = cos(0 / 10000^(0/4)) = cos(0 / 1) = cos(0) = 1.0 次元2(偶数、i=1): PE(0, 2) = sin(0 / 10000^(2/4)) = sin(0 / 10000^0.5) = sin(0 / 100) = sin(0) = 0.0 次元3(奇数、i=1): PE(0, 3) = cos(0 / 10000^(2/4)) = cos(0 / 100) = cos(0) = 1.0 PE(0) = [0.0, 1.0, 0.0, 1.0] ■ 位置1 のPositional Encoding ——————————— 次元0: PE(1, 0) = sin(1 / 1) = sin(1) ≈ 0.841 次元1: PE(1, 1) = cos(1 / 1) = cos(1) ≈ 0.540 次元2: PE(1, 2) = sin(1 / 100) = sin(0.01) ≈ 0.010 次元3: PE(1, 3) = cos(1 / 100) = cos(0.01) ≈ 0.9999 PE(1) = [0.841, 0.540, 0.010, 0.9999] ■ 位置2 のPositional Encoding ——————————— 次元0: PE(2, 0) = sin(2 / 1) = sin(2) ≈ 0.909 次元1: PE(2, 1) = cos(2 / 1) = cos(2) ≈ -0.416 次元2: PE(2, 2) = sin(2 / 100) = sin(0.02) ≈ 0.020 次元3: PE(2, 3) = cos(2 / 100) = cos(0.02) ≈ 0.9998 PE(2) = [0.909, -0.416, 0.020, 0.9998] 【観察ポイント】 ・各位置で異なるパターン ・低次元(0, 1): 大きく変化(高周波) ・高次元(2, 3): ゆっくり変化(低周波) ・位置が違えば必ず異なるベクトルになる

2-4. 正弦波・余弦波を使う4つの理由

💡 なぜsin/cosを使うのか?
  1. 相対位置を学習しやすい
    三角関数の加法定理により、PE(pos+k)はPE(pos)の線形結合で表せる
    sin(α+β) = sin(α)cos(β) + cos(α)sin(β)
    → モデルは「何単語離れているか」を学習しやすい
  2. 任意の長さに対応
    数式で決まるため、訓練時より長い系列も処理可能
    学習可能な埋め込みは訓練時の長さに制限される
  3. パラメータ不要
    学習するパラメータがない → メモリ効率が良い
  4. 多様な時間スケール
    低次元: 短周期 → 近い位置の違い
    高次元: 長周期 → 遠い位置の違い

2-5. PyTorchでの実装

ステップ1:基本構造の定義

import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): “””正弦波・余弦波による位置エンコーディング””” def __init__(self, d_model, max_len=5000, dropout=0.1): “”” Args: d_model: モデルの次元数(例: 512) max_len: 最大系列長(例: 5000) dropout: ドロップアウト率 “”” super(PositionalEncoding, self).__init__() self.dropout = nn.Dropout(p=dropout)

ステップ2:位置エンコーディングの計算

# 位置エンコーディングを格納するテンソル # pe: (max_len, d_model) pe = torch.zeros(max_len, d_model) # 位置インデックス: [[0], [1], [2], …, [max_len-1]] # position: (max_len, 1) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) # 分母の計算: 10000^(2i/d_model) # 対数空間で計算(数値安定性のため) # exp(2i * (-log(10000) / d_model)) = 10000^(-2i/d_model) = 1/10000^(2i/d_model) div_term = torch.exp( torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model) ) # div_term: (d_model/2,) = (256,) if d_model=512

ステップ3:sin/cosの適用とバッファ登録

# 偶数次元にsin、奇数次元にcosを適用 # pe[:, 0::2] は 0, 2, 4, … 番目の次元 # pe[:, 1::2] は 1, 3, 5, … 番目の次元 pe[:, 0::2] = torch.sin(position * div_term) # 偶数次元 pe[:, 1::2] = torch.cos(position * div_term) # 奇数次元 # バッチ次元を追加: (1, max_len, d_model) pe = pe.unsqueeze(0) # register_buffer: 学習対象外だがモデルと一緒に保存される # 学習時に更新されない定数として扱う self.register_buffer(‘pe’, pe)

ステップ4:forward関数

def forward(self, x): “”” Args: x: 入力テンソル (batch, seq_len, d_model) Returns: 位置エンコーディングを加算した結果 (batch, seq_len, d_model) “”” # x.size(1) は系列長 # self.pe[:, :x.size(1), :] で必要な長さ分だけ取得 x = x + self.pe[:, :x.size(1), :] return self.dropout(x)

完成コード(コピー用)

※コードが長いため、横スクロールできます。

import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): “””正弦波・余弦波による位置エンコーディング””” def __init__(self, d_model, max_len=5000, dropout=0.1): super(PositionalEncoding, self).__init__() self.dropout = nn.Dropout(p=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).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)

動作確認

# 使用例 d_model = 512 max_len = 5000 pos_encoding = PositionalEncoding(d_model, max_len) # 入力データを作成 batch_size = 2 seq_len = 10 x = torch.randn(batch_size, seq_len, d_model) # 位置エンコーディングを加算 output = pos_encoding(x) print(f”入力形状: {x.shape}”) print(f”出力形状: {output.shape}”) print(f”PE形状: {pos_encoding.pe.shape}”) print(f”\n位置0のPE(最初の10次元):”) print(pos_encoding.pe[0, 0, :10].numpy().round(3)) print(f”\n位置1のPE(最初の10次元):”) print(pos_encoding.pe[0, 1, :10].numpy().round(3))
実行結果: 入力形状: torch.Size([2, 10, 512]) 出力形状: torch.Size([2, 10, 512]) PE形状: torch.Size([1, 5000, 512]) 位置0のPE(最初の10次元): [ 0. 1. 0. 1. 0. 1. 0. 1. 0. 1. ] 位置1のPE(最初の10次元): [ 0.841 0.54 0.811 0.585 0.778 0.628 0.745 0.667 0.71 0.704]

🔲 3. Feed-Forward Network(FFN)

Transformerの各層では、Self-Attentionの後に Feed-Forward Network(FFN)を適用します。 これは2層の全結合ネットワークです。

3-1. FFNとは何か

【Feed-Forward Networkの構造】 FFN(x) = ReLU(x × W₁ + b₁) × W₂ + b₂ 【パラメータ】 d_model = 512 (入力・出力の次元) d_ff = 2048 (中間層の次元、通常 d_model × 4) W₁: (512, 2048) – 第1層の重み b₁: (2048,) – 第1層のバイアス W₂: (2048, 512) – 第2層の重み b₂: (512,) – 第2層のバイアス 【処理の流れ】 入力: (batch, seq_len, 512) ↓ 第1層(線形変換): x × W₁ + b₁ ↓ 中間: (batch, seq_len, 2048) ← 次元が4倍に拡大! ↓ ReLU(活性化関数): max(0, x) ↓ 中間: (batch, seq_len, 2048) ← 負の値が0にクリップ ↓ 第2層(線形変換): x × W₂ + b₂ ↓ 出力: (batch, seq_len, 512) ← 元の次元に戻る 【なぜ次元を拡大するのか?】 ・中間層で2048次元に拡大 ・より豊かな特徴空間で処理 ・ボトルネックを避ける ・最後に512次元に戻す

3-2. “Position-wise”の意味

💡 Position-wise(位置ごと)の意味

FFNは各位置(単語)に対して独立に同じネットワークを適用します。 単語間の情報交換はありません。

【Position-wiseの動作】 入力: “The cat sat” (3単語) Self-Attentionの後: [h_The, h_cat, h_sat] (各512次元) FFNの適用: h_The → FFN → 出力_The (独立に処理) h_cat → FFN → 出力_cat (同じFFNを使用) h_sat → FFN → 出力_sat (同じFFNを使用) 【ポイント】 ・3つの単語に同じFFNを適用(重みを共有) ・各単語は独立に処理される ・単語間の相互作用はない(Self-Attentionで済ませている) 【Self-Attentionとの役割分担】 Self-Attention: ・「どの単語がどの単語と関連するか」を計算 ・全単語を見る(グローバル) ・位置間の相互作用 FFN: ・「各単語の表現をどう変換するか」を計算 ・1単語だけを見る(ローカル) ・位置ごとの独立した処理 この2つが交互に繰り返されることで、 文脈を理解しつつ、各単語の表現を豊かにする

3-3. なぜFFNが必要なのか

【FFNが必要な理由】 ■ 問題: Self-Attentionはほぼ線形 Self-Attentionの計算: 1. QK^T(行列積)- 線形 2. softmax – 非線形だが、値を変換するだけ 3. × V(行列積)- 線形 → 全体として「ほぼ線形的」な操作 → 複雑なパターンを学習できない ■ 解決: FFNで非線形性を追加 FFNの計算: 1. x × W₁ + b₁ – 線形 2. ReLU – 非線形!(負の値を0に) 3. x × W₂ + b₂ – 線形 → ReLUにより非線形性が追加される → 複雑な関数を近似できるようになる ■ Universal Approximation Theorem 「十分に広い中間層を持つ2層のニューラルネットワークは、 任意の連続関数を近似できる」 d_ff = 2048(4倍に拡大) → 十分な表現力を確保 → 複雑なパターンを学習可能 ■ 実験結果 FFNなし: BLEU 24.5 FFNあり: BLEU 28.4 → 約4ポイントの向上! → FFNは不可欠な要素

3-4. PyTorchでの実装

class PositionwiseFeedForward(nn.Module): “””Position-wise Feed-Forward Network””” def __init__(self, d_model, d_ff, dropout=0.1): “”” Args: d_model: 入力・出力の次元(例: 512) d_ff: 中間層の次元(例: 2048) dropout: ドロップアウト率 “”” super(PositionwiseFeedForward, self).__init__() # 第1層: d_model → d_ff self.w_1 = nn.Linear(d_model, d_ff) # 第2層: d_ff → d_model self.w_2 = nn.Linear(d_ff, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x): “”” Args: x: 入力 (batch, seq_len, d_model) Returns: 出力 (batch, seq_len, d_model) “”” # 第1層 + ReLU x = self.w_1(x) # (batch, seq_len, d_ff) x = torch.relu(x) # ReLUで非線形変換 x = self.dropout(x) # 第2層 x = self.w_2(x) # (batch, seq_len, d_model) x = self.dropout(x) return x

動作確認

# 使用例 d_model = 512 d_ff = 2048 ffn = PositionwiseFeedForward(d_model, d_ff) # 入力 batch_size = 2 seq_len = 5 x = torch.randn(batch_size, seq_len, d_model) # FFN適用 output = ffn(x) print(f”入力形状: {x.shape}”) print(f”出力形状: {output.shape}”) # パラメータ数 params = sum(p.numel() for p in ffn.parameters()) print(f”\nパラメータ数: {params:,}”) print(f”内訳:”) print(f” W1: {d_model} × {d_ff} = {d_model * d_ff:,}”) print(f” b1: {d_ff:,}”) print(f” W2: {d_ff} × {d_model} = {d_ff * d_model:,}”) print(f” b2: {d_model}”)
実行結果: 入力形状: torch.Size([2, 5, 512]) 出力形状: torch.Size([2, 5, 512]) パラメータ数: 2,099,712 内訳: W1: 512 × 2048 = 1,048,576 b1: 2,048 W2: 2048 × 512 = 1,048,576 b2: 512
📊 パラメータ数の内訳

FFNはTransformer層のパラメータの約2/3を占めます。

  • Multi-Head Attention: 約100万パラメータ
  • FFN: 約200万パラメータ
  • 合計: 約300万パラメータ/層

🔗 4. 残差結合とLayer Normalization

Transformerでは、各サブ層の後に 残差結合(Residual Connection)Layer Normalizationを適用します。

4-1. 残差結合(Residual Connection)とは

【残差結合の仕組み】 通常のネットワーク: 入力 x → サブ層 → 出力 = Sublayer(x) 残差結合あり: 入力 x → サブ層 → 加算 → 出力 = x + Sublayer(x) └──────────────┘ スキップ接続 【図解】 x ────────────────┐ │ │ ↓ │ ┌─────────┐ │ │ Sublayer │ │ スキップ(バイパス) └─────────┘ │ │ │ ↓ │ Sublayer(x) │ │ │ ↓ ↓ ╋ ←───────────────╋ 加算 │ ↓ output = x + Sublayer(x) 【具体例】 入力: x = [1.0, 2.0, 3.0, 4.0] サブ層の出力: Sublayer(x) = [0.1, -0.2, 0.3, -0.1] 残差結合後: output = x + Sublayer(x) = [1.0, 2.0, 3.0, 4.0] + [0.1, -0.2, 0.3, -0.1] = [1.1, 1.8, 3.3, 3.9] → 元の入力 x の情報が保存されている!

4-2. なぜ残差結合が必要なのか

⚠️ 深いネットワークの問題: 勾配消失

層が深くなると、誤差逆伝播時に勾配が徐々に小さくなり、 学習が進まなくなる「勾配消失問題」が起きます。

【勾配消失問題】 6層のネットワーク(残差結合なし): y = f₆(f₅(f₄(f₃(f₂(f₁(x)))))) 誤差逆伝播での勾配: ∂L/∂x = ∂L/∂f₆ × ∂f₆/∂f₅ × ∂f₅/∂f₄ × ∂f₄/∂f₃ × ∂f₃/∂f₂ × ∂f₂/∂f₁ × ∂f₁/∂x 問題: ・6つの微分の「積」 ・各微分が1未満だと、積がどんどん小さくなる ・例: 0.5 × 0.5 × 0.5 × 0.5 × 0.5 × 0.5 = 0.016 ・勾配が0に近づく → 学習が進まない 【残差結合による解決】 残差結合ありの場合: y = x + f₆(x + f₅(x + f₄(x + f₃(x + f₂(x + f₁(x)))))) 勾配の計算: ∂y/∂x = 1 + ∂f₆/∂x + ∂f₅/∂x + … + ∂f₁/∂x ポイント: ・「1」の項が常に存在! ・勾配が0になることはない ・直接的な勾配の流れが確保される ・深い層でも学習可能 【もう一つの利点: 恒等写像の学習】 最悪の場合: Sublayer(x) = 0 を学習すれば output = x + 0 = x → 入力をそのまま出力(恒等写像) → 学習が進まなくても悪化しない → 安全に層を深くできる

4-3. Layer Normalizationとは

【Layer Normalizationの計算】 LayerNorm(x) = γ × ((x – μ) / σ) + β μ: 平均(各サンプル、各位置で計算) σ: 標準偏差 γ: スケールパラメータ(学習可能) β: シフトパラメータ(学習可能) 【計算例】 入力: x = [2, 4, 6, 8] (1つの位置、4次元) ステップ1: 平均を計算 μ = (2 + 4 + 6 + 8) / 4 = 5 ステップ2: 標準偏差を計算 分散 = ((2-5)² + (4-5)² + (6-5)² + (8-5)²) / 4 = (9 + 1 + 1 + 9) / 4 = 5 σ = √5 ≈ 2.236 ステップ3: 正規化 (x – μ) / σ = [(2-5), (4-5), (6-5), (8-5)] / 2.236 = [-3, -1, 1, 3] / 2.236 = [-1.34, -0.45, 0.45, 1.34] ステップ4: スケール・シフト(γ=1, β=0の場合) 出力 = [-1.34, -0.45, 0.45, 1.34] 【結果】 ・平均が0に正規化 ・標準偏差が1に正規化 ・γ、βで必要に応じて調整可能

4-4. なぜLayer Normalizationを使うのか

【Batch Normalization vs Layer Normalization】 ■ Batch Normalization(CNNで一般的) ・バッチ方向に正規化 ・バッチサイズが小さいと不安定 ・系列長が可変だと使いにくい ・推論時に学習時の統計を使用(複雑) ■ Layer Normalization(NLPで標準) ・特徴量方向に正規化 ・バッチサイズに依存しない ・系列長が可変でもOK ・推論時も同じ計算(シンプル) 【NLPでLayer Normを使う理由】 理由1: 系列長が可変 ・「Hello」(1単語) と「Hello, how are you」(4単語) ・系列長が異なるデータを同じバッチで処理 ・Batch Normだと系列長の統計がずれる 理由2: バッチサイズの影響 ・GPUメモリの制約で小さいバッチも使う ・Batch Normは小さいバッチで不安定 ・Layer Normはバッチサイズ1でもOK 理由3: シンプルさ ・推論時も学習時と同じ計算 ・Moving Averageを保存する必要なし

4-5. 残差結合 + Layer Normの実装

class SublayerConnection(nn.Module): “””残差結合 + Layer Normalization””” def __init__(self, d_model, dropout=0.1): “”” Args: d_model: モデルの次元数 dropout: ドロップアウト率 “”” super(SublayerConnection, self).__init__() # Layer Normalization self.norm = nn.LayerNorm(d_model) # Dropout self.dropout = nn.Dropout(dropout) def forward(self, x, sublayer): “”” Args: x: 入力 (batch, seq_len, d_model) sublayer: サブ層(関数として渡す) Returns: 出力 (batch, seq_len, d_model) “”” # Pre-LN方式: Layer Normを先に適用 # output = x + Dropout(Sublayer(LayerNorm(x))) return x + self.dropout(sublayer(self.norm(x)))

動作確認

# 使用例 d_model = 512 sublayer_conn = SublayerConnection(d_model) # ダミーのサブ層(線形変換) sublayer = nn.Linear(d_model, d_model) # 入力 x = torch.randn(2, 5, d_model) # 残差結合 + Layer Norm output = sublayer_conn(x, sublayer) print(f”入力形状: {x.shape}”) print(f”出力形状: {output.shape}”) # 残差結合の効果を確認 print(f”\n入力の最初の5要素: {x[0, 0, :5].detach().numpy().round(3)}”) print(f”出力の最初の5要素: {output[0, 0, :5].detach().numpy().round(3)}”) print(f”→ 元の入力の情報が保存されている”)
実行結果: 入力形状: torch.Size([2, 5, 512]) 出力形状: torch.Size([2, 5, 512]) 入力の最初の5要素: [ 0.423 -1.203 0.872 0.156 -0.634] 出力の最初の5要素: [ 0.512 -1.089 0.923 0.198 -0.589] → 元の入力の情報が保存されている

🏗️ 5. Encoder層の完全実装

ここまで学んだ要素を全て組み合わせて、完全なEncoder層を実装します。

5-1. Encoder層の構造

【1つのEncoder層の構造】 入力: (batch, seq_len, d_model) │ ▼ ┌─────────────────────────────────────┐ │ 1. Multi-Head Self-Attention │ │ ・各単語が他の全単語を見る │ │ ・文脈を理解 │ └────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ 2. 残差結合 + Layer Norm │ │ ・勾配消失を防ぐ │ │ ・学習を安定化 │ └────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ 3. Feed-Forward Network │ │ ・各位置の表現を変換 │ │ ・非線形性を追加 │ └────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ 4. 残差結合 + Layer Norm │ │ ・再び安定化 │ └────────────────┬────────────────────┘ │ ▼ 出力: (batch, seq_len, d_model) これを6回繰り返す(6層)

5-2. Encoder層の実装

まず、STEP 13で作成したMulti-Head Attentionを使用します。

import torch import torch.nn as nn import torch.nn.functional as F import math import copy # Multi-Head Attention(STEP 13から) class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads, dropout=0.1): super(MultiHeadAttention, self).__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.dropout = nn.Dropout(dropout) def forward(self, Q, K, V, mask=None): batch_size = Q.size(0) Q = self.W_Q(Q).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) K = self.W_K(K).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) V = self.W_V(V).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) attention_weights = F.softmax(scores, dim=-1) attention_weights = self.dropout(attention_weights) output = torch.matmul(attention_weights, V) output = output.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) output = self.W_O(output) return output, attention_weights

Encoder層の実装

class EncoderLayer(nn.Module): “””Transformerの1つのEncoder層””” def __init__(self, d_model, n_heads, d_ff, dropout=0.1): “”” Args: d_model: モデルの次元数(例: 512) n_heads: ヘッド数(例: 8) d_ff: FFNの中間層次元(例: 2048) dropout: ドロップアウト率 “”” super(EncoderLayer, self).__init__() # Multi-Head Self-Attention self.self_attn = MultiHeadAttention(d_model, n_heads, dropout) # Feed-Forward Network self.feed_forward = PositionwiseFeedForward(d_model, d_ff, dropout) # Layer Normalization(2つ) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) # Dropout self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): “”” Args: x: 入力 (batch, seq_len, d_model) mask: マスク(オプション) Returns: 出力 (batch, seq_len, d_model) “”” # 1. Multi-Head Self-Attention + 残差結合 + Layer Norm attn_output, _ = self.self_attn(x, x, x, mask) x = self.norm1(x + self.dropout(attn_output)) # 2. Feed-Forward + 残差結合 + Layer Norm ff_output = self.feed_forward(x) x = self.norm2(x + self.dropout(ff_output)) return x

5-3. 複数のEncoder層を積む

class Encoder(nn.Module): “””N層のEncoder””” def __init__(self, d_model, n_heads, d_ff, n_layers, dropout=0.1): “”” Args: d_model: モデルの次元数 n_heads: ヘッド数 d_ff: FFNの中間層次元 n_layers: 層の数(例: 6) dropout: ドロップアウト率 “”” super(Encoder, self).__init__() # N層のEncoder層 self.layers = nn.ModuleList([ EncoderLayer(d_model, n_heads, d_ff, dropout) for _ in range(n_layers) ]) # 最後のLayer Norm self.norm = nn.LayerNorm(d_model) def forward(self, x, mask=None): “”” Args: x: 入力 (batch, seq_len, d_model) mask: マスク Returns: 出力 (batch, seq_len, d_model) “”” # 各層を順番に通す for layer in self.layers: x = layer(x, mask) # 最後にLayer Norm return self.norm(x)

5-4. 動作確認

# パラメータ設定 d_model = 512 # モデル次元 n_heads = 8 # ヘッド数 d_ff = 2048 # FFN中間層 n_layers = 6 # 層数 dropout = 0.1 # モデル作成 encoder = Encoder(d_model, n_heads, d_ff, n_layers, dropout) pos_encoding = PositionalEncoding(d_model) # 入力データ batch_size = 2 seq_len = 10 x = torch.randn(batch_size, seq_len, d_model) # 位置エンコーディングを追加 x = pos_encoding(x) # Encoderを通す output = encoder(x) print(f”入力形状: {x.shape}”) print(f”出力形状: {output.shape}”) # パラメータ数 total_params = sum(p.numel() for p in encoder.parameters()) print(f”\n総パラメータ数: {total_params:,}”) print(f”層あたり: {total_params // n_layers:,}”)
実行結果: 入力形状: torch.Size([2, 10, 512]) 出力形状: torch.Size([2, 10, 512]) 総パラメータ数: 18,897,408 層あたり: 3,149,568
💡 Encoder層のまとめ
  • Positional Encoding: 位置情報を加算
  • Multi-Head Self-Attention: 文脈を理解
  • 残差結合 + Layer Norm: 学習を安定化
  • Feed-Forward Network: 非線形変換で表現を豊かに
  • これを6層繰り返す: 約1900万パラメータ

📝 練習問題

このステップで学んだ内容を確認しましょう。

問題1:位置情報の必要性

Self-Attentionに位置情報が必要な理由は何ですか?

  1. 計算を高速化するため
  2. Self-Attentionは単語の順序を考慮しないため
  3. パラメータ数を削減するため
  4. メモリ使用量を削減するため
正解:b

位置情報が必要なのはSelf-Attentionが単語の順序を考慮しないからです。

問題点:

  • “The cat sat on the mat” と “The mat sat on the cat”
  • 単語の集合が同じなので、Self-Attentionでは同じ計算結果
  • しかし意味は全く違う!

解決策:Positional Encodingで位置情報を追加

問題2:正弦波・余弦波の利点

正弦波・余弦波による位置エンコーディングの利点として正しくないものはどれですか?

  1. 任意の長さの系列に対応できる
  2. 学習不要(パラメータなし)
  3. 相対位置を学習しやすい
  4. 学習可能な埋め込みより常に高精度
正解:d

正弦波が常に高精度とは限りません。実験では両者ほぼ同等の性能です。

正弦波の利点(正しいもの):

  • a: 数式で決まるので任意の長さに対応可能 ✅
  • b: パラメータなしでメモリ効率が良い ✅
  • c: 三角関数の性質で相対位置を学習しやすい ✅

実際の使用:BERT、GPTは学習可能な埋め込みを使用していますが、性能はほぼ同等です。

問題3:FFNの役割

Position-wise Feed-Forward Networkの役割として正しいものはどれですか?

  1. 単語間の関係を学習する
  2. 各位置の表現を非線形変換で豊かにする
  3. 位置情報を埋め込む
  4. Attention Weightを計算する
正解:b

FFNは各位置の表現を非線形変換で豊かにする役割です。

FFNの特徴:

  • Position-wise: 各位置に独立に同じネットワークを適用
  • ReLUで非線形性を追加
  • 512 → 2048 → 512(次元を拡大・縮小)

役割分担:

  • Self-Attention: 位置間の関係を学習(a、dの役割)
  • FFN: 各位置の表現を変換(b)
  • Positional Encoding: 位置情報を埋め込む(c)

問題4:残差結合

残差結合(Residual Connection)の主な利点は何ですか?

  1. パラメータ数を削減する
  2. 計算を高速化する
  3. 勾配消失を防ぎ、深い層でも学習可能にする
  4. メモリ使用量を削減する
正解:c

残差結合の主な利点は勾配消失を防ぎ、深い層でも学習可能にすることです。

残差結合の仕組み:

  • output = x + Sublayer(x)
  • 元の入力xを加算(スキップ接続)

利点:

  • 勾配が直接流れる経路ができる
  • ∂output/∂x = 1 + ∂Sublayer/∂x
  • 「1」の項が常にある → 勾配消失しない
  • 6層以上の深いネットワークでも学習可能

📝 STEP 14 のまとめ

✅ このステップで学んだこと
  • 位置情報の必要性:Self-Attentionは順序を考慮しない
  • Positional Encoding:正弦波・余弦波で位置を埋め込む
  • Feed-Forward Network:位置ごとの非線形変換
  • 残差結合:勾配消失を防ぐスキップ接続
  • Layer Normalization:学習の安定化
  • 完全なEncoder層:全ての要素を統合
💡 Encoder層の全体像

入力 → Positional Encoding
→ Multi-Head Self-Attention → 残差 + Layer Norm
→ Feed-Forward Network → 残差 + Layer Norm
出力

これを6層繰り返す → 約1900万パラメータ

🎯 次のステップの準備

次のSTEP 15では、Transformer全体の実装を完成させます!

学ぶ内容:

  • Decoder層の実装
  • Masked Self-Attention(未来を見ない)
  • Cross-Attention(EncoderとDecoderの接続)
  • 完全なTransformerモデル
📝

学習メモ

自然言語処理(NLP) - Step 14

📋 過去のメモ一覧
#artnasekai #学習メモ
LINE