はじめに
昨今、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()
この画像からも、
- 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()
このコードを実行すると、距離(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 のベクトルを加算することで順序情報を付与する。
- 周波数による表現の分担: 低次元(高周波)で近距離の局所的な位置関係を、高次元(低周波)で遠距離の大局的な位置関係を表現している。
- 相対位置の計算: 三角関数の加法定理により、単語間の相対的な距離を単純な内積(線形変換)としてモデルが学習できる。

