位置エンコーディング(Positional Encoding)は、Transformerアーキテクチャにおいて系列の順序情報を与えるための重要な仕組みです。Self-Attentionは本質的に順序を無視した集合演算であるため、位置エンコーディングがなければ「犬が猫を追う」と「猫が犬を追う」を区別できません。
本記事では、位置エンコーディングがなぜ必要なのか、どのような数式で定義されるのか、そしてなぜ正弦波を使うのかを数学的に深掘りしていきます。特に、相対位置が内積で表現できるという重要な性質を証明し、その直感的な意味を明らかにします。
本記事の内容
- なぜ位置情報が必要か(RNNとの比較)
- 正弦波位置エンコーディングの数式と設計意図
- 相対位置が内積で表現できることの数学的証明
- Pythonでの実装と可視化
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
なぜ位置情報が必要か
RNNは順序を構造的に保持する
RNN(Recurrent Neural Network)では、時刻 $t$ の隠れ状態 $\bm{h}_t$ は前の時刻の隠れ状態 $\bm{h}_{t-1}$ に依存して計算されます。
$$ \bm{h}_t = f(\bm{W}_h \bm{h}_{t-1} + \bm{W}_x \bm{x}_t + \bm{b}) $$
この逐次的な計算構造により、RNNはアーキテクチャ自体に順序情報が埋め込まれています。1番目の単語の情報は $\bm{h}_1$ に反映され、それが $\bm{h}_2$ に伝搬し、という形で、計算の順序が系列の順序と一致しています。
Self-Attentionは順序を無視する
一方、Self-Attentionの計算を見てみましょう。
$$ \text{Attention}(\bm{Q}, \bm{K}, \bm{V}) = \text{softmax}\left(\frac{\bm{Q}\bm{K}^\top}{\sqrt{d_k}}\right)\bm{V} $$
この計算は、入力系列の各要素を同時に処理します。$i$ 番目のトークンと $j$ 番目のトークンの関連度は内積 $\bm{q}_i \cdot \bm{k}_j$ で計算されますが、この計算には「$i$ が $j$ より前にある」という情報は一切含まれていません。
数学的に言えば、Self-Attentionは入力の置換に対して等変(equivariant)です。入力系列の順序を入れ替えると、出力も同じように入れ替わります。これは集合に対する演算としては自然ですが、自然言語のように語順が意味を決定する問題では致命的です。
具体例で理解する
以下の2文を考えてみましょう。
- 「犬が猫を追いかける」
- 「猫が犬を追いかける」
これらは同じ単語集合 {犬, が, 猫, を, 追いかける} を持ちますが、意味は正反対です。位置エンコーディングがなければ、Self-Attentionはこの2文を区別できません。
位置エンコーディングは、各トークンに「自分が系列のどの位置にいるか」という情報を付与することで、この問題を解決します。
正弦波位置エンコーディングの数式
定義
原論文 “Attention Is All You Need” では、以下のsin/cos関数による位置エンコーディングを提案しています。
$$ \begin{equation} \text{PE}(pos, 2i) = \sin\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right) \end{equation} $$
$$ \begin{equation} \text{PE}(pos, 2i+1) = \cos\left(\frac{pos}{10000^{2i/d_{\text{model}}}}\right) \end{equation} $$
ここで、各記号の意味は以下の通りです。
| 記号 | 意味 |
|---|---|
| $pos$ | トークンの位置(0始まり) |
| $i$ | 埋め込みベクトルの次元インデックス($i = 0, 1, \dots, d_{\text{model}}/2 – 1$) |
| $d_{\text{model}}$ | 埋め込みの次元数(原論文では512) |
| $2i$ | 偶数次元(sinを適用) |
| $2i+1$ | 奇数次元(cosを適用) |
位置エンコーディングベクトル $\text{PE}(pos) \in \mathbb{R}^{d_{\text{model}}}$ は、入力埋め込みに加算されます。
$$ \bm{z}_{\text{input}} = \text{Embedding}(\text{token}) + \text{PE}(pos) $$
角周波数の導入
数式をより明確にするため、角周波数 $\omega_i$ を導入しましょう。
$$ \omega_i = \frac{1}{10000^{2i/d_{\text{model}}}} $$
すると、位置エンコーディングは以下のように書けます。
$$ \begin{align} \text{PE}(pos, 2i) &= \sin(\omega_i \cdot pos) \\ \text{PE}(pos, 2i+1) &= \cos(\omega_i \cdot pos) \end{align} $$
これは位置 $pos$ における角度 $\theta = \omega_i \cdot pos$ の正弦と余弦です。次元ペア $(2i, 2i+1)$ は、角周波数 $\omega_i$ で回転する単位円上の点 $(\sin\theta, \cos\theta)$ と解釈できます。
各次元の周波数
角周波数 $\omega_i$ は次元 $i$ が大きくなるにつれて指数関数的に減少します。
$$ \omega_i = 10000^{-2i/d_{\text{model}}} $$
具体的に $d_{\text{model}} = 512$ の場合を計算してみましょう。
| 次元 $i$ | $\omega_i$ | 周期 $2\pi/\omega_i$ |
|---|---|---|
| 0 | 1.0 | $\approx 6.28$ |
| 64 | $\approx 0.1$ | $\approx 62.8$ |
| 128 | $\approx 0.01$ | $\approx 628$ |
| 192 | $\approx 0.001$ | $\approx 6,280$ |
| 255 | $\approx 0.0001$ | $\approx 62,800$ |
この設計により、
- 低次元($i$ が小さい): 高い周波数で、近接位置間の差を細かく表現する
- 高次元($i$ が大きい): 低い周波数で、遠い位置間の差を大きなスケールで表現する
という階層的な位置表現が実現されます。
二進数との類似性
この設計は二進数の各桁の動きに似ています。二進数では、
- 最下位ビット(1の位): 1ごとに0と1を交互に繰り返す
- 2番目のビット(2の位): 2ごとに0と1を交互に繰り返す
- 3番目のビット(4の位): 4ごとに0と1を交互に繰り返す
- …
正弦波位置エンコーディングでは、各次元ペアが異なる周波数で振動することで、同様の「桁上がり」的な構造を連続値で表現しています。
相対位置が内積で表現できることの証明
正弦波位置エンコーディングの最も重要な性質は、位置 $pos$ と位置 $pos + k$ のエンコーディングの内積が、相対距離 $k$ のみの関数になるということです。これにより、モデルは絶対位置だけでなく相対位置の情報も活用できます。
準備:次元ペアの内積
まず、特定の次元ペア $(2i, 2i+1)$ に着目し、位置 $pos$ と位置 $pos + k$ の内積を計算します。
位置 $pos$ のエンコーディング(次元ペア $(2i, 2i+1)$ のみ):
$$ \bm{p}_{pos}^{(i)} = \begin{pmatrix} \sin(\omega_i \cdot pos) \\ \cos(\omega_i \cdot pos) \end{pmatrix} $$
位置 $pos + k$ のエンコーディング:
$$ \bm{p}_{pos+k}^{(i)} = \begin{pmatrix} \sin(\omega_i \cdot (pos + k)) \\ \cos(\omega_i \cdot (pos + k)) \end{pmatrix} $$
内積の計算
内積を計算します。
$$ \begin{align} \bm{p}_{pos}^{(i)} \cdot \bm{p}_{pos+k}^{(i)} &= \sin(\omega_i \cdot pos) \sin(\omega_i \cdot (pos+k)) + \cos(\omega_i \cdot pos) \cos(\omega_i \cdot (pos+k)) \end{align} $$
ここで、三角関数の積和公式(加法定理の系)を使います。
$$ \cos(\alpha – \beta) = \cos\alpha \cos\beta + \sin\alpha \sin\beta $$
$\alpha = \omega_i \cdot pos$, $\beta = \omega_i \cdot (pos + k)$ とおくと、
$$ \begin{align} \bm{p}_{pos}^{(i)} \cdot \bm{p}_{pos+k}^{(i)} &= \cos(\omega_i \cdot pos – \omega_i \cdot (pos+k)) \\ &= \cos(-\omega_i \cdot k) \\ &= \cos(\omega_i \cdot k) \end{align} $$
最後の等式は $\cos(-x) = \cos(x)$ を使いました。
全次元の内積
各次元ペア $(2i, 2i+1)$ の内積が $\cos(\omega_i \cdot k)$ であることがわかりました。全次元にわたる内積は、これらの和になります。
$$ \begin{equation} \text{PE}(pos) \cdot \text{PE}(pos+k) = \sum_{i=0}^{d_{\text{model}}/2 – 1} \cos(\omega_i \cdot k) \end{equation} $$
この結果から、重要な結論が得られます。
位置エンコーディングの内積は、絶対位置 $pos$ には依存せず、相対距離 $k$ のみの関数である。
つまり、$\text{PE}(0) \cdot \text{PE}(5)$ と $\text{PE}(100) \cdot \text{PE}(105)$ は同じ値になります。これにより、モデルは「5つ離れた位置」という相対的な関係を、系列中のどの位置でも同様に学習できます。
相対位置の線形変換表現
さらに興味深い性質として、位置 $pos$ のエンコーディングから位置 $pos + k$ のエンコーディングへの変換は、線形変換(回転行列)で表現できます。
三角関数の加法定理を使うと、
$$ \begin{align} \sin(\omega_i(pos+k)) &= \sin(\omega_i \cdot pos)\cos(\omega_i \cdot k) + \cos(\omega_i \cdot pos)\sin(\omega_i \cdot k) \\ \cos(\omega_i(pos+k)) &= \cos(\omega_i \cdot pos)\cos(\omega_i \cdot k) – \sin(\omega_i \cdot pos)\sin(\omega_i \cdot k) \end{align} $$
これを行列形式で書くと、
$$ \begin{pmatrix} \sin(\omega_i(pos+k)) \\ \cos(\omega_i(pos+k)) \end{pmatrix} = \begin{pmatrix} \cos(\omega_i k) & \sin(\omega_i k) \\ -\sin(\omega_i k) & \cos(\omega_i k) \end{pmatrix} \begin{pmatrix} \sin(\omega_i \cdot pos) \\ \cos(\omega_i \cdot pos) \end{pmatrix} $$
右辺の $2 \times 2$ 行列は回転行列であり、角度 $\omega_i k$ の回転を表します。つまり、次元ペアごとに見ると、相対位置 $k$ だけシフトすることは、そのペアを角度 $\omega_i k$ だけ回転させることに対応します。
この線形性により、
$$ \text{PE}(pos + k) = \bm{M}_k \cdot \text{PE}(pos) $$
という形で、$k$ のみに依存する行列 $\bm{M}_k$ で変換できます。$\bm{M}_k$ は各次元ペアに対応する $2 \times 2$ 回転行列をブロック対角に並べた行列です。
この性質のおかげで、Self-Attentionは相対位置の関係を線形演算を通じて学習しやすくなります。
Pythonでの実装
位置エンコーディングの実装
NumPyを使って位置エンコーディングを実装します。
import numpy as np
import matplotlib.pyplot as plt
def positional_encoding(max_len, d_model):
"""
正弦波位置エンコーディングを計算する
Args:
max_len: 最大系列長
d_model: 埋め込み次元数
Returns:
PE: (max_len, d_model) の位置エンコーディング行列
"""
# 位置インデックス (max_len, 1)
pos = np.arange(max_len)[:, np.newaxis]
# 次元インデックス (d_model/2,)
i = np.arange(0, d_model, 2)
# 角周波数 omega_i = 1 / 10000^(2i/d_model)
omega = 1 / np.power(10000, i / d_model)
# 角度 = pos * omega
angles = pos * omega # (max_len, d_model/2)
# 位置エンコーディング行列
PE = np.zeros((max_len, d_model))
PE[:, 0::2] = np.sin(angles) # 偶数次元: sin
PE[:, 1::2] = np.cos(angles) # 奇数次元: cos
return PE
# 位置エンコーディングを計算
max_len = 100
d_model = 128
PE = positional_encoding(max_len, d_model)
print(f"位置エンコーディングの形状: {PE.shape}")
print(f"位置0のエンコーディング(最初の10次元): {PE[0, :10]}")
print(f"位置1のエンコーディング(最初の10次元): {PE[1, :10]}")
位置エンコーディングの可視化
位置エンコーディング行列をヒートマップとして可視化します。
# 位置エンコーディングのヒートマップ
fig, ax = plt.subplots(figsize=(12, 6))
im = ax.imshow(PE, cmap='RdBu', aspect='auto', vmin=-1, vmax=1)
ax.set_xlabel('Dimension')
ax.set_ylabel('Position')
ax.set_title('Sinusoidal Positional Encoding')
fig.colorbar(im, ax=ax, label='Value')
plt.tight_layout()
plt.savefig('positional_encoding_heatmap.png', dpi=150, bbox_inches='tight')
plt.show()
このヒートマップでは、低次元(左側)ほど高い周波数で振動し、高次元(右側)ほど低い周波数で緩やかに変化する様子が観察できます。
各次元の正弦波の可視化
各次元がどのような周波数の正弦波であるかを確認します。
# いくつかの次元の正弦波を可視化
fig, ax = plt.subplots(figsize=(12, 5))
positions = np.arange(max_len)
dims_to_plot = [0, 10, 20, 50, 100]
for dim in dims_to_plot:
if dim < d_model:
ax.plot(positions, PE[:, dim], label=f'Dim {dim}', linewidth=1.5)
ax.set_xlabel('Position')
ax.set_ylabel('Encoding Value')
ax.set_title('Positional Encoding Values for Different Dimensions')
ax.legend()
ax.grid(True, alpha=0.3)
ax.set_xlim(0, max_len)
plt.tight_layout()
plt.savefig('positional_encoding_waves.png', dpi=150, bbox_inches='tight')
plt.show()
相対位置と内積の関係を検証
相対距離が同じならば内積も同じになることを数値的に確認します。
# 相対位置と内積の関係を検証
def compute_pe_inner_product(PE, pos1, pos2):
"""2つの位置の位置エンコーディングの内積を計算"""
return np.dot(PE[pos1], PE[pos2])
# 同じ相対距離を持つ位置ペアの内積を比較
print("相対距離と内積の関係:")
print("-" * 50)
relative_distances = [1, 5, 10, 20]
for k in relative_distances:
# 異なる絶対位置だが同じ相対距離のペア
pairs = [(0, k), (10, 10+k), (30, 30+k), (50, 50+k)]
inner_products = [compute_pe_inner_product(PE, p1, p2) for p1, p2 in pairs]
print(f"相対距離 k={k}:")
for (p1, p2), ip in zip(pairs, inner_products):
print(f" PE({p1}) . PE({p2}) = {ip:.6f}")
print(f" -> すべて同じ値: {np.allclose(inner_products, inner_products[0])}")
print()
内積と相対距離のグラフ
相対距離が大きくなるにつれて内積がどのように変化するかを可視化します。
# 内積と相対距離の関係をグラフ化
fig, ax = plt.subplots(figsize=(10, 5))
base_pos = 0
relative_distances = np.arange(0, 50)
inner_products = [compute_pe_inner_product(PE, base_pos, base_pos + k)
for k in relative_distances]
ax.plot(relative_distances, inner_products, 'b-', linewidth=2)
ax.axhline(y=0, color='gray', linestyle='--', linewidth=0.5)
ax.set_xlabel('Relative Distance k')
ax.set_ylabel('Inner Product PE(0) . PE(k)')
ax.set_title('Inner Product of Positional Encodings vs Relative Distance')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('pe_inner_product.png', dpi=150, bbox_inches='tight')
plt.show()
print(f"自分自身との内積 PE(0) . PE(0) = {compute_pe_inner_product(PE, 0, 0):.6f}")
print(f"これは d_model/2 = {d_model/2} に等しい(各次元ペアで sin^2 + cos^2 = 1)")
PyTorchでの実装
ニューラルネットワークに組み込むためのPyTorchクラスを実装します。
import torch
import torch.nn as nn
import math
class PositionalEncoding(nn.Module):
"""
正弦波位置エンコーディング(Transformer用)
"""
def __init__(self, d_model, max_len=5000, dropout=0.1):
super().__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)
# 角周波数 omega_i = 1 / 10000^(2i/d_model)
# log空間で計算して数値的安定性を確保
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) # 奇数次元
# (1, max_len, d_model) に変形してバッチ次元を追加
pe = pe.unsqueeze(0)
# 学習対象ではないのでbufferとして登録
self.register_buffer('pe', pe)
def forward(self, x):
"""
Args:
x: (batch_size, seq_len, d_model) - 入力埋め込み
Returns:
(batch_size, seq_len, d_model) - 位置エンコーディングを加算した埋め込み
"""
# 系列長に合わせて位置エンコーディングをスライス
x = x + self.pe[:, :x.size(1), :]
return self.dropout(x)
# 動作確認
d_model = 64
max_len = 100
batch_size = 2
seq_len = 20
# 位置エンコーディングモジュール
pe_module = PositionalEncoding(d_model, max_len, dropout=0.0)
# ダミー入力(通常はEmbedding層の出力)
x = torch.randn(batch_size, seq_len, d_model)
# 位置エンコーディングを加算
x_with_pe = pe_module(x)
print(f"入力形状: {x.shape}")
print(f"出力形状: {x_with_pe.shape}")
print(f"位置エンコーディングが加算されたことを確認:")
print(f" x[0, 0, :5] = {x[0, 0, :5].tolist()}")
print(f" PE[0, :5] = {pe_module.pe[0, 0, :5].tolist()}")
print(f" x_with_pe[0, 0, :5] = {x_with_pe[0, 0, :5].tolist()}")
学習可能な位置エンコーディングとの比較
正弦波位置エンコーディングは学習パラメータを使わない固定的なエンコーディングですが、学習可能な位置エンコーディングという選択肢もあります。
学習可能な位置エンコーディング
class LearnablePositionalEncoding(nn.Module):
"""学習可能な位置エンコーディング"""
def __init__(self, d_model, max_len=5000, dropout=0.1):
super().__init__()
self.dropout = nn.Dropout(p=dropout)
# 各位置に対応するd_model次元のベクトルを学習
self.pe = nn.Embedding(max_len, d_model)
def forward(self, x):
"""
Args:
x: (batch_size, seq_len, d_model)
"""
seq_len = x.size(1)
positions = torch.arange(seq_len, device=x.device)
return self.dropout(x + self.pe(positions))
比較
| 方式 | 利点 | 欠点 |
|---|---|---|
| 正弦波 | 任意の長さに外挿可能、パラメータ不要 | 表現力が固定的 |
| 学習可能 | データに適応した表現を学習可能 | 学習時より長い系列に対応困難 |
BERTは学習可能な位置エンコーディングを使用しています。一方、原論文のTransformerでは両者の性能差はほとんどなかったと報告されています。正弦波エンコーディングは学習時より長い系列にも対応できる外挿性があり、この点が実用上の利点となります。
まとめ
本記事では、Transformerの位置エンコーディングについて解説しました。
-
なぜ位置情報が必要か: Self-Attentionは順序を無視した集合演算であるため、RNNとは異なり位置情報を明示的に与える必要がある
-
正弦波位置エンコーディングの数式: $\text{PE}(pos, 2i) = \sin(\omega_i \cdot pos)$, $\text{PE}(pos, 2i+1) = \cos(\omega_i \cdot pos)$ で定義され、各次元が異なる周波数の波を形成する
-
相対位置の内積表現: 位置エンコーディングの内積 $\text{PE}(pos) \cdot \text{PE}(pos+k) = \sum_i \cos(\omega_i k)$ は相対距離 $k$ のみに依存し、モデルが相対位置を学習しやすくなる
-
線形変換による相対位置シフト: 位置のシフトは回転行列による線形変換で表現でき、Self-Attentionの計算と相性が良い
位置エンコーディングは、Transformerが系列データを効果的に処理するための基盤技術です。この理解を基に、さらに発展的な相対位置エンコーディング(RoPE、ALiBiなど)を学ぶことで、最新のLLMアーキテクチャへの理解が深まるでしょう。
次のステップとして、以下の記事も参考にしてください。