
Transformerの中身をのぞく:attentionを手で計算する
LLMの中で、次のトークンの確率を計算しているTransformer。全体の構造と、中心のattentionを小さなコードで実際に計算して確かめる。
LLMが次のトークンの確率を出す部分(Transformer)の構造を、図とコードで確かめます。中心は、どのトークンをどれだけ見るかを決めるattentionの計算です。
この記事で見るもの
前の記事では、LLMは「次のトークンの確率」を計算して、そこから選ぶと説明しました。この記事では、その確率を計算している部分、Transformerの中をのぞきます。Transformerは、2017年の論文「Attention Is All You Need」で提案された構造です。再帰や畳み込みを使わず、attentionを中心に作られているのが特徴でした。現在の多くのLLMは、この構造を基にしています。ただし、Claudeを含む個々のモデルの詳細な内部構造は、この記事では扱いません。ここで説明するのは、論文で示された一般的な構造です。
図と数値は、仕組みを理解するための単純化です。実際のモデルは、層の数もベクトルの大きさも桁違いに大きく、具体的な値はモデルによって違います。論文の提案は翻訳向けの構成で、LLMで広く使われているのは、その出力側に近い形(デコーダー型)です。
全体像:下から上へ流れる
次の図は、デコーダー型の一般的なTransformerの流れです。色のついた点が、データが下から上へ流れるイメージを表します(動きを減らす設定では表示されません)。
- トークンをベクトル(数字の並び)に直す(埋め込み)。トークンの位置の情報も、ここで加える
- attention:各トークンが、前のトークンのどれをどれだけ見るかを決めて、情報を集める
- フィードフォワード:各トークンのベクトルを、それぞれ別々に加工する
- 「足して正規化」と残差接続:元の入力を足し戻して、層を深く重ねても学習しやすくする
- この組を何層も重ねる(図の × N 層)
- 最後に、語彙の数だけスコアを出し(Linear)、softmaxで確率にする。前の記事の温度は、このsoftmaxの前のスコアに効く
トークンは、ベクトルになる
モデルの中では、トークンは数字の並び(ベクトル)として扱われます。使われ方の似たトークンが近いベクトルになるように、学習で調整されます。attentionだけでは、トークンの並び順が分かりません。そのため、位置の情報も加えます。位置の表し方にはいくつかの方式があり、モデルによって違います。
attention:どのトークンを見るか決める
attentionの目的は、「今のトークンの意味を決めるのに、前のトークンのどれが大事か」を、計算で決めることです。各トークンから、3つのベクトルを作ります。Q(クエリ)は「何を探しているか」、K(キー)は「何を持っているか」、V(バリュー)は「渡す中身」です。計算は次の4段階です。
- QとKの内積で、トークン同士の相性のスコアを出す。で割って、値が大きくなりすぎないようにする
- 未来のトークンは見えないようにする(マスク)。次のトークンを当てる学習で、答えを先に見てしまわないため
- softmaxで、行ごとに合計1の重みにする
- 重みでVを足し合わせて、そのトークンの新しいベクトルにする
これを式で書くと、次のとおりです。
ここで は、ベクトルの次元です。
コードで確かめる
手で追える大きさ(トークン5つ、4次元、1ヘッド)で、attentionを実装しました。重みは学習前の乱数なので、パターン自体に意味はありません。見てほしいのは、計算の形です。乱数の種はいくつか試して、差が見やすいものを選びました(種を変えると、別のパターンになります)。コードは、JavaScript版とPython版をタブで切り替えて読めます。乱数も同じ式で作っているので、出力は一字一句同じです(筆者がNode.js 26.1.0とPython 3.11.5で実行して確認)。
// Self-attention を1ヘッド分、手で計算する。重みは学習前の乱数なので、パターン自体に意味はない。
const tokens = ['the', 'model', 'predicts', 'the', 'next'];
const d = 4; // ベクトルの次元(実際のモデルは、はるかに大きい)
function rng(seed) {
return () => {
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const random = rng(2);
const matrix = (rows, cols) =>
Array.from({ length: rows }, () => Array.from({ length: cols }, () => random() * 2 - 1));
const mul = (A, B) =>
A.map(row => B[0].map((_, j) => row.reduce((sum, v, k) => sum + v * B[k][j], 0)));
const transpose = A => A[0].map((_, j) => A.map(row => row[j]));
const softmax = row => {
const max = Math.max(...row);
const exps = row.map(v => Math.exp(v - max));
const sum = exps.reduce((a, b) => a + b, 0);
return exps.map(v => v / sum);
};
const X = matrix(tokens.length, d); // 各トークンのベクトル(埋め込み)
const Wq = matrix(d, d), Wk = matrix(d, d), Wv = matrix(d, d);
const Q = mul(X, Wq), K = mul(X, Wk), V = mul(X, Wv);
// 1. 各トークンの Q と、すべてのトークンの K の内積を取り、√d で割る
const scores = mul(Q, transpose(K)).map(row => row.map(v => v / Math.sqrt(d)));
// 2. 未来のトークンは見えないようにする(右上を -∞ にする)
const masked = scores.map((row, i) => row.map((v, j) => (j <= i ? v : -Infinity)));
// 3. 行ごとに softmax をかけて、合計が 1 の重みにする
const weights = masked.map(softmax);
// 4. 重みで V を足し合わせて、各トークンの新しいベクトルにする
const output = mul(weights, V);
console.log('weights:');
weights.forEach((row, i) => console.log(tokens[i].padEnd(9), row.map(w => w.toFixed(2)).join(' ')));
console.log('row sums:', weights.map(r => r.reduce((a, b) => a + b, 0).toFixed(2)).join(' '));
console.log('output of "next":', output[4].map(v => v.toFixed(2)).join(' '));# 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図は、この重みの表です。行が「見る側のトークン」、列が「見られる側のトークン」です。読み取れることは3つあります。
- 右上がすべて0。未来のトークンは見えないようにしてあるため
- 各行の合計が1.00。softmaxで重みにしているため
- 1行目は自分自身だけ(1.00)。最初のトークンには、見られる相手が自分しかいないため
重みが乱数のままなので、predictsに重みが集まっているのも偶然です。学習では、Q、K、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)です。
attentionのあと
attentionで周りの情報を集めたら、フィードフォワードで各トークンのベクトルを加工し、残差接続で元の入力を足し戻します。この組を何層も重ねます。実際のモデルは、attentionを複数の「ヘッド」に分けて並列に計算し、それぞれが別の関係を見られるようにします。このコードは、そのうちの1ヘッド分です。最後の層まで進んだら、最後のトークンのベクトルから、語彙の数だけスコアを出します。それをsoftmaxにかけたものが、前の記事で見た「次のトークンの確率」です。温度は、そのsoftmaxの前のスコアに効きます。
ここまでで、「確率で次のトークンを選ぶ」という前の記事の説明が、どの部分の計算なのかがつながりました。