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 後)
明日 → 明日117.1%
明日 → 天気2.576.6%
明日 → は06.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 という対象の、奥行きの一端が、こんな脚注一つにも顔をのぞかせている。

前編はこちら
はじめに、はこちら