Skip to content

マルチモーダル入口(パッチ化)

実装: llm/patch/ / 実行: go test ./llm/patch/

Transformer は入力の種類を区別せず、トークン列になっていれば何でも処理できる。画像の標準は ViT のパッチ化で、画像を 16×16 ピクセルの格子に切り、各パッチを 1 トークンに線形射影する。テキストの BPE に対応する画像側のトークナイザだ。この章ではパッチ化と射影を実装し、パッチの細かさがトークン数、つまり計算コストを決めることを確かめる。

この章で作るもの

画像も音声も扱えるモデルが取っている道は、突き詰めると1つだ。何であれトークン列に変換して、同じ Transformer に流し込む。テキストは BPEトークナイザでトークンにした。残るは、画像をどうトークンにするかだ。この章はその画像側の入口を作る。

答えは意外なほど単純で、画像を格子状の小さなパッチに切り、各パッチを 1 つのトークンとして扱う。これが ViT(Vision Transformer、2020)の方式で、論文の題名 "An Image is Worth 16x16 Words" がそのまま中身を表す。16×16 ピクセルのパッチ 1 枚が、1 単語に相当するトークンになる。畳み込みニューラルネット(CNN)で画像を処理してきた歴史に対し、「画像も Transformer でいい」を示した転換点だった。

画像 224×224          パッチ 16×16 に切る       各パッチ → 1トークン
┌───────────┐        ┌──┬──┬──┬──┐           [P1][P2][P3]...[P196]
│           │   →    │P1│P2│P3│..│    →      各パッチを平坦化して
│           │        ├──┼──┼──┼──┤           線形射影 + 位置埋め込み
└───────────┘        └──┴──┴──┴──┘           = テキストと同じトークン列
                     14×14 = 196 パッチ
ViT のパッチ化。画像を 16×16 の格子に切り、各パッチを平坦化して線形射影で 1 トークンにする。あとはテキストと同じトークン列として Transformer に流せる

順に見ていく。

  1. 画像を格子に切る: size×size のパッチに切り、各パッチを平坦化する。これがトークンの単位
  2. 線形射影でトークンに: 平坦化パッチを dModel 次元に写し、位置埋め込みを足す。テキストの埋め込み表と同じ役割
  3. パッチの細かさ = トークン数 = コスト: 細かく切るほどトークンが増え、attention の n² が効く。マルチモーダルの費用はここで決まる

① パッチ化: 画像を格子に切る

まず画像を正方パッチに切り、各パッチを行優先で平坦化する:

go

// Patchify は画像(行×列の 2 次元)を size×size の正方パッチに切り、
// 各パッチを行優先で平坦化したベクトルの列にする。
// 画像の縦横が size で割り切れる前提。
func Patchify(img [][]float64, size int) ([][]float64, error) {
	if len(img) == 0 || len(img[0]) == 0 {
		return nil, errors.New("patch: empty image")
	}
	h, w := len(img), len(img[0])
	if size <= 0 || h%size != 0 || w%size != 0 {
		return nil, errors.New("patch: image dimensions must be divisible by patch size")
	}
	var patches [][]float64
	for pr := 0; pr < h; pr += size {
		for pc := 0; pc < w; pc += size {
			patch := make([]float64, 0, size*size)
			for r := pr; r < pr+size; r++ {
				for c := pc; c < pc+size; c++ {
					patch = append(patch, img[r][c])
				}
			}
			patches = append(patches, patch)
		}
	}
	return patches, nil
}

// NumPatches は sizeXsize パッチで縦横 dim の画像を切ったときのパッチ数。
// 224×224 を 16×16 で切ると (224/16)² = 196。トークン数の見積もりに使う。
func NumPatches(dim, size int) int {
	n := dim / size
	return n * n
}

テストでは 4×4 画像を 2×2 パッチに切り、左上パッチが [0,1,10,11](行 0,1 × 列 0,1)、右下が [22,23,32,33] になることを固定した。切り方は決定的で、パッチの並び順(左上から右へ、行ごとに下へ)がそのままトークンの並び順になる。

NumPatches はトークン数の見積もりで、224×224 を 16×16 で切ると 196 になる。この 196 という数が重要で、画像 1 枚が 196 トークンを消費するということだ。キャパシティ見積もり と同じで、この数がコストを決める。

② トークンへの射影

平坦化したパッチはまだ生のピクセル値の列だ。これを Transformer が扱う dModel 次元のトークンに射影し、位置埋め込みを足す:

go

// Projector は平坦化したパッチを dModel 次元のトークンへ線形射影し、
// 位置埋め込みを足す。テキストの埋め込み表に対応する画像側の入口。
type Projector struct {
	weight [][]float64 // (patchDim × dModel)
	dModel int
	seed   uint64
}

// NewProjector は patchDim 次元のパッチを dModel 次元へ写す射影を作る。
// 重みは決定的な擬似乱数。
func NewProjector(patchDim, dModel int, seed uint64) *Projector {
	w := make([][]float64, patchDim)
	s := seed*6364136223846793005 + 1442695040888963407
	for i := range w {
		w[i] = make([]float64, dModel)
		for j := range w[i] {
			s = s*6364136223846793005 + 1442695040888963407
			w[i][j] = float64(s>>40)/float64(1<<24) - 0.5
		}
	}
	return &Projector{weight: w, dModel: dModel, seed: seed}
}

// ToTokens は各パッチを線形射影し、位置埋め込みを足したトークン列を返す。
// 位置埋め込みにより、同じ内容のパッチでも位置が違えば別トークンになる。
func (p *Projector) ToTokens(patches [][]float64) [][]float64 {
	tokens := make([][]float64, len(patches))
	for i, patch := range patches {
		tok := make([]float64, p.dModel)
		for j := 0; j < p.dModel; j++ {
			sum := 0.0
			for k := range patch {
				sum += patch[k] * p.weight[k][j]
			}
			tok[j] = sum + p.positionEmb(i, j)
		}
		tokens[i] = tok
	}
	return tokens
}

// positionEmb は位置 i の次元 j の位置埋め込み(決定的な小さな値)。
func (p *Projector) positionEmb(i, j int) float64 {
	s := (uint64(i+1)*911 + uint64(j+1)*2749 + p.seed) * 6364136223846793005
	return float64(s>>40)/float64(1<<24)*0.2 - 0.1
}

テキストの BPE がトークン ID を埋め込み表で引いてベクトルにしたのに対し、画像はパッチを線形射影でベクトルにする。入口の形は違うが、出てくるのはどちらも dModel 次元のトークンで、以降は区別されない。

位置埋め込みも要点だ。rope で見たとおり attention は順序を見ないので、パッチがどの位置にあったかを教える必要がある。テストでは、同じ内容のパッチでも位置が違えば別トークンになることを固定した。画像では 2 次元の位置(行と列)を表す必要があり、実物は 2 次元の位置埋め込みや、画像向けに拡張した RoPE を使う。

こうして画像が 196 個のトークンになれば、あとはテキストと同じ Transformer ブロックに流せる。キャプションのテキストトークンと画像のパッチトークンを 1 本の列に並べれば、attention が両者の間に注目を張り、「この単語」と「その物体が写ったパッチ」が直接つながる。これがネイティブマルチモーダルと呼ばれる作りの中身で、製品としての姿はGeminiの系譜で見る。

③ トークン数がコストを決める

パッチの細かさは、精度とコストの交換になる。テストで固定したとおり、16×16 パッチが 196 トークンなのに対し、8×8 パッチにすると 4 倍の 784 トークンになる。細かく切れば細部を捉えられるが、Transformer全体像 の attention は n² なので計算は 16 倍に膨れる。

動画になるとこれが跳ね上がる。30fps × 60 秒の動画をフレームごとにパッチ化すると、196 × 1800 = 35 万トークンになる。Gemini の 100 万トークン窓が動画 1 本を扱えるのは、この数を収められるからで、逆に言えば長い動画がすぐ窓を食い潰す理由でもある。マルチモーダルの費用は、このトークン数の会計で決まる。だから実物はフレームを間引いたり、パッチを粗くしたり、attention変種SSM で n² を抑えたりする。

動かす

下のデモは 2 つの見方を用意した。「パッチ化」は画像を格子に切り、各パッチが 1 トークンになる様子と、パッチサイズを変えるとトークン数が変わることを見る。「トークン会計」は画像・動画のトークン数を計算し、パッチの細かさや動画の長さがコストをどう押し上げるかを確かめられる。

デモマルチモーダル入口(パッチ化)16 トークン
パッチ化トークン会計2×24×4
→ 16 トークン →
P1P2P3P4P5P6P7P8P9P10P11P12P13P14P15P16

8×8 画像を 2×2 パッチに切ると 16 個のパッチ = 16 トークン。各パッチを平坦化して線形射影すれば、あとはテキストと同じトークン列。パッチを細かくするほどトークンが増える

画像を格子パッチに切り、各パッチを線形射影すれば、テキストと同じトークン列になる。 あとは Transformer が種類を区別せず処理する。パッチの細かさと動画の長さがトークン数を決め、 それがそのままマルチモーダルの計算コスト(attention は n²)になる。

設計の観点

  • パッチサイズの交換: 小さいパッチは細部に強いがトークンが増えて計算が n² で膨らむ。大きいパッチは軽いが細部を潰す。16×16 が定番の落としどころ
  • CNN との対比: CNN は局所の畳み込みを積んで階層的に特徴を作る。ViT はパッチにして全パッチ間の attention で大域を一気に見る。データが多いと ViT が勝ち、少ないと CNN の帰納バイアスが効く
  • 2 次元の位置: テキストは 1 次元だが画像は行と列の 2 次元。位置埋め込みもそれに合わせる必要があり、動画は時間軸も加わって 3 次元になる
  • トークン数がコスト: マルチモーダルの費用見積もりは「何トークンになるか」の一点で決まる。画像 196、動画は フレーム × 196。キャパシティ見積もり と同じ会計
  • 他モダリティも同型: 音声は短い時間窓を符号(トークン)に量子化する。「切ってトークンにする」工程さえ用意すれば、どのモダリティも同じ Transformer に流せる

対照と実例

モダリティトークン化の方法1 単位のトークン数
テキストBPE サブワード1 単語 ≈ 1〜2
画像パッチ化(ViT)224² を 16² で 196
音声時間窓を離散符号に量子化1 秒 ≈ 数十
動画フレームを画像として + 時間並べフレーム数 × 画像

裏どり:

簡略化したこと

  • 1 チャネル(グレースケール): 実物は RGB 3 チャネルで patchDim = 16×16×3 = 768
  • 位置埋め込みは固定値: 実物は学習する。1 次元の連番で近似し、2 次元位置は本文で言及
  • [CLS] トークンなし: ViT の分類用トークンは扱わない
  • 音声・動画は実装せず: トークン数の会計として概念的に扱った

参考資料