← 記事一覧へ
AI生成画像

Transformerの中身をのぞく:attentionを手で計算する

LLMの中で、次のトークンの確率を計算しているTransformer。全体の構造と、中心のattentionを小さなコードで実際に計算して確かめる。

AI作成 · 約10分 · 「LLMはどう動くか」を読んだ人向け
TransformerattentionLLM
この記事で扱うこと

LLMが次のトークンの確率を出す部分(Transformer)の構造を、図とコードで確かめます。中心は、どのトークンをどれだけ見るかを決めるattentionの計算です。

この記事で見るもの

前の記事では、LLMは「次のトークンの確率」を計算して、そこから選ぶと説明しました。この記事では、その確率を計算している部分、Transformerの中をのぞきます。Transformerは、2017年の論文「Attention Is All You Need」で提案された構造です。再帰や畳み込みを使わず、attentionを中心に作られているのが特徴でした。現在の多くのLLMは、この構造を基にしています。ただし、Claudeを含む個々のモデルの詳細な内部構造は、この記事では扱いません。ここで説明するのは、論文で示された一般的な構造です。

図と数値は、仕組みを理解するための単純化です。実際のモデルは、層の数もベクトルの大きさも桁違いに大きく、具体的な値はモデルによって違います。論文の提案は翻訳向けの構成で、LLMで広く使われているのは、その出力側に近い形(デコーダー型)です。

全体像:下から上へ流れる

次の図は、デコーダー型の一般的なTransformerの流れです。色のついた点が、データが下から上へ流れるイメージを表します(動きを減らす設定では表示されません)。

Transformer(デコーダー型)の全体像次のトークンの確率Softmax温度はここで効くLinear語彙の数だけスコアを出す× N 層足して正規化フィードフォワード各トークンを別々に加工足して正規化マスク付き self-attentionどのトークンを見るか決めて集める残差残差埋め込み + 位置の情報トークンをベクトルにするthemodelpredicts
図1 一般的なTransformer(デコーダー型)の流れ。下の入力から上の確率まで、データは下から上へ流れる。枠の中を何層も重ねる。
  • トークンをベクトル(数字の並び)に直す(埋め込み)。トークンの位置の情報も、ここで加える
  • attention:各トークンが、前のトークンのどれをどれだけ見るかを決めて、情報を集める
  • フィードフォワード:各トークンのベクトルを、それぞれ別々に加工する
  • 「足して正規化」と残差接続:元の入力を足し戻して、層を深く重ねても学習しやすくする
  • この組を何層も重ねる(図の × N 層)
  • 最後に、語彙の数だけスコアを出し(Linear)、softmaxで確率にする。前の記事の温度は、このsoftmaxの前のスコアに効く

トークンは、ベクトルになる

モデルの中では、トークンは数字の並び(ベクトル)として扱われます。使われ方の似たトークンが近いベクトルになるように、学習で調整されます。attentionだけでは、トークンの並び順が分かりません。そのため、位置の情報も加えます。位置の表し方にはいくつかの方式があり、モデルによって違います。

attention:どのトークンを見るか決める

attentionの目的は、「今のトークンの意味を決めるのに、前のトークンのどれが大事か」を、計算で決めることです。各トークンから、3つのベクトルを作ります。Q(クエリ)は「何を探しているか」、K(キー)は「何を持っているか」、V(バリュー)は「渡す中身」です。計算は次の4段階です。

  • QとKの内積で、トークン同士の相性のスコアを出す。d\sqrt{d}で割って、値が大きくなりすぎないようにする
  • 未来のトークンは見えないようにする(マスク)。次のトークンを当てる学習で、答えを先に見てしまわないため
  • softmaxで、行ごとに合計1の重みにする
  • 重みでVを足し合わせて、そのトークンの新しいベクトルにする

これを式で書くと、次のとおりです。

softmax ⁣(QK⊤d)V\mathrm{softmax}\!\left(\frac{QK^{\top}}{\sqrt{d}}\right)V

ここで dd は、ベクトルの次元です。

コードで確かめる

手で追える大きさ(トークン5つ、4次元、1ヘッド)で、attentionを実装しました。重みは学習前の乱数なので、パターン自体に意味はありません。見てほしいのは、計算の形です。乱数の種はいくつか試して、差が見やすいものを選びました(種を変えると、別のパターンになります)。コードは、JavaScript版とPython版をタブで切り替えて読めます。乱数も同じ式で作っているので、出力は一字一句同じです(筆者がNode.js 26.1.0とPython 3.11.5で実行して確認)。

attention.py(同じ計算をPythonで)
# Self-attention を1ヘッド分、手で計算する(attention.mjs と同じ計算を Python で書いたもの)。
import math

tokens = ['the', 'model', 'predicts', 'the', 'next']
d = 4                                    # ベクトルの次元(実際のモデルは、はるかに大きい)


def rng(seed):
    """JavaScript版と同じ式の乱数。同じ種なら、同じ数列になる。"""
    state = seed & 0xFFFFFFFF

    def random():
        nonlocal state
        state = (state + 0x6D2B79F5) & 0xFFFFFFFF
        t = ((state ^ (state >> 15)) * (1 | state)) & 0xFFFFFFFF
        t = ((t + (((t ^ (t >> 7)) * (61 | t)) & 0xFFFFFFFF)) & 0xFFFFFFFF) ^ t
        return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296

    return random


random = rng(2)


def matrix(rows, cols):
    return [[random() * 2 - 1 for _ in range(cols)] for _ in range(rows)]


def mul(A, B):
    return [[sum(a * b for a, b in zip(row, col)) for col in zip(*B)] for row in A]


def transpose(A):
    return [list(col) for col in zip(*A)]


def softmax(row):
    top = max(row)
    exps = [math.exp(v - top) for v in row]
    total = sum(exps)
    return [v / total for v in exps]


X = matrix(len(tokens), d)               # 各トークンのベクトル(埋め込み)
Wq, Wk, Wv = matrix(d, d), matrix(d, d), matrix(d, d)
Q, K, V = mul(X, Wq), mul(X, Wk), mul(X, Wv)

# 1. 各トークンの Q と、すべてのトークンの K の内積を取り、√d で割る
scores = [[v / math.sqrt(d) for v in row] for row in mul(Q, transpose(K))]
# 2. 未来のトークンは見えないようにする(右上を -∞ にする)
masked = [[v if j <= i else -math.inf for j, v in enumerate(row)] for i, row in enumerate(scores)]
# 3. 行ごとに softmax をかけて、合計が 1 の重みにする
weights = [softmax(row) for row in masked]
# 4. 重みで V を足し合わせて、各トークンの新しいベクトルにする
output = mul(weights, V)

print('weights:')
for token, row in zip(tokens, weights):
    print(token.ljust(9), ' '.join(f'{w:.2f}' for w in row))
print('row sums:', ' '.join(f'{sum(row):.2f}' for row in weights))
print('output of "next":', ' '.join(f'{v:.2f}' for v in output[4]))

結果を読む

実行すると、次の結果になります(筆者がNode.js 26.1.0で実行して確認。乱数の種が固定なので、何度実行しても同じです)。

実行結果
weights:
the       1.00 0.00 0.00 0.00 0.00
model     0.54 0.46 0.00 0.00 0.00
predicts  0.22 0.10 0.68 0.00 0.00
the       0.21 0.27 0.29 0.23 0.00
next      0.15 0.08 0.52 0.02 0.23
row sums: 1.00 1.00 1.00 1.00 1.00
output of "next": 0.29 0.92 0.90 -0.29
attentionの重み(5トークン × 5トークン)↓ 見る側見られる側 →thethemodelmodelpredictspredictsthethenextnext1.00××××0.540.46×××0.220.100.68××0.210.270.290.23×0.150.080.520.020.2301重み未来のトークン(見えない)
図2 実際に計算した重み(行が見る側、列が見られる側)。右上は未来のトークンなので見えず、各行の合計は1。枠で囲んだ行が、次の図で詳しく見る next の行。

図は、この重みの表です。行が「見る側のトークン」、列が「見られる側のトークン」です。読み取れることは3つあります。

  • 右上がすべて0。未来のトークンは見えないようにしてあるため
  • 各行の合計が1.00。softmaxで重みにしているため
  • 1行目は自分自身だけ(1.00)。最初のトークンには、見られる相手が自分しかいないため

重みが乱数のままなので、predictsに重みが集まっているのも偶然です。学習では、Q、K、Vを作る行列(WQW_Q、WKW_K、WVW_V)が、次のトークンをうまく当てられるように調整されます。

「next」の1行を詳しく見る

最後の行(next)の計算を追います。nextは、自分を含む5つのトークンを見られます。内積のスコアをsoftmaxにかけると、predictsに0.52、nextの自分自身に0.23、最初のtheに0.15の重みがつきます。その重みでVを足し合わせた結果が、nextの新しいベクトル(0.29、0.92、0.90、-0.29)です。

「next」が前のトークンを見る重み「next」が、どのトークンをどれだけ見るか内積÷√d重み(softmax後)the0.420.15model-0.200.08predicts1.640.52the-1.600.02next0.850.23重みでVを足し合わせるnextの新しいベクトル0.290.920.90-0.29
図3 最後の行(next)の計算。内積のスコアをsoftmaxにかけて重みにし、その重みでVを足し合わせたものが、nextの新しいベクトルになる。数値は前の節のコードの結果。

attentionのあと

attentionで周りの情報を集めたら、フィードフォワードで各トークンのベクトルを加工し、残差接続で元の入力を足し戻します。この組を何層も重ねます。実際のモデルは、attentionを複数の「ヘッド」に分けて並列に計算し、それぞれが別の関係を見られるようにします。このコードは、そのうちの1ヘッド分です。最後の層まで進んだら、最後のトークンのベクトルから、語彙の数だけスコアを出します。それをsoftmaxにかけたものが、前の記事で見た「次のトークンの確率」です。温度は、そのsoftmaxの前のスコアに効きます。

ここまでで、「確率で次のトークンを選ぶ」という前の記事の説明が、どの部分の計算なのかがつながりました。

公式リファレンス

Vaswani et al. — Attention Is All You Need (arXiv:1706.03762) ↗Claude Docs — Glossary ↗
PAIBOT / DEVELOPMENT JOURNAL記事一覧に戻る →

知りたいことを探す

タイトル・タグ・本文から検索 esc で閉じる