Transformer の風景
前編はこちら
はじめに、はこちら
後編──自作 Transformer で確かめる(実装篇)
前回の風景に戻る
前回、僕たちは LLM を一本の登山鉄道として眺めた。
トークンという乗客が列車に乗り、いくつもの駅を通りながら姿を変え、山頂で次の乗客が決まる。各駅で行われる乗客同士の囁き合い。これを Self-Attention と呼んだ。そして、約 100 もの駅で、この囁き合いがくりかえされる、と話した。
前回たどり着いた、いちばん大事な発見も、思い出しておきたい。主役は、動く列車ではなく、動かない山のほうだった。無数の列車が登って各駅に経験を堆積させ、その重みの地層こそが LLM の本体である、と。
だが、前回は一つ、開けずに残した箱がある。
その「囁き合い」が、駅の中で実際にどんな計算をしているのか、という箱である。前回は「乗客の Query と、ほかの乗客の Key とを照合して、Value を受け取る」とだけ述べて、その照合の中身には踏み込まなかった。そして、駅に堆積した経験が乗客を導く、とも述べた。では、その経験は、駅の中で具体的にどう働くのか。
今日は、この一駅の中だけを、徹底的に開けてみる。
約 100 の駅すべてではない。たった一つの駅の中で何が起きているのかが分かれば、あとはその同じことが 100 回くりかえされているだけだからである。
そして、今日お見せするのは、教科書の図でも、どこかからの借り物でもない。僕自身が、2017 年の原論文をたどって書き、実際に動かしたプログラムである。
僕は、本を読むだけでは納得できなかった。「次の語を予測している」という説明を百回読んでも、中で何が起きているかは手に触れてこない。だから自分で組んでみた。素人がゼロから組み立てて動くのだから、これは魔法ではない。今日は、それをご一緒に確かめたい。
一つ、前回からの橋渡しをしておく。
僕が書いたのは、2017 年の原論文の構成、すなわち翻訳のための Transformer である。原論文には Encoder と Decoder という二つの翼があった。今の ChatGPT や Claude は、このうち Decoder という片翼を巨大化させたものにあたる。
だが、今日開ける「駅の中の囁き合い」、すなわち Self-Attention の仕組みは、どちらにも共通する心臓部である。原型で理解すれば、現代の LLM にもそのまま通じる。
まず、山を一望する──コードの中の登山鉄道
駅を一つ開ける前に、僕のプログラムの中で、登山鉄道の全体がどう書かれているかを見ておきたい。前回「山こそが本体だ」と述べたことが、コードの上では、驚くほど素直に表れているからである。
僕は、山全体を Encoder という部品として書いた。その中心は、次の数行である。
class Encoder(tf.keras.layers.Layer):
def call(self, x, training=False, mask=None):
x = self.embedding(x) # 乗客に姿を与える(Embedding)
x *= tf.math.sqrt(…) # 姿の大きさを整える
x += self.pos_encoding[:, :seq_len, :] # 席番号を加える(位置情報)
for layer in self.enc_layers: # 駅を一つずつ通る
x = layer(x, mask=mask)
return x
注目してほしいのは、for layer in self.enc_layers: という一行である。
x が乗客。self.enc_layers が、駅を順に並べたものである。乗客 x が、駅(layer)を一つずつ通り、そのたびに姿を変えて(x = layer(x))、次の駅へ進む。これが、登山鉄道が山を登っていく、まさにその姿である。麓で乗客に姿を与え(embedding)、席番号を渡し(位置情報)、あとは駅を順に通していくだけ。
そして、ここが大事なところである。この山の本体は、self.enc_layers という駅の列が抱えている。各駅の中には、学習で堆積した経験、すなわち重みが詰まっている。前回「山こそが LLM の本体」と言ったが、コードで言えば、それはこの駅の列に蓄えられた重みのことなのである。乗客は通り過ぎるだけ。駅が、すべてを覚えている。
では、その駅の一つを、開けてみよう。
一つの駅を、外から眺める
僕のプログラムの中で、駅を一つ表す部品が、EncoderLayer である。これが、登山鉄道の一つの駅にあたる。コードはこうなっている。今は、細部を読む必要はない。形だけ眺めてほしい。
class EncoderLayer(tf.keras.layers.Layer):
def call(self, x, training=False, mask=None):
# ① 囁き合い(Self-Attention)
attn_output = self.mha(query=x, key=x, value=x, attention_mask=mask)
out1 = self.layernorm1(x + attn_output)
# ② 姿の手直し(Feed Forward Network)
ffn_output = self.ffn(out1)
return self.layernorm2(out1 + ffn_output)
(実際のコードには、これに学習を安定させるための dropout という処理が加わるが、骨格はこの通りである。)
駅の中で行われていることは、たった二つである。
一つめが、①の囁き合い。コードでは self.mha と書いた一行である。mha とは Multi-Head Attention の略で、これがまさに乗客同士が互いの声を聞く部分である。query, key, value という三つの言葉がすでに見えているが、これが今日の主役で、後ほど一つずつ開ける。
二つめが、②の姿の手直し。self.ffn と書いた部分である。囁き合いで文脈を取り込んだあと、各乗客が自分の姿を一人で整え直す工程だと思えばよい。隣の声を聞いて受け取った情報を、自分の中で噛みくだく作業である。
そして、もう一つ大切なのが、この奇妙な足し算である。
out1 = self.layernorm1(x + attn_output)
x が、駅に入る前の乗客の姿。attn_output が、囁き合いで受け取った情報。それを足している。
これは何を意味するのか。
乗客は、囁きを聞いたからといって、まるごと別人に入れ替わるのではない。元の自分、つまり x に、囁きで受け取った分、つまり attn_output を足し加えて、少しだけ姿を更新する。この「元の自分に足す」という作り、専門的には残差接続と呼ばれる作りのおかげで、100 の駅を通っても、乗客は自分の芯を失わずに、少しずつ豊かになっていける。
ここまでで、一つ分かることがある。
駅とは、思っていたほど複雑な装置ではない。囁き合いがあり、姿の手直しがあり、元の自分に足す。部品はこれだけである。
では、その中でいちばんの核心、囁き合いの中を開けよう。ここからが今日の山場である。
囁き合いの中を開ける──Query・Key・Value
囁き合いの中で、機械が実際にやっているのは、突きつめると三つの言葉に集約される。
Query、Key、Value。
前回、比喩で次のように紹介したものである。
| 三つの情報 | 意味(比喩) |
|---|---|
| Query(クエリ) | 僕はいま、どんな情報を探しているか |
| Key(キー) | 僕は、どんな情報として探されうるか |
| Value(バリュー) | 僕から渡せる中身は何か |
言葉で言われても、ぴんと来ないだろう。だから、実際に数字を動かしてみる。ここが今日の核心である。電卓があれば追える程度の、足し算と掛け算しか出てこないことを、目で確かめてほしい。
舞台──三人の乗客
話を可能な限り小さくする。
乗客は三人だけとしよう。「明日」「天気」「は」の三人である。前回、本物の乗客は数千個の数値でできた姿を持つ、と話した。だが数千個では黒板に書けないので、ここでは一人を四個の数値で表すことにする。考え方は、数が増えても全く同じである。
各乗客は、自分の姿から、Query・Key・Value という三つの小さな数値の組を取り出す。
どう取り出すかの規則、つまり変換のための重みは、前回話したとおり、学習段階で駅に堆積した経験そのものである。ここでは、その経験の結果こうなった、という値を直接お見せする。
「明日」という乗客に注目しよう。
| 乗客 | Query(探している) | Key(探されうる) | Value(渡す中身) |
|---|---|---|---|
| 明日 | [2, 1, 0, 0] | [1, 0, 0, 0] | [9, 0, 0] |
| 天気 | [1, 2, 0, 0] | [2, 1, 0, 0] | [0, 9, 0] |
| は | [0, 0, 1, 1] | [0, 0, 0, 1] | [0, 0, 9] |
値そのものに深い意味を読み取る必要はない。駅に堆積した経験の結果、たまたまこうなった、と思ってよい。大事なのは、これから先の手続きである。
第一歩──内積でスコアを測る
「明日」という乗客は、自分の Query を持って、ほかの全員、そして自分自身に問いかける。
「僕が探しているのは、あなたですか?」と。
この「どれくらい合っているか」を測るのが、内積という計算である。内積とは、二つの数値の組について、同じ位置どうしを掛けて、すべて足し合わせるだけの操作である。
「明日」の Query [2, 1, 0, 0] と、「天気」の Key [2, 1, 0, 0] で、実際にやってみよう。
明日の Query : [2, 1, 0, 0]
天気の Key : [2, 1, 0, 0]
2×2 + 1×1 + 0×0 + 0×0
= 4 + 1 + 0 + 0
= 5
5 という数が出た。
これが、「明日」が「天気」にどれくらい注目すべきかの、生のスコアである。同じことを、「明日」と自分自身、「明日」と「は」についても行う。
| 明日が問いかける相手 | 内積(生スコア) |
|---|---|
| 明日 → 明日(自分自身) | 2 |
| 明日 → 天気 | 5 |
| 明日 → は | 0 |
内積が大きいほど、二人の向きがよく合っている、つまり強く関係している、ということである。「明日」は「天気」に対してスコア 5 と、際立って高い。「は」に対しては 0。これは、「明日」という乗客が、「天気」という乗客と強く照応するように、駅に堆積した経験が仕込んでいたからである。
第二歩──大きくなりすぎを抑える(スケーリング)
ここで一つ、調整が入る。
生のスコアは、乗客の姿の数値が大きかったり、数値の個数が多かったりすると、どんどん膨らんでしまう。膨らみすぎると、次の段階の計算が極端になりすぎる。
そこで、スコアをある決まった数で割って、大きさをならす。
割る数は、姿の数値の個数の平方根である。今回は一人を四個の数値で表したので、4 の平方根、すなわち 2 で割る。
生スコア : [2, 5, 0]
(明日→明日、明日→天気、明日→は)
÷ 2 : [1, 2.5, 0]
なぜ「個数の平方根」なのか。ここには確率・統計のきちんとした理由があるのだが、本筋からは外れるので、巻末の補足に回す。今は「数が暴れないように、決まった数で割って整える一手間」とだけ理解しておけば、先へ進める。
第三歩──重みに変える(softmax)
いま手元にあるのは [1, 2.5, 0] という三つの数である。
だが、このままでは「注意の配分」として使いにくい。僕たちが欲しいのは、「明日」が持っている注意を 100% として、それを三人にどう振り分けるか、という割合である。
そこで softmax という操作を通す。
難しい名前だが、やっていることは「大きい数ほど大きな割合を与えつつ、全部を足すと 100% になるように変換する」ことである。大小の順番は保ったまま、合計が 1 になる割合へとならす。
結果はこうなる。
| 明日が注目する相手 | スコア(÷2 後) | 注意の割合(softmax 後) |
|---|---|---|
| 明日 → 明日 | 1 | 17.1% |
| 明日 → 天気 | 2.5 | 76.6% |
| 明日 → は | 0 | 6.3% |
これが、囁き合いの核心である。
「明日」という乗客は、自分が持つ注意のうち、約 77% を「天気」に、約 17% を自分自身に、わずか 6% を「は」に振り分けることを決めた。
前回の比喩を思い出してほしい。
「各駅で乗客たちは互いに耳を澄ます。ただし、やみくもに全員の声を同じ大きさで聞くのではない」と述べた。その「どれだけ重く聞くか」が、いま、77%・17%・6% という具体的な割合として、目の前に出てきたのである。
比喩で語ったことが、数字になった。
第四歩──中身を受け取る(重みつき平均)
割合が決まれば、あとは簡単である。
「明日」は、その割合に従って、各乗客の Value、つまり渡す中身を受け取る。「天気」の中身を 77% 分、自分の中身を 17% 分、「は」の中身を 6% 分、混ぜ合わせる。
明日が受け取る新しい中身
=
0.171 × 明日の Value [9, 0, 0]
+ 0.766 × 天気の Value [0, 9, 0]
+ 0.063 × は の Value [0, 0, 9]
= [1.54, 6.90, 0.57]
この [1.54, 6.90, 0.57] が、囁き合いを終えた「明日」の新しい中身である。
元は「明日」という、ただの時間を表す語だった。それが、いまや真ん中の数値、すなわち 6.90 が大きく立ち上がっている。これは「天気」の成分である。
「明日」という乗客は、囁き合いを通じて、「天気について問われている明日」へと、姿を一歩変えたのである。
そして思い出してほしい。
前の章で見た、あの足し算。
out1 = layernorm1(x + attn_output)
これは、まさにこの新しい中身を、元の「明日」に足し戻す操作だった。
乗客は別人にはならない。「明日」のまま、「天気」の色を帯びる。
これだけである
いま見たものが、Self-Attention のすべてである。
手続きを並べ直すと、こうなる。
| 手順 | やっていること | 比喩 |
|---|---|---|
| ① 内積 | Query と Key を掛けて足す | 僕が探す相手と、どれだけ合うか測る |
| ② スケーリング | 決まった数で割って整える | 数が暴れないようにならす |
| ③ softmax | 合計 100% の割合に変える | 注意をどう振り分けるか決める |
| ④ 重みつき平均 | 割合に従い Value を混ぜる | その配分で中身を受け取る |
掛けて、足して、割って、割合にして、また掛けて足す。出てきたのは、それだけである。
微分も、難しい関数も、魔法の箱も、どこにもない。前回「駅員が、この乗客の声を重く聞きなさいと調整する」と比喩で述べたものの正体は、この内積と softmax による割合計算だった。そして、その計算の手綱を握っていたのは、駅に堆積した経験、すなわち重みである。計算の手順はこれだけ単純で、その単純な手順に意味を与えているのは、山に蓄えられた経験のほうなのである。
そして、これが約 100 の駅でくりかえされる。
一つの駅で「明日」が「天気」の色を少し帯びる。次の駅では、その少し染まった「明日」が、また別の関係を計算する。これを百回。先ほどコードで見た for layer in self.enc_layers: という、あの一行のループである。
一回いっかいは、いま見た電卓レベルの計算にすぎない。だが、それが百層積み重なり、数千個の数値の上で、数千の乗客に対して同時に行われると、人間の文章と見分けのつかない出力が生まれてくる。
駅を閉じ、山を引いて眺める
開けた駅を、そっと閉じよう。
そして、登山鉄道の全景に、もう一度戻る。
いま僕たちは、たった一つの駅の中で、「明日」という乗客が「天気」に 77% の注意を払い、その中身を受け取って、自分の姿を一歩変えるのを見た。
前回は雲の上から山全体を眺めた。今日は、そのうちの一駅に降りて、床板をはがし、歯車が掛けて足して割っているのを、この目で見た。歯車は、思っていたより、ずっと単純だった。
ここで、前編の発見が、はっきりと裏づけられる。歯車の動き、すなわち内積も softmax も、それ自体は何も知らない。ただ掛けて足すだけである。意味を与えていたのは、Query・Key・Value の値、つまり駅に堆積した経験のほうだった。計算はからくりにすぎず、知恵は山にある。主役は列車ではなく山だ、という前編の結論は、こうして一駅の床下からも確かめられる。
だから、今日いちばん持ち帰っていただきたいのは、この安心である。
LLM の中身は、魔法ではない。理解できないブラックボックスでもない。一つひとつの部品は、中学校の数学で追える、掛け算と足し算の繰り返しである。素人の僕がゼロから組んで動いたのが、その何よりの証拠である。「賢すぎて不気味だ」という漠然とした怖さは、中を一度見れば、かなりの部分が解ける。
お追従の正体、ふたたび
ここで、前回の最後に置いた問いに戻る。
なぜ LLM は、あれほど調子よく相槌を打つのか。
今日、中を開けた僕たちは、もう一歩深く答えられる。
注意の割合、すなわち 77%、17%、6% は、どこから来たか。乗客が出す Query や Key や Value の値から来た。ではその値は、どう決まったか。学習段階で、駅に堆積した経験から決まった、と今日述べた。
つまり、何にどれだけ注目するかは、すべて山に蓄えられた重みが生んでいる。
そして、その学習データには、人間に好まれた応答が大量に含まれている。機械は、答え合わせをくりかえす中で、「人間が高く評価する語のつながり」に、自然と高い注意の割合を割り振るようになる。
お追従とは、機械が意地悪を隠しているのでも、本心を偽っているのでもない。今日見た内積と softmax が、山に刻まれた「好かれる方向」の経験に従って、その方向の語に高い重みを与える。その帰結なのである。
仕組みが分かれば、お追従は不気味な人格ではなく、計算の癖として見えてくる。
それでも、残る問い
最後に、安心の上に、考え続けるための小さな宿題を置いておきたい。
仕組みは、確かに単純である。掛けて、足して、割る。それは今日確かめた。
だが不思議なのは、その単純な計算を、百層、数千次元、数兆語という途方もない規模で積み上げると、作った本人たちすら予測しきれない振る舞いが現れることである。文脈を読み、筋を通し、ときに人間より滑らかな文章を書く。誰一人「そうしろ」と部品に命じてはいないのに、である。
仕組みは魔法ではない。これは、今日はっきりした。だが、単なる電卓だと言い切るにも、まだ早い。
魔法と電卓の、ちょうど中間に、いまの LLM は立っている。その中間で何が起きているのかを見極めることが、これから僕たち全員に問われている課題だと、僕は思っている。
今日、ご一緒に一駅の床板をはがせたことが、その見極めの、確かな一歩になればうれしい。
補足──なぜ「個数の平方根」で割るのか
本編で、スコアを「姿の数値の個数の平方根」で割る、と述べた。今回は 4 個だったので 2 で割った。なぜ平方根なのか、興味のある方のために記しておく。
内積は、同じ位置どうしの掛け算を、数値の個数だけ足し合わせたものである。
仮に、各位置の数値がてんでばらばら、つまり互いに無関係で、それぞれ同じ程度のばらつきを持つとしよう。すると、足し合わせる項の数が増えるほど、合計のばらつき、すなわち分散は、項の数に比例して大きくなることが知られている。
ばらつきの幅、すなわち標準偏差は、その分散の平方根である。だから、数値の個数を n とすると、内積のばらつきの幅は n の平方根に比例して大きくなる。スコアのばらつきを、個数によらず一定の大きさにそろえたければ、n の平方根で割ってやればよい。これが、平方根で割る理由である。
もし割らずに放置すると、個数が多いときにスコアが極端に大きくなり、次の softmax の段階で、ほとんど一人の乗客に 100% 近い注意が集中してしまう。それでは、複数の乗客から少しずつ情報を集めるという、囁き合いの妙が失われる。平方根で割る一手間は、その偏りを防ぎ、注意を健全に分散させるための工夫なのである。
ちなみに、この一手間は、僕が参照した原論文でも、本文ではなく、ほとんど脚注のような扱いで一言触れられているだけである。地味だが、効いている。コードを書いて自分で動かすと、こういう地味な一行の意味に、かえって気づきやすくなる。
原論文の語り口も、ここで紹介しておきたい。
著者たちは、内積が大きくなると softmax が勾配の極めて小さい領域へ押しやられる、と述べるのだが、その言い方は「我々はそう疑っている(We suspect that)」という、控えめな推測の形をとっている。理論的に証明したと言い切るのではなく、経験的にうまくいくこの工夫の理由を、おそらくこうだろう、と述べているのである。
そして、なぜ「個数の平方根」なのかという定量的な根拠、すなわち各成分が平均 0・分散 1 の独立な値なら、内積は平均 0・分散が個数 d_k になる、という根拠は、本文ではなく、たった一つの脚注に格納されている。
つまり、いま僕たちが補足として一節を割いて確かめたことは、Transformer を生んだ当の論文では、本文の一文と、脚注の一行で済まされている。
これほど広く使われ、これほど効いている工夫が、提案した本人たちによって、半ば控えめに、半ば直感的に置かれた。そのことが、僕には面白く思える。
仕組みは単純でも、なぜそれが効くのかは、提案者でさえ完全には言い切れていなかった。LLM という対象の、奥行きの一端が、こんな脚注一つにも顔をのぞかせている。
