オタク式LLM解説 -「ChatGPT有効活用で爆アド!!」と言う前に仕組みを知っておきましょう -
オタク式LLM解説 -「ChatGPT有効活用で爆アド!!」と言う前に仕組みを知っておきましょう -
オタクの前置きは長い
こんにちは。
近頃(もはや近頃というほどでもないけど)、AIがブームですよね。
大人も子供もAIを使っています。ChatGPTに聞けば何でもわかる。チャッピーはなんでもやってくれる。徳川9代目将軍も、今日の献立も、あなたの前世も、チャッピーが教えてくれます(笑)
お金の匂いもぷんぷんするのでしょう、猫も杓子も上段に立って 「AI有効活用で爆アド!!」 みたいなことを言っています。 その猫や杓子のうち、どれくらいがAIのことを分かっているのかはさておき。
チャッピーがなんだ、Claudeがなんだって話をするのなら(あるいはそういう話を聞くのなら)、とにもかくにも構造を押さえておいたほうが色々お得です。
本記事は 「ChatGPTだのClaudeだの、そもそもあいつらって何者なんだよ!意味わかんねぇよ!!」 という疑問に対して、カジュアルに、冗長に、オタク的身内ノリ満載でお答えします。
小難しい話を小難しいノリで「トークナイズしたらエンベッディングでアテンションスコアがファルシのルシがコクーンでパージで」みたいなことを言われてもつまんね~しおもんね~でしょ。
まあ、本記事もサムいノリ全開だし 「オタク君これ本当に面白いと思って書いたの?(笑)」 と指摘されたら縮こまるしかありませんが......
忘れもしないあの頃、教室の隅でカードゲームの制限改定の愚痴やオムド・ロレスの配合方法を駄弁っていた時のテンションで、こういう小難しいことを語るのも少しくらいは需要があるはずだと踏んで、今回は筆(キーボード)を持ちました。
本記事が、AIを学びたい方々の理解の一助になれば幸いです。
AIっていうかLLMという話
AI、AIっていうけれど「AI」という言葉は広い意味を持ちます。なにもChatGPTのようなお喋りロボットだけがAIってわけではありません。
ルールベースのエキスパートシステムも、画像認識も、音声合成も、すべてAIの一種です。
ボス戦でやたらザラキを唱えてくるアイツだって立派なAIです。てかぶっちゃけザラキってボス戦以外でも唱えてほしくないよね。
そんなAIの中で、大量のデータからパターンを学習する手法を機械学習と呼び、さらに脳みその仕組みをパクったニューラルネットワークなる不思議なものを多層に重ねた機械学習の手法を 深層学習(ディープラーニング) と呼びます。
ChatGPTやClaude、Geminiみたいな奴らは、上述の手法を用いて大量のテキストデータから言語のパターンを学習し「次に来る可能性が高い単語(トークン)は何か」を予測するAIモデルです。
そういう奴らを大規模言語モデル(Large Language Model, LLM) と呼称します。
チャッピーのことを一括りにAIと呼ぶのは、オカンがゲームをなんでもかんでもファミコン呼びするような......いやその逆で、ファミコンもカルタもしりとりも全部ゲーム呼びするようなものです。間違ってはないんだけど、微妙にもやもやする感じがあります。
この記事の目的
本記事では、そんなLLMがテキストを受け取ってからテキストを返すまでに何が起きているかを順を追って解説していきます。
ぼく「日本の首都はどこですか」
エーアイ「二ホンノシュトハトウキョウデス」
この会話が成立する理由さえ理解すれば究極完全LLM理解者です。※当社比
自然言語処理や機械学習の事前知識は前提としません。つうか、「そんな事前知識があれば苦労しねぇよ」って話ですよね。
んまあ、中身の話をする以上、ある程度のプログラミング知識と少々の数学の知識が必要な箇所もありますが、そのあたりの知識がなくとも、難しそうなところを読み飛ばしつつ通読すれば、いい感じにLLMの概要や全体像をつかむ事は可能だと思います。
自信がなくてもとりあえず読んでみよう。案外イケるもんだったりします。
なお、本記事の数式・コード例ではパラメータの具体値としてGPT-2(117Mパラメータ)の構成を用います。早速意味不明な値が出てくるけど「ふ~ん」で流し読みしてもらってOK。
| パラメータ | 値 |
|---|---|
| vocab_size | 50,257トークン種 |
| d_model(隠れ層次元数) | 768次元 |
| n_heads(Attentionヘッド数) | 12ヘッド |
| n_layers(デコーダ層数) | 12層 |
| d_ff(FFN中間層次元数) | 3,072次元 |
これらはGPT-2固有の値であり、絶対的な定数ではないことに注意だゾ!
何はともあれまずは全体像
チャットレベルの流れ
ChatGPTやClaudeの使い方ってめちゃくちゃ簡単だよね。僕らは文章を入力するだけ。しばらくすると、なんかいい感じの文章が返ってくる。みんなハッピー。
ここで、LLMは一息に応答文を返しているわけではありません。なんと、LLMが1回の推論で出力するのは次の1トークン(≒1語)だけなんです。応答文全体はこの1トークン予測を繰り返す涙ぐましい努力によって組み立てられます。
ここで重要なのは、LLMは別にこちらの質問を理解して回答しているわけではないということ。
くどいようですが、LLMがやっているのは飽くまでも 「この文脈の続きとして最も自然なテキストは何か」を予測することです。
例えば「日本の首都はどこですか」という入力に対して、LLMはまずこの文章の続きとして確率が高いトークンを選びます。
「日本」が選ばれたら、今度は「日本の首都はどこですか 日本」の続きとして「の」を予測し、さらに「首都」「は」「東京」「です」と1トークンずつ順に予測していく……という流れです。
LLMは1トークンずつしか出力できません。チャットで一度に文章が返ってくるように見えるのは、このループが高速に回っているためです。
どうでしょう。彼らが人語を解さぬ化け物だということを、ざっくりお分かり頂けたと思います。
1ステップの推論パイプライン
ここから、先述したループの1回分……つまりトークン列を入力して次の1トークンを得るまでのパイプラインを解説します。
各ステップでデータの形がどんどん変わっていきます。ベルトコンベアみたいね。
| ステップ | データの形 | イメージ | 例(GPT-2) |
|---|---|---|---|
| 入力トークン列 | トークンID列(整数) | テキストに番号を振った状態 | [46036, 25, 171, 120, 234] |
| Embedding | ベクトル列 | 各トークンに意味を持たせた状態 | 各トークンが768次元のベクトルに |
| Transformerデコーダ | ベクトル列(変換後) | 文脈を踏まえて意味を練り直した状態 | 768次元 × 12層の演算を通過 |
| 出力層 | 確率分布 | 次に来そうな単語の候補リスト | 50,257トークン種それぞれの確率 |
| サンプリング | トークンID(1つ) | 候補から1つ選んだ状態 | 次のトークンID |
なお、テキストとトークン列の相互変換(トークナイズ / デトークナイズ)はLLMモデル本体の外側の処理だけど、パイプラインの理解には不可欠なのでいっしょに解説しちゃいます。
fateを語るならzeroも観てほしいのが人情ってもんだろうがよ!!
オタクくん、zeroの話は原作と地続きではありませんよ。
テキストからトークンへ
文字列とUnicode
さて、コンピュータ上のテキストは、Unicode文字の並びとして表現されます。例えば "Hello" は [U+0048, U+0065, U+006C, U+006C, U+006F] という5つのコードポイントの列です。
しかし悲しいかな、LLMは冷血な化け物なので数値しか扱えません。人の温もりのあるUnicodeを何らかの方法で無機質な数値列に変換する必要があるわけです。Unicode列の時点で十分無機質だろうが。
最も素朴な方法は1文字ずつ番号を振ることですが、Unicodeには15万以上の文字が定義されており、セコセコそんな事してたら語彙の量がとんでもないことになる。この後に行うAttentionという処理は系列長の二乗に比例して計算コストが増えるのでこんなことやってられません。
逆に、単語ごとに番号を振ると、未知語(学習データに存在しない単語)が処理できません。特に日本語なんかはそれっぽい複合語をいくらでも作れてしまうのだから未知語まみれになるわね。
この問題を解決するのがサブワードトークナイズというナイスな手法。こいつは文字と単語の中間的な単位(サブワード)にテキストを分割し、それぞれに番号(トークンID)を割り当てます。要はいいとこ取りってこと。現在は、基本的にこの方法しか使われていません。
BPEの仕組み
さて、現在のLLMで最も広く使われているサブワードトークナイズの手法に BPE(Byte Pair Encoding) というものがあります。ほとんど常識になっている手法なのでこの機会に押さえてしまおう。
mergeルールの構築(学習時)
BPEの語彙は、学習データから以下のようなアルゴリズムで学習されます。
- 初期語彙として、すべてのバイト値(0〜255)を登録する
- 学習データ全体を初期語彙の単位(バイト列)に分割する
- 隣り合う2つのトークンのペアの出現頻度を数える
- 最も頻度の高いペアを1つの新しいトークンとして語彙に追加する(これがmergeルール)
- 学習データ中の該当ペアをすべて新トークンに置き換える
- 語彙が目標サイズ(例: 50,257)に達するまで 3〜5 を繰り返す
具体例で見てみよう。
学習データに "low lower lowest" が含まれている場合はこんな感じになる。
初期状態: l o w l o w e r l o w e s t
l と o のペアが最も頻出なら、lo という新トークンが作られる。
merge 1: lo w lo w e r lo w e s t
次に lo と w のペアが最も頻出なら、low というトークンが作られる。
merge 2: low low e r low e s t
このようにして頻出するパターンほど1つのトークンにまとまっていくわけですね。
はい、拍手!
トークナイザの成果物
BPEの学習が終わるとmergeルールと語彙はファイルとしてモデルのプロジェクトに保存されます。GPT-2の場合、成果物は2つのテキストファイルだね。
-
merges.txt— mergeルールの一覧。優先度順に1行1ペアで記述される -
vocab.json— トークン文字列とIDの対応表
プロジェクトのディレクトリ構成はこんな感じになる。
models/gpt-2/
├── merges.txt # BPEのmergeルール(テキスト)
├── vocab.json # トークン → IDの対応表(JSON)
└── model.ckpt # モデルの重み
実際にどんな事が書いてあるか気になりますよね?いったいどんな高度な記述が飛び出てくるのやら......息を呑みつつ、それぞれの中身を見てみよう。
# merges.txt(GPT-2 実際のファイルより抜粋)
#version: 0.2
Ġ t
Ġ a
h e
i n
r e
o n
Ġt he
e r
...
// vocab.json(GPT-2 実際のファイルより抜粋)
{
"!": 0,
"\"": 1,
"#": 2,
"$": 3,
"%": 4,
"Ġthe": 262,
"Ġof": 286,
"Ġand": 290,
...
}
素敵なことにPythonで実際にトークナイズを試すこともできます。
from transformers import GPT2Tokenizer
tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
# 語彙サイズ
print(tokenizer.vocab_size) # 50257(語彙に含まれるトークンの総数)
# トークナイズの実行
text = "Hello world"
print(tokenizer.tokenize(text)) # ['Hello', 'Ġworld']
print(tokenizer.encode(text)) # [15496, 995]
はい。これだけ。
トークナイザの実体は、テキストファイル(merges.txt, vocab.json)と、それを読み込んで分割処理を行うコードの組み合わせでした。
ChatGPTって言ったって、別に超特別な不思議技術が使われているわけでもなんでもなく、所詮は僕らエンジニアが日常的に扱うファイルとプログラムの集合に過ぎません。
特殊トークン
さて、語彙の中には通常のテキストには現れない特殊トークンが含まれています。
これ単体が意味を持つ言葉として扱われるのではなく、モデルに対して「ここが入力の始まりです」「ここで生成を終えてください」といった指示を与えるための存在だね。
# GPT-2の特殊トークンを確認
print(tokenizer.eos_token) # '<|endoftext|>'
print(tokenizer.eos_token_id) # 50256
| トークン | 名称 | 役割 |
|---|---|---|
| endoftext | EOS(End of Sequence) | 文の終了を示す。モデルがこれを出力したら生成を停止する |
| padding | PAD(Padding) | 複数の入力の長さを揃えるための穴埋め |
さっきの「チャットレベルの流れ」で終了条件として「終了トークン」と書いたのがまさにこのEOSトークン。LLMが次のトークンとしてEOSを予測した時点で、応答の生成が終了するぞ。
推論時のトークナイズ
実際に推論をするときの話をしよう。といっても、さっき構築したmergeルールを優先度順に適用してテキストを分割していくだけなんだけどね。こんな感じです。
- 入力テキストをバイト列(初期語彙の単位)に分解する
- mergeルールを優先度の高い順(学習時に早く追加されたものから)に適用する
- 適用できるmergeがなくなったら終了
- 各トークンに対応するトークンIDを語彙テーブルから引く
トークンIDへの変換
トークナイズとやらをしたら結局どうなるの?何が完成形なのかっていうと、整数のトークンID列です。語彙テーブルはトークン文字列とIDの対応表で、これらはバチクソ単純なルックアップで変換される。
tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
text = "日本の首都はどこですか"
tokens = tokenizer.tokenize(text)
ids = tokenizer.encode(text)
print(tokens) # ['æ', 'ĺ', '¥', 'æ', '£', '¬', 'ã', 'ģ', '®', ...]
print(ids) # [46036, 25, 171, 120, 234, ...]
ここで、GPT-2は英語を主な学習データとしているので日本語は細かいバイト単位に分割されているのよね。わっかりづらいけど、これはGPT-2の語彙が英語中心に構築されているためであり、BPEの仕組み自体の限界ではないのでご安心を。GPT-2のことは嫌いになっても、BPEのことは嫌いにならないでください。
このトークンID列が、LLMモデル本体への入力となります。
トークンからベクトルへ
Embeddingテーブル
ここからニューラルネットの世界に入るぞ!数式もちょっと出てくる!かっこいい!
さて、晴れてLLM君にも読める言葉(整数)に加工することができたけど、さっきの工程で割り当てた数値はただの通し番号でしかない。その値の大小は何の意味も持たない。ID 15496("Hello")はID 995(" world")より大きいけれど、そこに言語的な意味はない。
ということで、そんな無味無臭の整数に意味という温もりを与えるのがこのステップ(さっき温もりを奪ったのに!?)。ただのIDを768次元(768次元!?)の意味的な特徴をもつEmbeddingベクトルに変換するのだ。
このEmbeddingという工程、一見意味不明だけど地図に例えるとわかりやすい。
都市名("東京", "大阪")はただのラベルだけど、緯度・経度の座標に変換すれば「東京と大阪は近い」「東京とロンドンは遠い」が計算できるね?
Embeddingはこれと同じ発想で、トークンを多次元の座標空間に配置します。768次元もあれば、「意味が近い」「品詞が同じ」「文脈的に置換可能」など、多様な関係性を座標上の距離として表現できそうよね。
数直線上(1次元)の関係よりタテヨコ平面(2次元)の関係の方が情報量が多いし、それに高さを加えた空間(3次元)で関係をとらえた方がリッチな情報だよね?768次元というと「なにそれ?ヤプール人とかいたりするの?」と、ギョッとするかもしれないけれど、要は「物差しは多い方が嬉しいよね」ってだけの話なのよ。
で、この変換の仕組みは単純で、語彙サイズ × d_model の2次元テーブル(行列)を用意し、トークンIDを行番号としてベクトルを引くだけです。
-
: Embeddingテーブル(行列)E -
: 語彙サイズ(GPT-2では50,257トークン種)V -
: Embeddingの次元数(GPT-2では768次元)d
GPT-2の場合、このテーブルは
import torch
import torch.nn as nn
# Embeddingテーブルの定義
embedding = nn.Embedding(num_embeddings=50257, embedding_dim=768)
# トークンID列 → ベクトル列
token_ids = torch.tensor([15496, 995]) # "Hello world"
vectors = embedding(token_ids) # 2トークン × 768次元の行列が返る
token_ids が2つの整数だったのに対し、vectors は2つの768次元ベクトルになりました。各ベクトルの値は学習によって決まり、意味的に近いトークンは近いベクトルを持つようになります。
つまり、各トークンは意味を持つことができたってこと。いや~めでたい。
位置エンコーディング
Embeddingにより、ひとつひとつのトークンは意味を持つことができたけど、まだ終わりではない。単にEmbeddingを施しただけではトークンの順序の情報が失われてしまうからです。
言語において語順は意味を決定する重要な要素。この情報がないととても困る。["猫", "が", "犬", "を", "追う"] と ["犬", "が", "猫", "を", "追う"] は同じEmbeddingベクトルの集合になってしまうわけ。困るでしょ。
そこで、位置エンコーディングという手法で各トークンの位置情報をベクトルに組み込みます。
学習済みPositional Embedding
元祖Transformer(2017年)やGPT-2では、位置ごとに固定のベクトルを学習し、トークンのEmbeddingに加算する。これをPositional Embeddingといいます。
何言ってんのかわかんねェよ!!はい。映画館の座席に例えます。同じ人(トークン)でも1番席に座るか10番席に座るかで周囲との関係が変わります。「誰が座っているか」+「どの席か」の両方の情報を足し合わせるのがこの手法。
数式で表すとこう。
-
: 位置x_i のトークンの最終的な入力ベクトルi -
: トークンIDから引いたEmbeddingベクトル\text{TokenEmbed}(token_i) -
: 位置\text{PosEmbed}(i) に対応する位置ベクトルi
# GPT-2の位置エンコーディング
pos_embedding = nn.Embedding(num_embeddings=1024, embedding_dim=768) # 最大1024位置
positions = torch.arange(len(token_ids)) # [0, 1]
x = embedding(token_ids) + pos_embedding(positions) # 2トークン × 768次元の行列
GPT-2での位置Embeddingテーブルのサイズは
RoPE(Rotary Position Embedding)
紹介しといてあれだけどPositional Embeddingとかもうオワコンです。
「え~!?まだPositional Embeddingやってんの~!?Positional Embeddingが許されるのは2021年4月までだよね~wwwwww」
道行くギャルにすらこんなことを言われる始末です。
現代のLLMでは、 RoPE(Rotary Position Embedding) というイケてる主流が用いられます。2021年4月頃に公開されたイケイケのやり方。
古典的手法がEmbeddingに位置ベクトルを「加算」するのに対し、RoPEは位置に応じてベクトルを「回転」させます。またしても意味わかんね~。回すだけの操作が合成(足し算)に勝てるわけないだろ!!はい。
時計の針をイメージしてください。12時の位置から始めて、トークンが1つ進むごとに針を一定角度だけ回します。2つのトークンがどれだけ離れているかは、針の角度差を見ればわかります。
この 「角度差で距離がわかる」性質こそがRoPEの核心です。 絶対位置(何番目か)ではなく相対位置(どれだけ離れているか)で表現するのです。
数学的には、Attention計算(次のセクションで解説)の中でQ(Query)とK(Key)のベクトルに回転行列を掛けることで2つのトークン間の相対的な距離が内積に自然に反映されるって寸法。
-
: 回転対象のベクトル(QまたはK)x -
: トークンの位置(0, 1, 2, ...)pos -
: 位置R(pos) に応じた回転行列pos
「で?これの何がイケてるの?」にお答えします。RoPEの利点は以下の通り。
- 相対位置を自然に扱える — 2トークン間の距離が内積に直接反映される
- 外挿性 — 学習時に見ていない長さの入力にもある程度対応できる
- 追加パラメータが不要 — 位置Embeddingテーブルを持たず、回転角度は数式で決まる
鬼門!!Transformerデコーダ
前セクションで、各トークンはEmbedding+位置エンコーディングによって768次元のベクトルに生まれ変わり、「自分がどんな意味を持つのか」という情報を手に入れ、LLMくんはそれを基に意味を理解することができるようになった。
しかしここで八木に電流走る──────。
「トークン同士の相関が、わからねェ!!」
「彼はキャッチャーとして打席に立った」のうち「キャッチャー」のみに着目しても、それが野球の捕手なのかUFOキャッチャーなのかLLMくんにはちんぷんかんぷんなのである。
Transformerデコーダは、文脈の統合を担う中核部分。各トークンが「自分以外の全トークンに対して『お前、俺とどれくらい関係ある?』と聞いて回り、関係が深いやつの情報を多めに、薄いやつの情報を少なめに取り込む」という処理をさせる。
ここがLLMを理解する上での最大の難所です。
長いうえに意味不明な言葉がたくさん出てくるぞ!うれしい!
さて、GPT-2では同じ構造のデコーダブロックが12層積み重なっており、各ブロックは以下の2つの処理で構成されます。
- Attention — 他のトークンの情報を集める
- Feed-Forward Network(FFN) — 集めた情報を変換・加工する
Attention(Multi-Head Attention)
Attentionってなぁ~んだ?
Attentionを一言で表すと 「各トークンが、他のすべてのトークンに対して"どれくらい注目すべきか"を計算し、注目度に応じて情報を集める」 という仕組みです。
会議を想像してください。ぽまいらが発言をまとめるとき、全参加者の全発言を一言一句均等に聞いてまとめるのではなく、今の話題に関連する発言に重点を置いて聞くよな?Attentionはこれと同じことを数値的に行います。オタクは発言をまとめる役なんかやらないって?口答えするんじゃない!!
ただし、「注目すべきかどうか」の観点は1つとは限りません。「彼女は昨日銀行で口座を開いた」という文で「銀行」に注目するとき、構文的には「開いた」との述語関係が重要だし、意味的には「口座」との共起関係が重要です。
1つの観点からは関連が薄く見えるトークン同士でも別の観点から見れば強い関連があり得ます。
ある1つの観点からは「こいつらは相性悪いな」という風に見えても、見方を変えれば「いや、こいつらはデキてる」となるかもしれない。本当は俺くんに思いを寄せてるツンデレヒロインを見て 「この二人は"ない"ね(ニチュア......)」 と切り捨てるなんてもったいなさすぎる。うどみょんはあります。同じコマに写ってたらカップルなんだよ。
そこで、実際のAttentionでは、複数のヘッドが異なる観点から同時に注目度を計算します。GPT-2では12個のヘッドが並列に動き、それぞれが異なる関係性を捉えます。これをMulti-Head Attentionと呼びます。
入力射影
一気に数学の話が増えるけど、しんどかったら雰囲気で読んでOKだぞ!
ここから、あるトークン(厳密には、ある系列内の位置インデックス)
ここで
さて、着目トークン
-
Q(Query) — 「何の情報を探しているか」を表す。着目トークン
から生成される\mathbf{x}_i -
K(Key) — 「どんな情報を持っているか」を表す。比較対象
から生成される\mathbf{x}_j -
V(Value) — 「実際の情報の中身」を表す。同じく比較対象
から生成される\mathbf{x}_j
つまり、Qは「自分が探しているもの」K, Vは「相手が提供できるものとその中身」という関係。
-
: 位置\mathbf{x}_i, \mathbf{x}_j ,i のトークンの入力ベクトル(それぞれ768次元)j -
: ヘッドW_Q^{(h)}, W_K^{(h)}, W_V^{(h)} の学習済み重み行列(それぞれ768 × 64次元)h -
: ヘッド\mathbf{q}_i^{(h)} における位置h のQueryベクトル(64次元)i -
: ヘッド\mathbf{k}_j^{(h)}, \mathbf{v}_j^{(h)} における位置h のKey・Valueベクトル(64次元)j
ここで、ヘッドごとに異なる重み行列を持つことこそがマルチヘッドの核心。同じ入力でもヘッドによって異なるQ, K, Vが生成されるため、各ヘッドが異なる観点で注目度を計算できるんだね。見づらいと思うので以降の数式では特に明示すべき場合を除いてヘッドの上付き ぶっちゃけめんどい。
Attentionスコアの導出
あるヘッドの中で、位置
-
: 位置\alpha_{ij} のトークンから位置i のトークンへの注目度スコア(生の値)j -
: 位置\mathbf{q}_i のQueryベクトル(64次元/ヘッド)i -
: 位置\mathbf{k}_j のKeyベクトル(64次元/ヘッド)j -
: スケーリング係数(\sqrt{d_k} = 64次元/ヘッド)。内積の値が大きくなりすぎることを防ぐd_k
LLMのデコーダでは 因果マスク(causal mask) が適用されるため、位置
でてきたスコアをsoftmaxで正規化し、0〜1の重みに変換します。
でかい数字って扱いづらいでしょ?0〜1の範囲に納めるといろいろ都合がいいのよ。
-
: 正規化後の注目度(重み)。w_{ij} \sum_{j=0}^{i} w_{ij} = 1
加重和
さて、重みを得られたので、こいつを使って各位置のV(情報の中身)を重み付き平均する。注目度が高いトークンの情報が多く取り込まれ、低いトークンの情報はほとんど反映されないぞ。漏れたちはほとんど反映されないってこと。涙拭けよ。漏れたちは、"ダチ"だろ?
-
: ヘッド\mathbf{z}_i^{(h)} における位置h の出力ベクトル(64次元)i -
: 位置w_{ij} への注目度j -
: 位置\mathbf{v}_j のValueベクトル(64次元/ヘッド)j
これが1つのヘッドの処理です。12個のヘッドがそれぞれ異なる重み行列
結合と線形変換
12個のヘッドの出力(それぞれ64次元)を結合して768次元に戻し、出力用の重み行列
-
: 12個のヘッド出力を結合(768次元)\text{Concat}(\dots) -
: 出力用の重み行列(768 × 768次元)W_O -
: 位置\mathbf{o}_i のAttention最終出力(768次元)i
実際にはこの計算は全トークンについて行列演算で一括処理される。
PyTorch使いのお前らならば余裕で歌えるね。 ← 初音ミクさんもこうおっしゃっていることですし......
import torch.nn.functional as F
n_heads = 12
d_head = 64 # 768次元 ÷ 12ヘッド = 64次元/ヘッド
# 1. 入力射影:全トークンのQ, K, Vを一括生成し、12ヘッドに分割
Q = x @ W_Q # (トークン数, 768次元) → 12ヘッド × (トークン数, 64次元)
K = x @ W_K
V = x @ W_V
# 2. 各ヘッドでAttentionスコアを計算(因果マスク適用)
scores = (Q @ K.transpose(-2, -1)) / (d_head ** 0.5) # (トークン数, トークン数)
scores = scores.masked_fill(causal_mask, float('-inf')) # 未来の位置を-∞にする
weights = F.softmax(scores, dim=-1)
# 3. 加重和
head_output = weights @ V # 各ヘッド: (トークン数, 64次元)
# 4. 12ヘッドを結合して線形変換
concat = torch.cat(all_heads, dim=-1) # (トークン数, 768次元)
output = concat @ W_O # (トークン数, 768次元)
Feed-Forward Network(FFN)
Attentionでトークン間の情報交換を行った後、各トークンの表現は「他のトークンからの情報を取り込んだ」状態になりますがまだ内部的な変換が不十分。FFN (Feed-Forward Network) がこれを補い、各トークンの表現を 個別に 非線形変換して最終的な表現に仕上げます (トークン間の情報交換はしない)。だから意味わかんねぇって!!はい。
Attentionがトークン間の情報を集める「横方向」の処理だとすれば、FFNは各トークンの情報を個別に変換・加工して意味を深める「縦方向」の処理って感じ。
「練習はおにぎり!チームワーク!仲間との絆!お前はどけぇ!すまん手が滑った(笑)」も「エーゴエゴエゴ!俺のゴールで勝つエゴよ~!」も、どっちもイイよね。って話をしています。本当にそんな話してましたか?多分そう。部分的にそう。
で、そんなFFNの構造はシンプルな2層のニューラルネットワークです。
-
: Attentionの出力ベクトル(768次元)x -
: 第1層の重み行列(768 × 3,072次元)。ベクトルを4倍の次元に拡大するW_1 -
: 第2層の重み行列(3,072 × 768次元)。元の次元に戻すW_2 -
: バイアス項b_1, b_2 -
: 活性化関数(非線形変換)\text{GELU}
なんで一度3,072次元に拡大してから768次元に戻すんだよ。はい。次元を広げることでより複雑なパターンを表現できる「作業スペース」が生まれるからです。それを元の次元に圧縮することで重要な特徴だけが残るって寸法。
# FFNの計算
W1 = ... # (768次元, 3072次元)
W2 = ... # (3072次元, 768次元)
hidden = F.gelu(x @ W1 + b1) # (トークン数, 3072次元) — 拡大
output = hidden @ W2 + b2 # (トークン数, 768次元) — 元に戻す
残差接続とLayerNorm
実は、デコーダブロックではAttentionとFFNのそれぞれの後に残差接続とLayerNormが適用されています。後出しじゃんけんですまんが許してね。説明入れるタイミングがむずいんだわ。
残差接続ってのは、処理の入力をそのまま出力に足し合わせる仕組みのこと。
12層もの処理を通すと元の情報が徐々に失われたり、学習が不安定になるリスクが出てくる。残差接続によって元の情報に差分を足す形にすれば、各層は改善すべき差分だけを学習すればよくなるんだね。お得だ......
LayerNormは、ベクトルの各要素の値を正規化する(平均0、分散1に近づける)処理のこと。
-
: 入力ベクトル(768次元)\mathbf{x} -
:\mu の全要素の平均値\mathbf{x} -
:\sigma^2 の全要素の分散\mathbf{x} -
: ゼロ除算を防ぐための微小値(例:\epsilon )10^{-5} -
: 学習可能なスケール・シフトパラメータ(それぞれ768次元)\gamma, \beta -
: 要素ごとの積\odot
層が深くなるにつれてベクトルの値が極端に大きくなったり小さくなったりすることを防ぎ、学習を安定させてくる縁の下の力持ちだぞ!
で、残差接続とLayerNormを合わせるとデコーダブロック内の処理は以下のようになります。
層の積み重ね
GPT-2では上記のデコーダブロック(Attention → 残差接続+LayerNorm → FFN → 残差接続+LayerNorm)が12層積み重なっています。お察しの通り、えげつない計算量です。GPU万歳。
ここで入力も出力もトークン数 × 768次元で形が変わらないことに注目!各層は同じ形のデータを受け取り、同じ形のデータを返します。中身だけが層を通るごとに洗練されていくってワケ。
また、どうも浅い層では構文や品詞といった表面的な特徴を、深い層では意味や文脈の理解といった抽象的な特徴を捉えているっぽいことが研究で示されているぞ。
KVキャッシュ
なぜキャッシュが必要か
Attention計算では、各トークンが他のすべてのトークンとの注目度スコアを計算する。つまり系列長が
さて「チャットレベルの流れ」で見た通り、LLMは自己回帰的に1トークンずつ生成しますね。これを素朴に実装すると、1トークン生成するたびに系列全体のAttention計算を最初からやり直すことになる。となるとどうなるか、
しかしよくよく考えてみよう。「俺馬鹿だからわかんねェけどよォ。いちいち全部のK, Vを再計算する必要なんかないンじゃねェか?」 ということに気付けると思います。
例えば「日本の首都は」(5トークン)の次のトークンを予測する場合、Attentionは全5トークンのK, Vを使って計算します。次に「東京」が生成されたら、今度は「日本の首都は東京」(6トークン)の次を予測しますが、このとき最初の5トークンのK, Vは前回とまったく同じ値ですよね? 因果マスクによって過去のトークンが未来のトークンから影響を受けないため、同じ入力からは常に同じK, Vが得られる。
毎回すべてのトークンについてK, Vを再計算するのはとんでもない無駄です。そこで、一度計算したK, Vをメモリ上に保持しておいて、新しいトークンのK, Vだけを追加する仕組みがKVキャッシュ。あきれるほど合理的な発想ですな。
自己回帰ループでの動作
では、KVキャッシュを使った自己回帰ループの流れを具体的に見てみましょう。
ステップ1:初回(プロンプト入力)
入力「日本の首都は」(5トークン)すべてのK, Vを計算し、キャッシュに格納する。
K_cache = [k_0, k_1, k_2, k_3, k_4] # 5トークン分
V_cache = [v_0, v_1, v_2, v_3, v_4]
このステップでは当然、5トークン分のAttention計算が必要です。仕方なし。
ステップ2:1トークン目の生成
新しいトークン「東京」のK, Vだけを計算し、キャッシュに追加。
K_cache = [k_0, k_1, k_2, k_3, k_4, k_5] # 1つ追加
V_cache = [v_0, v_1, v_2, v_3, v_4, v_5]
新しいトークンのQとキャッシュ全体のK, Vだけを使えば過去トークンのQ計算も、過去トークン同士のAttentionスコア計算も不要だね。サイコー!!
ステップ3以降: これを繰り返すのみ。毎ステップ、新しいトークン1つ分のK, Vをキャッシュに追加するだけ。
メモリ消費の計算例
KVキャッシュは計算を節約する代わりにメモリを消費する。計算対象を記憶しておくことで解決しているわけだからね。避けられぬトレードオフ。逃れられぬカルマ。
GPT-2で具体的に計算してみよう。
1トークンあたりのKVキャッシュサイズはこうなる。
-
: KとVの2種類2 -
: デコーダ層数(12層)n_{\text{layers}} -
: 隠れ層次元数(768次元)d_{\text{model}} -
: 1つの浮動小数点数のサイズ(float32で4バイト)\text{sizeof(float)}
GPT-2の最大系列長1,024トークンではこんな感じ。
GPT-2の規模では72MBと控えめだけど、現代のLLMではこの数字が大きく膨らんでいくぞ。
| モデル | 層数 | 次元数 | 最大系列長 | KVキャッシュ(float16) |
|---|---|---|---|---|
| GPT-2 | 12層 | 768次元 | 1,024トークン | 36 MB |
| Llama 3 8B | 32層 | 4,096次元 | 8,192トークン | 4 GB |
| Llama 3 70B | 80層 | 8,192次元 | 8,192トークン | 20 GB |
Attentionの発展
本記事で解説したAttentionは全てのトークンペアのスコアを計算するので
GQA(Grouped Query Attention)
通常のマルチヘッドAttentionでは各ヘッドが独自のK, Vを持つわけだけど、GQAでは複数のQueryヘッドでK, Vヘッドを共有することでKVキャッシュのサイズを削減する。例えばLlama 3 8Bでは32個のQueryヘッドに対してKVヘッドは8個であり、キャッシュサイズは単純なマルチヘッドAttentionの1/4になります。すげー!
もちろんマルチヘッドAttentionと同じ精度が出るわけじゃないけれど、Attention計算の仕組み自体は変わらないので品質への影響を抑えつつメモリを節約できる超実用的な手法。これマジでイイです。
DeltaNet(Gated DeltaNet)
Attentionの
DeltaNetはKVキャッシュの代わりに固定サイズのメモリ行列を使い、計算量を
当然、系列長がどれだけ長くなってもメモリ消費が一定でなので長文処理に向いています。ただし、固定サイズの状態に圧縮する都合、遠方の細かい情報は劣化するのはご愛嬌。
Hybrid Attention
softmax Attentionの精度とDeltaNet等の線形Attentionの効率を両立させるため、両者を組み合わせるHybrid Attention構成も実用化されています。例えばQwen3では、3層の線形Attention(Gated DeltaNet)に対して1層のsoftmax Attentionを配置する3:1構成が採用されており、長いコンテキストを効率的に扱いつつ高い品質を維持している。
正直筆者程度の技術力だと 「なるほど完璧な作戦っスねーっ!不可能だという点に目をつぶればよぉ~~!!!!」 って感じなんだけど、なんか上手くいっています。Qwenすげぇよマジで......
出力の生成
Logitsと確率分布
Transformerデコーダの12層を通過した結果、各トークンの768次元ベクトルは文脈情報を十分に織り込んだ状態になっているね。しかしこの768次元ベクトルのままでは「次のトークンは何か」を決められないという難点がある。
つまりどういうことだってばよ。「日本の首都は」という入力に対して、次に来るトークンの候補は語彙に含まれる50,257トークン種すべてですよね。「東京」かもしれないし「京都」かもしれないし「どこ」かもしれない。モデルは768次元ベクトルを、50,257個の候補それぞれに対する「次に来そうな度合い」のスコアに変換する必要があるってこと。
具体的には、最後のトークン位置のベクトルに語彙サイズ分の重み行列を掛けて50,257次元のベクトルを得る。この生のスコアをlogitsと呼ぶぞ!
-
: デコーダ最終層の出力ベクトル(768次元)\mathbf{h} -
: 語彙射影の重み行列(768 × 50,257次元)W_{\text{vocab}} -
: バイアス項\mathbf{b} -
: logitsベクトル(50,257次元)。各要素が語彙中の1トークンに対応する\mathbf{l}
logitsは正の値にも負の値にもなり得る生のスコア。このままだと扱いづらいったらありゃしないので、これをsoftmax関数で0〜1の確率に変換します。
-
: トークンP(token_k) が次に来る確率k -
: トークンl_k のlogit値k -
: 語彙サイズ(50,257トークン種)V
import torch.nn.functional as F
# デコーダの最終出力からlogitsを計算
logits = h @ W_vocab + b # (50257次元)
# softmaxで確率分布に変換
probs = F.softmax(logits, dim=-1) # (50257次元) 全要素の合計が1
# 例: 最も確率が高いトークン
top_token_id = torch.argmax(probs).item()
print(tokenizer.decode([top_token_id]))
この時点で50,257個のトークンそれぞれに「次のトークンとしてどれくらいありそうか」の確率が割り当てられている。
サンプリング戦略
確率分布が得られたら、そこから次のトークンを1つ選ぶ。この時、いろいろな選び方がある。
そこら辺の手法を練るのをサンプリング戦略(デコーディング戦略)と呼びます。
Greedy(貪欲法)
最も確率が高いトークンをそのまま選びます。シンプルイズベスト。
決定的で再現性があるのが貪欲法の良いところなんだけど、常に最も「無難な」選択をするため生成されるテキストが単調になりがちです。人間様はなァ!話し相手が機械的だと寂しいンだわ!!
Temperature
ということで人間っぽい揺らぎを与える時にこれを使う。父さん、僕はtemperatureを調整して綾波を笑わせます。 具体的にはsoftmaxの計算前にlogitsを定数
-
: temperature パラメータT -
: 分布が鋭くなる(高確率のトークンがより支配的に)。より確実で保守的な出力T < 1 -
: 元の分布のままT = 1 -
: 分布が平坦になる(低確率のトークンも選ばれやすくなる)。より多様で創造的な出力T > 1
Top-k サンプリング
確率上位
Top-p サンプリング(Nucleus Sampling)
確率の高い順にトークンを足していき、累積確率が
トークンからテキストへ
デトークナイズ
サンプリングによって次のトークンIDが1つ決まりましたね。このトークンIDをテキストに戻す処理がデトークナイズです。俺らは機械語わかんねェんだわ!!
仕組みはトークナイズの逆で、語彙テーブルからトークンIDに対応する文字列を引くだけ。
# サンプリングで得られたトークンID
next_token_id = 16490 # 例
# デトークナイズ: IDから文字列に変換
next_token_str = tokenizer.decode([next_token_id])
print(next_token_str) # "東京" など
複数のトークンIDをまとめてデコードすることもできます。
token_ids = [15496, 995] # "Hello world"
text = tokenizer.decode(token_ids)
print(text) # "Hello world"
これ、BPEによるサブワード分割の逆変換なのでトークン境界と単語境界は一致しないことがあります。例えば "playing" が ["play", "ing"] の2トークンに分割されていた場合、デコード時に結合されて元の単語に戻る。
自己回帰ループの全体像
最後に、ここまでの全セクションの内容を1つの自己回帰ループとしてまとめる。
総集編だぞ。喜べよ。
from transformers import GPT2Tokenizer, GPT2LMHeadModel
import torch
# モデルとトークナイザの読み込み
tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
model = GPT2LMHeadModel.from_pretrained("gpt2")
model.eval()
# 入力テキスト
prompt = "日本の首都は"
input_ids = tokenizer.encode(prompt, return_tensors="pt") # トークナイズ
# 自己回帰ループ
max_new_tokens = 20
generated = input_ids
for _ in range(max_new_tokens):
with torch.no_grad():
outputs = model(generated) # Embedding → デコーダ12層 → logits
logits = outputs.logits[:, -1, :] # 最後のトークン位置のlogits (50257次元)
# サンプリング(ここではGreedy)
next_token = torch.argmax(logits, dim=-1, keepdim=True)
# EOS(終了トークン)なら停止
if next_token.item() == tokenizer.eos_token_id:
break
# 生成トークンを入力に追加して次のループへ
generated = torch.cat([generated, next_token], dim=-1)
# デトークナイズ
output_text = tokenizer.decode(generated[0])
print(output_text)
この短いコードの中で、本記事で解説したすべてのステップが動いています。
なんか難しいことをガチャガチャ説明してきたけれど、実装したらこんなもんです。
-
tokenizer.encode— テキストからトークンへ(BPE) -
model(generated)— Embedding → 位置エンコーディング → Transformerデコーダ12層(Attention + FFN)→ logits -
torch.argmax— サンプリング(確率分布から次のトークンを選択) -
tokenizer.eos_token_idとの比較 — EOS(特殊トークン)による終了判定 -
torch.cat— KVキャッシュを使わない素朴な実装では、生成済みトークンを入力に追加して再計算 -
tokenizer.decode— トークンからテキストへ(デトークナイズ)
おわりとそれから
はい。晴れて、をまいらは長ったらしい上にサムイ駄文を読み終えました。お疲れ様。
改めて、はじめに述べた通り、LLMくんは魔法の道具でも究極汎用知能でも神様でもありませんでしたね。テキストが数値に変換され、ベクトルとして演算され、確率分布から次のトークンが選ばれ、再びテキストに戻る......この一連のデータ変換パイプラインこそが、私たちが日常的に使っている "チャッピー" の正体です。
今回紹介したのは基本的な推論アーキテクチャに過ぎません。最新のテクニカルな手法や、LLMを活用する際のさまざまな拡張、推論と対をなす重要事項、"学習"の詳細、エトセトラエトセトラ。学ぶべきことはまだまだあったりします。
そこらへんの話もぼちぼち記事にできたらと思います。
俺たちの戦いはこれからだ!! 先生の次回作にご期待ください。
これまた改めて、本記事が、AIを学びたい方々の理解の一助になれば幸いです。
さようなら。
Discussion