Self-Attention完全解剖(前編)――なぜ「内積」で関係性が分かるのか? Q・K・Vの比喩を捨ててテンソルで理解する注意の物理
1. 素朴な疑問:なぜ「内積」を計算するだけで、言葉の係り受けが分かるのか?
Transformerの論文『Attention Is All You Need』(2017年)を読んだとき、あるいは解説書を開いたとき、誰もが最初に出会う有名な数式があります。
$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$
そして、決まって次のような比喩で説明されます。 「$Q$(Query)はYouTubeの検索窓に入力するキーワードで、$K$(Key)は動画のタイトル一覧で、$V$(Value)は動画の中身そのものです」
……この比喩を読んで、腹の底から納得できた人が世界に何人いるでしょうか? 「なぜ1つの文章を読んでいるだけなのに、自分自身の中にYouTubeの検索窓と動画タイトルがあるのか?」 「なぜ検索窓とタイトルを『行列の掛け算』して、挙句の果てに $\sqrt{d_k}$ で割るのか?」
比喩を重ねれば重ねるほど、プログラマーの頭の中は霧に包まれます。 そして何冊本を読んでも、数ヶ月経つと「結局 $Q, K, V$ が何だったのか」を綺麗さっぱり忘れてしまうのです。
忘れてしまう理由は明白です。「比喩」で覚えようとして、「幾何学(テンソルの幾何)」で理解していないからです。
Self-Attentionの心臓部で行われていることは、図書館の検索でもYouTubeのレコメンドでもありません。 それは、**「高次元空間に浮かぶ単語同士が、互いにベクトルを照射し合い、内積(角度)によって関係性の強さを測り、互いの成分を混ぜ合わせる物理プロセス」**です。
今回は曖昧な比喩をすべて捨て去り、数理とテンソルの幾何学だけでSelf-Attentionを完全に解体します。
2. Q・K・Vの正体:同じベクトルから分身する「3つの影」
まず、最も多くの初学者をつまずかせる最大の誤解を解きましょう。 「$Q$ と $K$ と $V$ という、3つの異なるデータが最初からどこかに用意されている」――これは完全な間違いです。
手元にあるのは、前話(第3話)で手に入れた、入力テキストの埋め込みテンソル $X$ ただ1つしかありません。
入力テンソル X の形状: [Seq_Len, Hidden_Dim]
例: [5単語, 4096次元]
では、$Q, K, V$ はどこから湧いて出るのか? **同じ入力ベクトル $X$ に、3つの異なる重み行列($W_Q, W_K, W_V$)を掛け算して作られた「3つの影(線形変換)」**に過ぎません。
なぜ1つの単語を「3つの役割」に分身させるのか?
人間同士の会話で考えてみましょう。あなたが会議室に入ったとき、あなたの中には同時に3つの顔が存在します。
- Query(探索の顔): 「私はエンジニアだ。インフラに詳しい人を探している(相手に求める条件)」
- Key(自己紹介の顔): 「私はバックエンド開発とPythonが得意だ(自分の名刺・看板)」
- Value(提供価値の顔): 「私と組めば、爆速でAPIサーバーを実装してあげる(実際に相手に渡す中身)」
もし分身させず、元の単語ベクトル $x$ 同士を直接内積したらどうなるでしょうか? 「名刺」と「検索条件」が同じベクトルになってしまうため、「自分自身にしか強いアテンションを向けられない(自分としか仲良くなれない)」という致命的な制約に縛られます。
- **動詞「cross(渡る)」**は、「主語になれる名詞(動物や人)」を探したい($\to$ これが Query)。
- **名詞「animal(動物)」**は、「私は主語になれる名詞ですよ」という名刺を掲げている($\to$ これが Key)。
$X$ に訓練可能な重み行列を掛けて $Q$ と $K$ と $V$ に分離した瞬間、単語は**「相手を探す機能(Q)」「自分をアピールする機能(K)」「情報を手渡す機能(V)」**という3つの自由度を獲得するのです。
3. なぜ「内積($QK^T$)」が関係性の強さになるのか?
次に、数式の分子にある $QK^T$ の幾何学を解き明かします。 なぜ行列の掛け算をするだけで、単語同士の「関連度(注意の強さ)」が計算できるのでしょうか?
2つのベクトル $\vec{q}$ と $\vec{k}$ の内積(Dot Product)の公式を思い出してください。
$$\vec{q} \cdot \vec{k} = \Vert{}\vec{q}\Vert{} \Vert{}\vec{k}\Vert{} \cos(\theta)$$
内積とは、幾何学的には**「一方のベクトルを、もう一方のベクトルへ影として投影したときの長さ」**です。
- 単語Aの Query(求めている相手)と、単語Bの Key(自己紹介)が同じ方向を向いているとき: 内積は巨大なプラスの値になります。「お前を探していたんだ!」と意気投合した状態です。
- 互いに無関係な方向(直交)を向いているとき: 内積はゼロになります。「あなたには興味がありません」という状態です。
- 互いに逆方向を向いているとき: 内積はマイナスの値になります。
行列の掛け算一撃で「全単語の総当たり」を完了する
文章の長さが5単語あるとき、$Q$ と $K$ のテンソルの形状は以下のようになります(バッチ次元を省略)。
Q の Shape: [5, 64] (5単語それぞれのQueryベクトル)
K の Shape: [5, 64] (5単語それぞれのKeyベクトル)
ここで、$K$ を転置(行と列を入れ替え)して $K^T$(形状: [64, 5])にし、行列積 $Q K^T$ を計算します。
$$\underbrace{Q}{[5 \times 64]} \times \underbrace{K^T}{[64 \times 5]} = \underbrace{\text{Attention Scores}}_{[5 \times 5]}$$
【アテンション・スコア行列(5×5)の構造】
Key: The cat sat on the
Query: The [ 2.3, 0.1, -1.2, 0.0, 1.5 ]
Query: cat [ 0.5, 8.9, 7.2, 0.2, 0.1 ] ← 'cat' は自分自身と 'sat(座った)' に強い視線!
Query: sat [ -0.1, 6.5, 5.1, 4.2, 0.3 ]
Query: on [ 0.0, 0.2, 3.8, 1.1, 4.5 ]
Query: the [ 1.2, 0.1, -0.5, 0.2, 2.1 ]
GPUのテンソルコアが最も得意とする単一の行列積(GEMM)を実行するだけで、すべての単語が、他のすべての単語に対して抱いている「関心の強さ(未整形のスコア)」が、わずか1クロックで $[5 \times 5]$ のグリッド状に一網打尽に計算されるのです。
4. なぜ $\sqrt{d_k}$ で割るのか?:割らないとモデルが即死する「勾配消失の物理」
ここで、数式の分母にある謎の項 $\sqrt{d_k}$(キーベクトルの次元数の平方根)の真実に迫りましょう。 なぜ、ただ内積を取るだけでなく、わざわざ割り算(スケーリング)をしなければならないのでしょうか?
結論から言えば、**「これをやらないと、ベクトルの次元数が大きくなったときに Softmax の微分(勾配)がゼロになり、AIの学習が即死するから」**です。
統計学の物理:分散の足し算の罠
$q$ と $k$ の各成分が、平均 0、分散 1 の標準正規分布に従う独立な確率変数だと仮定しましょう。 内積とは、$d_k$ 個の成分同士の掛け算の「足し算」です。
$$\vec{q} \cdot \vec{k} = q_1 k_1 + q_2 k_2 + \dots + q_{d_k} k_{d_k}$$
2つの独立な変数を掛け合わせると、その期待値(平均)は $0$、分散は $1 \times 1 = 1$ になります。 そして、分散 1 の変数を $d_k$ 個足し合わせると、統計学の基本法則により、合計の分散は $d_k$ に跳ね上がります!
$$\text{Var}(\vec{q} \cdot \vec{k}) = d_k \quad \Longrightarrow \quad \text{標準偏差} = \sqrt{d_k}$$
もし次元数 $d_k$ が 64 なら、標準偏差は $\sqrt{64} =$ 8 になります。 もし次元数 $d_k$ が 128 なら、標準偏差は $\sqrt{128} \approx$ 11.3 になります。
標準偏差が 8 や 11 になると、内積の値は平気で $+20$ や $+30$、あるいは $-30$ といった極端な巨大数へと発散します。
Softmaxの「飽和(Saturation)」と死のフラット
この $+30$ という数値を、そのまま Softmax 関数($e^x$ の比率)に放り込んだらどうなるでしょうか?
$$e^{30} \approx 10^{13} \quad (\text{10兆以上})$$
最大値のスコアだけが確率 0.9999999…(ほぼ100%)を独占し、残りの単語の確率はすべて 0.000000… に押し潰されます。
Softmax関数は、入力の差が極端に開いて出力が「1」や「0」に張り付くと、関数の傾き(微分係数)が**ほぼ完全にゼロ(フラット)**になります。 微分がゼロになれば、学習時にバックプロパゲーションで戻ってくる勾配(エラー信号)に「0」が掛け算され、Transformerの重みパラメータは1ミリも更新されなくなります(勾配消失)。
そこで、内積の値をあらかじめ標準偏差である $\sqrt{d_k}$ で割ってやるのです。
$$\text{Var}\left(\frac{\vec{q} \cdot \vec{k}}{\sqrt{d_k}}\right) = \frac{d_k}{(\sqrt{d_k})^2} = \frac{d_k}{d_k} = 1$$
内積の分散を常に「1」へと綺麗に飼いならす。 これにより、どれほど次元数 $d_k$ が大きくなろうとも、Softmaxに入力される値は程よい範囲($-2 \sim +2$ 程度)に収まり、Softmaxはしなやかな勾配を保ち続け、モデルは健全に学習を継続できるのです。
5. Valueベクトルの加重平均:文脈を吸い上げた「新しい自分」の誕生
内積を $\sqrt{d_k}$ で割り、Softmaxを通すことで、ついに**「アテンション重み行列 $A$(Attention Weights)」**が完成します。
$$A = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)$$
この行列 $A$ の各行は、合計がちょうど 「1.0(100%)」 になる確率の重みです。
【Softmax適用後のアテンション重み A (5×5)】
Key: The cat sat on the
Query: cat [ 0.01, 0.65, 0.30, 0.02, 0.02 ] ← 合計 1.0 (100%)
触って試す:アテンションの重み体験
👀 アテンションの重み体験
英文「The animal didn't cross the street because it was tired.」の中で、各単語が他の単語をどれだけ「見ている」か(アテンション重み)を表示します。単語をクリックしてみてください。特に「it」を押すと、代名詞が何を指しているかが数字で見えます(重みは学習済みモデルの振る舞いを模した例示値です。緑のバーは自分自身への視線)。
まずは単語をクリックしてみてください。
そして、Self-Attentionの最後のステップ。 この確率の重み行列 $A$ に、3つ目の影である $V$(Value行列) を掛け算します。
$$\text{Output} = A \times V$$
$$\underbrace{A}{[5 \times 5]} \times \underbrace{V}{[5 \times 64]} = \underbrace{\text{Contextualized Output}}_{[5 \times 64]}$$
辞書の「静止した意味」が、「文脈付きの意味」へ変態する
この最後の掛け算 $AV$ が行っていることの本質は、**「すべての単語のValueベクトルを、視線の強さ(確率)に応じて混ぜ合わせる『加重平均(ブレンド)』」**です。
単語「cat」から出力された新しいベクトルを見てください。 そこには、元々の「猫」という情報が65%含まれているだけでなく、述語である「sat(座った)」の情報が30%も染み込んでいます。
前話(第3話)の辞書引き(Embedding)の時点では、単語は孤立した「静止した記号」でした。 しかし、このSelf-Attentionをくぐり抜けた瞬間、単語「cat」は**「座っている状態の猫」という文脈を物理的に取り込んだ「生きた動的ベクトル」へと進化を遂げた**のです。
6. 完全実装:NumPyで作る「最小のSelf-Attention」
それでは、ここまでの数式とテンソルの旅を、NumPyだけで完全に再現してみましょう。 PyTorchなどのフレームワークは一切使いません。純粋な行列積とSoftmaxだけでSelf-Attentionを実装します。
ファイル名を min_self_attention.py として保存してください。
import numpy as np
def softmax(x: np.ndarray, axis=-1) -> np.ndarray:
"""数値安定性を考慮したSoftmax関数(オーバーフロー防止)"""
# 各行の最大値を引くことで e^x の爆発を防ぐ
e_x = np.exp(x - np.max(x, axis=axis, keepdims=True))
return e_x / np.sum(e_x, axis=axis, keepdims=True)
class MinimalSelfAttention:
def __init__(self, hidden_dim: int, d_k: int):
self.hidden_dim = hidden_dim # 入力ベクトルの次元 (例: 4)
self.d_k = d_k # Q, K, V の射影次元 (例: 3)
np.random.seed(42)
# 3つの重み行列 W_Q, W_K, W_V を初期化 (Hidden_Dim -> d_k)
self.W_q = np.random.randn(hidden_dim, d_k)
self.W_k = np.random.randn(hidden_dim, d_k)
self.W_v = np.random.randn(hidden_dim, d_k)
def forward(self, X: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""
X: 入力埋め込みテンソル [Seq_Len, Hidden_Dim]
戻り値: (出力テンソル [Seq_Len, d_k], アテンション重み [Seq_Len, Seq_Len])
"""
seq_len, _ = X.shape
# 1. 3つの影(Q, K, V)を線形変換で生成
# [Seq_Len, Hidden_Dim] @ [Hidden_Dim, d_k] -> [Seq_Len, d_k]
Q = X @ self.W_q
K = X @ self.W_k
V = X @ self.W_v
# 2. 内積(Q @ K.T)によるアテンション・スコアの算出
# [Seq_Len, d_k] @ [d_k, Seq_Len] -> [Seq_Len, Seq_Len]
scores = Q @ K.T
# 3. √d_k によるスケーリング(勾配消失の防止!)
scaled_scores = scores / np.sqrt(self.d_k)
# 4. Softmax による確率分布化(行ごとの合計を 1.0 にする)
attention_weights = softmax(scaled_scores, axis=-1)
# 5. Valueベクトルの加重平均(文脈情報の融合)
# [Seq_Len, Seq_Len] @ [Seq_Len, d_k] -> [Seq_Len, d_k]
output = attention_weights @ V
return output, attention_weights
# -------------------------------------------------------------
# 実験:3単語の文章でテンソルの挙動を観察する
# 文章: "Time flies fast" (3単語, 各単語4次元)
# -------------------------------------------------------------
if __name__ == "__main__":
words = ["Time", "flies", "fast"]
seq_len = len(words)
hidden_dim = 4
d_k = 3
# 仮想の埋め込みベクトル(第3話で生成されたものと想定)
np.random.seed(100)
X = np.random.randn(seq_len, hidden_dim)
print("=== 入力テンソル X ===")
print(f"Shape: {X.shape} ([Seq_Len={seq_len}, Hidden_Dim={hidden_dim}])")
for w, vec in zip(words, X):
print(f" {w:<6}: {vec}")
# Self-Attention層の実行
attention_layer = MinimalSelfAttention(hidden_dim=hidden_dim, d_k=d_k)
output, weights = attention_layer.forward(X)
print("\n=== アテンション重み行列 A (Softmax後) ===")
print(f"Shape: {weights.shape} ([Seq_Len, Seq_Len])")
print(" " + " ".join([f"{w:>8}" for w in words]))
for i, row in enumerate(weights):
row_str = " ".join([f"{val:8.4f}" for val in row])
print(f"{words[i]:<6} {row_str} (合計: {np.sum(row):.1f})")
print("\n=== 最終出力テンソル Output (AV) ===")
print(f"Shape: {output.shape} ([Seq_Len={seq_len}, d_k={d_k}])")
for w, vec in zip(words, output):
print(f" {w:<6} の新表現: {vec}")
実行結果
=== 入力テンソル X ===
Shape: (3, 4) ([Seq_Len=3, Hidden_Dim=4])
Time : [-1.74976547 0.3426804 1.1530358 -0.25243604]
flies : [ 0.98132079 0.51421884 0.22117967 -1.07004333]
fast : [-0.18949583 0.25500144 -0.45802699 0.43516349]
=== アテンション重み行列 A (Softmax後) ===
Shape: (3, 3) ([Seq_Len, Seq_Len])
Time flies fast
Time 0.0536 0.6273 0.3191 (合計: 1.0)
flies 0.0631 0.6030 0.3339 (合計: 1.0)
fast 0.0883 0.5050 0.4067 (合計: 1.0)
=== 最終出力テンソル Output (AV) ===
Shape: (3, 3) ([Seq_Len=3, d_k=3])
Time の新表現: [ 0.16017355 1.21360528 -0.37042531]
flies の新表現: [ 0.13840637 1.17871342 -0.34380695]
fast の新表現: [ 0.05389647 1.03770163 -0.23594246]
アテンション重み行列の結果を見てください。
1行目の Time は、自分自身(0.0536)よりも、動詞である flies に対して 0.6273(約63%)もの強烈な視線を向けています。
その結果、Time の新しい出力ベクトルには、flies のValue成分が圧倒的な割合でブレンドされ、「ただの抽象的な時間」ではなく「矢のように飛び去る時間」という意味の変容が、数値として刻み込まれたのです。
7. テンソルの視点:Shapeの完全な変形地図
本連載の絶対ルールであるテンソルの形状(Shape)の遷移を、一枚の設計図として総括します。 バッチサイズ $B$、系列長 $S$、モデル隠れ層次元 $D$、アテンション射影次元 $d_k$ とします。
【Self-Attention内部のテンソル変形ロードマップ】
入力: X [B, S, D]
│
├─ X @ W_Q ─────────────> Q: [B, S, d_k]
├─ X @ W_K ─────────────> K: [B, S, d_k]
└─ X @ W_V ─────────────> V: [B, S, d_k]
│
Q @ K^T │
──────────────────────────────────┘
│
未整形スコア: QK^T [B, S, S] ← ★全単語同士の視線グリッドが出現!
│
÷ √d_k (スケーリング) [B, S, S] ← 分散を 1 に矯正
│
softmax(・, axis=-1) │
│
アテンション重み: A [B, S, S] ← 行方向の合計が 1.0 の確率分布
│
A @ V │
──────────────────────────────────┘
│
最終出力: Output [B, S, d_k] ← 文脈を吸収した新しい特徴表現!
[B, S, D]から始まったデータが、- 途中で単語と単語の総当たりマトリクス
[B, S, S]を形成し、 - 最終的に元の単語の並びと同じ
[B, S, d_k]の形へと美しく畳み込まれて着地します。
この [B, S, S] という正方行列こそが、文脈のすべての秘密が詰まった「思考の交差点」なのです。
8. まとめ:Self-Attentionとは「動的な文脈のミキサー」である
Self-Attentionの数学と物理を総括すると、以下の4行に集約されます。
- Q・K・Vは同一ベクトルの3つの顔: 同じ単語ベクトルに異なる重み行列を掛けることで、「探す条件(Q)」「提示する名刺(K)」「渡す情報(V)」の3役に分離する。
- 内積は類似度の測定器: 高次元空間において、ベクトルの内積は方向の一致度(コサイン類似度)を反映し、掛け算一撃で全単語の関係性を走査する。
- $\sqrt{d_k}$ は勾配消失の特効薬: 内積の次元数比例による分散の肥大化を抑え込み、Softmaxの飽和と学習の即死を物理的に阻止する。
- Valueの加重平均: 視線の強さに応じて相手の情報を自分自身に混ぜ合わせることで、静止した単語を生きた文脈ベクトルへとアップデートする。
しかし、このエレガントな数式には、まだ2つの巨大な問題が残されています。
- 問題①: このままだと、モデルは「未来の単語」まで見放題になってしまい、次単語予測のカンニングが成立してしまう(因果性の崩壊)。
- 問題②: 1つの単語ペアに対して「1つの視線」しか持てないため、「文法的な関係」を見ているときは「意味的な関係」を見落としてしまう(視線の単線化)。
この2つの限界を打破し、現代LLMを完成へと導く最後のピース。 次回、**「第5話:Self-Attention完全解剖(後編)――因果マスクとMulti-Headの必然」**へと進みましょう。
📝 3行で頭に入る『試験・実務の要点まとめ』
- Q・K・Vの分離理由: 単一の埋め込みベクトルに $W_Q, W_K, W_V$ を掛けて「探索条件」「自己属性」「提供情報」に役割分担させることで、自分自身以外との柔軟な文脈結合を可能にする。
- 内積類似度と計算量: $Q K^T$ 行列積によって全単語間の類似度($[S, S]$)を一括算出するが、系列長 $S$ の2乗($O(S^2)$)に比例してメモリと計算量が増大する。
- $\sqrt{d_k}$ スケーリングの物理: 次元数 $d_k$ が増えると内積の分散が $d_k$ に比例して巨大化し、Softmaxが飽和して勾配消失を起こすため、標準偏差 $\sqrt{d_k}$ で割って分散を 1 に制御する。