0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Attention だけでは言葉の順序はわからない?Transformer を真に完成させる「Positional Encoding」の秘密と数学的美しさ

0
Last updated at Posted at 2026-03-08

はじめに

昨今、LLMの心臓部であるTransformerやSelf-Attentionの素晴らしい解説記事をよく見かけます。「RNNが抱えていた長距離依存の弱点や、直列処理ゆえの計算の遅さを、Attentionの並列計算がどのように克服したか」という解説は非常に腑に落ちるものです。

しかし、多くの入門記事であえて省略されがちな、絶対に欠かせない重要なピースが存在する。それが Positional Encoding(位置エンコーディング)です。

実は純粋な「Attention」だけでは、RNNが持っていた「時系列データを処理する」という能力を完全に代替することはできません。本記事では、なぜAttentionにPositional Encodingが必要なのか、そしてその数式に隠された驚くべき「数学的美しさ」を紐解いていきたいと思います。

1. Attentionの致命的な弱点:「語順の喪失」

Self-Attentionは、全単語間の関係性を一括で行列計算で求めるため、GPU による高速な並列処理が得意です。しかし、計算式を観察してみると、Attention 自身には「単語の順番」という概念が存在していません。

たとえば以下の2つの文を考えてみましょう:

  • A: 「犬 が 男 を 噛んだ」
  • B: 「男 が 犬 を 噛んだ」

RNN はトークンを逐次的に処理するため、この2つの意味が全く違うことを自然に理解します。しかし純粋な Self-Attention にこれらを入力すると、単なるトークンの集合として扱われてしまい、モデルはどちらも同じ意味として計算してしまいます。

2. Positional Encoding の魔法

Attention 自体に順序を理解する能力がないなら、どうすればいいか?
Transformer の原論文が取った解決策は非常にシンプルかつ大胆でした。入力されるトークンのベクトル(Embedding)そのものに、「私が何番目の単語か」という位置情報ベクトルを足し合わせてしまうのです。

これが「Positional Encoding」です。しかし、単純に「1, 2, 3...」という数値を足すと、文が長い場合に値が大きくなりすぎ学習が不安定になります。そこで採用されたのが、サイン波($\sin$)とコサイン波($\cos$)を使った巧妙な関数でした。

原論文で提示されている Positional Encoding の数式は以下:

$$PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right)$$

$$PE_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right)$$

($pos$ は単語の位置、$i$ はベクトルの次元のインデックス、$d_{\text{model}}$ はベクトルの全次元数)

3. なぜサイン・コサイン波なのか?(数式に隠された2つの美しさ)

なぜわざわざ三角関数を使うのか?そこには2つの美しい理由が隠されています。

美しさ1:次元(Index)による「近距離」と「遠距離」の役割分担

数式の分母にある $10000^{2i/d_{\text{model}}}$ に注目してみましょう。
これは、インデックス $i$ が小さい(ベクトルの前半)ほど値が小さく、インデックス $i$ が大きい(ベクトルの後半)ほど値が大きくなります。

これが波の「周波数(波長)」にどう影響するかというと、以下のようになります。

  • index の数字が小さい部分($i$ が小さい):
    波長が非常に短く、位置($pos$)が1つズレるだけで値が激しく sin・cos の波を上下します。つまり、**「すぐ隣の単語」など、近い距離の細かな位置関係(局所的な情報)**を区別するのに役立ちます。
  • index の数字が大きい部分($i$ が大きい):
    波長が非常に長く、位置($pos$)が 10 や 20 変わっても値はほとんど変化しません。つまり、**「文の先頭と末尾」など、遠い距離の大まかな位置関係(大局的な情報)**を保持する役割を持っています。

時計の針に例えるなら、秒針(小さい index)が細かな時間の違いを刻み、時針(大きい index)が1日の大きな流れを表現するようなものです。1つのベクトルの中に「ミクロな位置情報」と「マクロな位置情報」が同時にパッキングされています。

もっとわかりやすくするために、この index とトークン位置に対応した Positional Encoding の値を可視化してみましょう。

import numpy as np
import matplotlib.pyplot as plt

def get_positional_encoding(seq_len: int, d_model: int) -> np.ndarray:
    """
    Positional Encoding行列を計算する関数
    戻り値のshape: (seq_len, d_model)
    """
    pe = np.zeros((seq_len, d_model))
    position = np.arange(0, seq_len)[:, np.newaxis]
    
    # 10000^(2i/d_model) の計算
    div_term = np.exp(np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model))
    
    pe[:, 0::2] = np.sin(position * div_term)
    pe[:, 1::2] = np.cos(position * div_term)
    
    return pe

# パラメータ設定
seq_len = 100    # トークンの最大位置(横軸)
d_model = 128    # ベクトルの次元数(縦軸)

# PE行列の取得
pe_matrix = get_positional_encoding(seq_len, d_model)

# === 可視化 ===
plt.figure(figsize=(12, 6))

# 縦軸をIndex、横軸をPositionにするため転置 (.T) して描画
# vmin=-1, vmax=1 で色の範囲を固定し、origin='lower'でY軸の0を下にする
plt.imshow(pe_matrix.T, cmap='coolwarm', aspect='auto', origin='lower', vmin=-1, vmax=1)

plt.colorbar(label='PE Value (-1 to 1)')
plt.title('Positional Encoding (Sine and Cosine functions)')
plt.xlabel('Token Position (Sequence Length)')
plt.ylabel('Embedding Dimension Index')

plt.tight_layout()
plt.show()

image.png

この画像からも、

  • index が小さい部分は、トークン位置の変化によって、激しく振動が激しい
    一方、
  • index が大きい部分は、トークン位置の変化による振動がゆるやか
    であることがわかります。

これより、下記のことが言えます。

  • 1つのトークンのベクトル中で、近い距離にあるトークンとの情報は小さい index に集まり、遠い距離にあるトークンとの情報は大きい index に集められる。
    言い換えると、

  • 任意のトークンについて、別の複数のトークンとの距離情報を、1つのベクトル内に同時に「なめらかに」内在させることができる。

美しさ2:加法定理がもたらす「相対位置」の学習のしやすさ

人間が文章を読むとき、「『私』という単語は先頭から3番目にある」という絶対位置よりも、「『私』と『は』は隣り合っている」といった単語同士の「相対的な距離」のほうが重要です。

サインとコサインをペアで組み合わせておく($2i$ と $2i+1$)と、ある位置 $pos$ から距離 $k$ だけ離れた位置 $pos + k$ の Positional Encoding は、$pos$ のベクトルに対する「線形変換(回転行列の掛け算)」で表現できることが数学的に証明されています。

周波数を $\omega_i = \frac{1}{10000^{2i/d_{\text{model}}}}$ とすると、次のように表現できます。

$$\begin{pmatrix} PE_{(pos+k, 2i)} \ PE_{(pos+k, 2i+1)} \end{pmatrix} = \begin{pmatrix} \cos(\omega_i k) & \sin(\omega_i k) \ -\sin(\omega_i k) & \cos(\omega_i k) \end{pmatrix} \begin{pmatrix} PE_{(pos, 2i)} \ PE_{(pos, 2i+1)} \end{pmatrix}$$

この数式が意味するのは、「距離 $k$ だけ離れている」という関係性が、単なるベクトルの回転として表現できるということです。Self-Attention は内部で行列の内積を行いますが、この性質のおかげで、モデルは「特定の距離だけ離れた単語に Attention を向ける」というルールを極めて簡単に学習できます。

4. 実装編1:NumPyで「相対距離の表現」を確認してみる

実際にコードを書いて、距離が離れるにつれてベクトル同士の内積(類似度)がどう変化するかを見てみましょう。

import numpy as np
import matplotlib.pyplot as plt

def get_pe(pos: int, d_model: int = 128) -> np.ndarray:
    """特定の位置(pos)のPositional Encodingベクトルを取得"""
    pe = np.zeros(d_model)
    # 次元に基づく分母の計算
    div_term = np.exp(np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model))
    # 偶数インデックスにSin、奇数インデックスにCos
    pe[0::2] = np.sin(pos * div_term)
    pe[1::2] = np.cos(pos * div_term)
    return pe

# 位置0のベクトルと、位置0〜50のベクトルの内積(類似度)を計算
d_model = 128
pe_0 = get_pe(0, d_model)

similarities = []
for pos in range(50):
    pe_pos = get_pe(pos, d_model)
    # 内積を計算
    dot_product = np.dot(pe_0, pe_pos)
    similarities.append(dot_product)

# 距離による類似度の減衰をプロット
plt.plot(range(50), similarities, marker='o')
plt.title("Dot Product of PE(0) and PE(pos)")
plt.xlabel("Distance (pos)")
plt.ylabel("Dot Product (Similarity)")
plt.grid(True)
plt.show()

image.png

このコードを実行すると、距離(pos)が 0 のとき内積が最大になり、距離が離れるにつれて波打ちながら内積(類似度)が綺麗に減衰していくグラフが描画されます。つまり、Attentionメカニズムは複雑な計算をしなくても、PE同士の内積を取るだけで自然と「どれくらい距離が離れているか」を把握できる仕組みになっています。

5. 実装編2:PyTorch でモデルに組み込む

最後に、実際の PyTorch モデルではこの Positional Encoding をどこで差し込むのかを確認します。答えは「トークンをベクトル化した直後、Attention レイヤーに渡す直前」です。

import torch
import torch.nn as nn
import math

class PositionalEncoding(nn.Module):
    def __init__(self, d_model: int, max_len: int = 5000):
        super().__init__()
        
        # PE テンソルを準備 (max_len, d_model)
        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)
        
        # バッチ処理用に次元追加: (1, max_len, d_model)
        pe = pe.unsqueeze(0)
        
        # 学習する重みではないため、register_bufferで登録
        self.register_buffer('pe', pe)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        x: (batch_size, seq_len, d_model) の入力エンベディング
        """
        seq_len = x.size(1)
        # 入力された単語ベクトルに、対応する長さ分の位置情報を足し合わせる
        x = x + self.pe[:, :seq_len, :]
        return x

# === 動作確認 ===
d_model = 64
seq_len = 10
batch_size = 2

# 1. 単語が Embedding され状態のダミーテンソル
word_embeddings = torch.randn(batch_size, seq_len, d_model)

# 2. Positional Encoding 適用
pos_encoder = PositionalEncoding(d_model=d_model)
embedded_sequence = pos_encoder(word_embeddings)

print(f"入力shape: {word_embeddings.shape}")
print(f"出力shape: {embedded_sequence.shape}")
# この embedded_sequence が、次段の Self-Attention へ渡される

まとめ

  • Attention の限界: Self-Attention は並列計算に優れる反面、それだけでは入力されたトークンの順序を認識できない。
  • Positional Encoding の役割: トークンの Embedding ベクトルに、位置情報を表す sin・cos のベクトルを加算することで順序情報を付与する。
  • 周波数による表現の分担: 低次元(高周波)で近距離の局所的な位置関係を、高次元(低周波)で遠距離の大局的な位置関係を表現している。
  • 相対位置の計算: 三角関数の加法定理により、単語間の相対的な距離を単純な内積(線形変換)としてモデルが学習できる。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?