Self-Attention完全解剖(前編)――なぜ「内積」で関係性が分かるのか? Q・K・Vの比喩を捨ててテンソルで理解する注意の物理

2026-11-05 / シリーズ「LLM・AIの内臓」第4話 / LLM, Self-Attention, Transformer, 線形代数, 勾配消失, Python, 深層学習

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$ が何だったのか」を綺麗さっぱり忘れてしまうのです。

混乱を招く抽象的な比喩 Q:検索クエリ K:データベースのキー V:格納された値 謎の照合 謎の引き出し 物理と線形代数の冷徹な真実 入力単語ベクトル x (4096次元の空間座標) Q:自分が相手に求める条件ベクトル K:自分が周囲にアピールする属性ベクトル V:合意したときに相手に渡す中身ベクトル 重み W_Q を掛ける 重み W_K を掛ける 重み W_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つの影(線形変換)」**に過ぎません。

単語ベクトル x(例:'bank' の4096次元) 線形変換 重み行列 W_Q [4096, 64] 重み行列 W_K [4096, 64] 重み行列 W_V [4096, 64] Query ベクトル q(64次元) 『私はどんな単語と 結合したいか?』 Key ベクトル k(64次元) 『私はどんな特徴を 持つ単語か?』 Value ベクトル v(64次元) 『私と結合した相手に 与える情報は何か?』

なぜ1つの単語を「3つの役割」に分身させるのか?

人間同士の会話で考えてみましょう。あなたが会議室に入ったとき、あなたの中には同時に3つの顔が存在します。

  1. Query(探索の顔): 「私はエンジニアだ。インフラに詳しい人を探している(相手に求める条件)」
  2. Key(自己紹介の顔): 「私はバックエンド開発とPythonが得意だ(自分の名刺・看板)」
  3. 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:同じ方向(意気投合!) B:直交している(無関係) C:逆方向(相容れない) Query Key θ ≈ 0° Query Key θ = 90° Query Key θ = 180° cos(θ) ≈ 1.0 ★内積は大きな正の値 cos(θ) = 0.0 ★内積はゼロ cos(θ) = −1.0 ★内積は負の値

内積とは、幾何学的には**「一方のベクトルを、もう一方のベクトルへ影として投影したときの長さ」**です。

  • 単語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]$ のグリッド状に一網打尽に計算されるのです。

NVIDIA A100に使われるGA100 GPUのダイ写真。演算ブロックが格子状に繰り返し並んでいる
QK^Tのような行列積(GEMM)を高速に実行するテンソルコアを内蔵したGPU「NVIDIA A100」のダイ(GA100)。格子状に繰り返し並ぶブロックの一つひとつが、掛け算と足し算を並列に繰り返す演算ユニットの集まりである。写真:Kurnal Insights(作者不詳)(CC BY 3.0/Wikimedia Commons)

4. なぜ $\sqrt{d_k}$ で割るのか?:割らないとモデルが即死する「勾配消失の物理」

ここで、数式の分母にある謎の項 $\sqrt{d_k}$(キーベクトルの次元数の平方根)の真実に迫りましょう。 なぜ、ただ内積を取るだけでなく、わざわざ割り算(スケーリング)をしなければならないのでしょうか?

結論から言えば、**「これをやらないと、ベクトルの次元数が大きくなったときに Softmax の微分(勾配)がゼロになり、AIの学習が即死するから」**です。

スケーリング(√d_k)をサボった場合の破滅シナリオ 次元数 d_k が増大(例:64次元、128次元) d_k 個の掛け算を足し合わせるため、内積値の分散が d_k に比例して肥大化! 内積スコアが極端な値になる(例:+50 と −30) Softmax を適用すると… 最大値が 0.9999999、他が 0.0000001 に張り付く(飽和状態) Softmaxの傾きが完全にフラット(0)になり、誤差逆伝播の学習が永久停止する

統計学の物理:分散の足し算の罠

$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]}$$

アテンション重み A(行:'cat' の視線) cat:65% / sat:30% / 他:5% V('cat') のベクトル × 0.65 V('sat') のベクトル × 0.30 V('他') のベクトル × 0.05 新しい 'cat' の出力ベクトル! (自分自身の情報65% + 『座った』という文脈情報30%が融合)

辞書の「静止した意味」が、「文脈付きの意味」へ変態する

この最後の掛け算 $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行に集約されます。

  1. Q・K・Vは同一ベクトルの3つの顔: 同じ単語ベクトルに異なる重み行列を掛けることで、「探す条件(Q)」「提示する名刺(K)」「渡す情報(V)」の3役に分離する。
  2. 内積は類似度の測定器: 高次元空間において、ベクトルの内積は方向の一致度(コサイン類似度)を反映し、掛け算一撃で全単語の関係性を走査する。
  3. $\sqrt{d_k}$ は勾配消失の特効薬: 内積の次元数比例による分散の肥大化を抑え込み、Softmaxの飽和と学習の即死を物理的に阻止する。
  4. 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 に制御する。

確認クイズ

📝 確認クイズ(3問)