技術約9分で読めます

AnthropicのJacobian lensをQwen3-4B用に作って、「何もしない」を学んだ単一層SFT校正器の内部を読み出す

いけさん目次

前回、Qwen3-4Bの1層だけをフルランクSFTした文体校正器で妙な結果が出た。
eval_lossには論文と同じ層局在が出たのに、書き換えを実際に流すと深さ25%と50%の2本は「ほぼ何もしない」を学んでいて、深さ75%はほぼ素のQwenのままだった。
数字は変わったのに、機能は現れなかった。あの層の中では何が起きていたのか。

ちょうどAnthropicのJ-space論文を読んだところで、モデル内部の概念を読み出すJ-lensは実装が公開されている
今回はこれをQwen3-4B-Instruct-2507用にfitして、学習済みの層を差し込んだモデルの内部をベースと比べて読む。
レンズのfitはRunPodの4090、読み出しはM4 Mac mini(MPS)で行った。

レンズは自前でfitするしかない

J-lensは、中間層の残差ストリーム(各層が読み書きしながら次へ渡していくベクトル列)が最終層の表現に与える平均的な一次効果をヤコビアン行列 J_ℓ として推定し、その行列で座標変換してからモデル自身のunembeddingで語彙に落とす読み出し器。
仕組みの詳細は解説記事に書いたので、ここでは省く。

公開されている学習済みレンズ(neuronpedia/jacobian-lens)にはQwen3-4B用が既にある。
ただしそれは素のQwen3-4B用で、今回の校正器のベースであるInstruct-2507用ではない。
J_ℓ はモデルの重みそのものから推定されるので、別チェックポイントには使い回せない。

fitのコストは1プロンプトあたりforward 1回と、backward(逆伝播)が ceil(d_model / dim_batch) 回。
d_model=2560のQwen3-4Bをdim_batch=8で回すと、1プロンプトにつきbackward 320回になる。
M4 miniで実測したら1プロンプト636秒だった。リポジトリのREADMEはwikitext系100本で実用水準としているので、このままだと約18時間。メイン機を丸一日塞ぐことになるため、RunPodに出した。

RunPodの4090でfitする

Podの運用は前回のSFTと同じで、bootstrapスクリプト1本を送り込んで環境構築からfitまで無人で回し、ローカルから2分おきに成果物を吸い出す。
ライブラリも前回の組(torch 2.6.0+cu124固定、transformers 5.13.0)をそのまま使った。

最初の1回はdim_batch=32で投げてOOMで落ちた。
fitは全35層分の計算グラフを保持したままbackwardを繰り返すので、モデル本体の8GBに加えてグラフが約14GB乗り、24GBに収まらない。
dim_batch=16に落とすと17.3GBで安定した。backwardの総計算量はdim_batchを変えても同じ設計になっている。

もう1つの詰まりは回収側で、今回のテンプレート(RunPod PyTorch 2.4.0)にはrsyncが入っていなかった。apt-getで足すだけだが、吸い出しスクリプトはエラーを出さずに空振りし続ける。

fit本体は1プロンプト30〜33秒、100本で51分。
時間の都合でPodを途中で止める可能性があったため、25本ごとに再開チェックポイントから中間レンズを保存する構成にした。どの時点で止めても、その時点までのレンズが成果物として残る。
レンズはfp16で1本438MB。Pod代はOOMのやり直しと環境構築込みで$1前後だった。

まず既知の現象が再現するか確かめる

fitしたレンズが正しく動いているかを、論文の看板現象で確認する。
「Fact: The currency used in the country shaped like a boot is」を流すと、プロンプトにも出力にも出てこないItalyが中間層で浮上してくる。

Italyの順位Euroの順位
L123998657
L2428792
L302914
L3374

最終的な出力は「the Euro」へ続く。答えを言う前に、答えの根拠になる未発話の概念(ブーツの形の国=イタリア)が中間層に載っている。

なお、Qwenでは読み出しの上位5件が「?」「…」のような記号トークンに占拠される。
これはリポジトリ側も既知としていて、可視化時には語らしいトークンへのマスクを持っている。なので観測は、生の上位表示ではなく特定トークンの順位追跡で行う。

日本語も試した。「事実: 富士山がある国の通貨は」で、JapanがL18で23位、L30では日本が4位、yenが1位。
今回の本題はすべて日本語のスロップ文なので、日本語入力でも中間層の概念読み出しが機能する確認を先に取れた。

差し込みモデルの読み方

単一層SFTの成果物は学習した1層分のstate_dictなので、前回の評価と同じく、ベースモデルをロードしたまま該当層だけをメモリ上で差し込む。

J_ℓ は層ℓから最終層までの重みで決まるので、層kを1つ差し替えたモデルでは、k以上の層の J_ℓ は変わらない。
つまりベースでfitしたレンズが、学習層より上の読み出しには差し込みモデルでもそのまま厳密に使える。全層が変わるLoRAではこうはいかない(前回のLoRAベースラインを今回の観測から外したのはこのため)。

flowchart TD
    A[ベースのQwen3-4B-Instruct-2507] --> B[wikitext 100本でJ-lensをfit<br/>RunPod 4090で51分]
    A --> C[学習済み層のstate_dictを差し込み<br/>depth-25 / depth-50 / depth-75]
    C --> D[同じ書き換えプロンプトで<br/>全層の残差を記録]
    A --> E[ベースでも同じ残差を記録]
    D --> F[cos類似度とΔhの語彙射影で比較<br/>学習層より上はベースのレンズが厳密]
    E --> F

入力は前回と同じ11文(スロップ8文+自然文3文)を、同じ校正器プロンプトで流す。
ベースと差し込みモデルの残差のcos類似度を層ごとに測り、差分ベクトル Δh(差し込みモデルの残差からベースの残差を引いたもの)をレンズで語彙へ射影して、摂動が何を言う方向を向いているのかを読んだ。

差し込み後の挙動は生成でも確かめた。4構成の出力は前回記事の結果と一致した。

depth-75は内部もほぼ変わっていなかった

まず一番シンプルな結果から。26層目を学習したdepth-75は、内部がほぼ無風だった。
摂動の相対ノルム(Δhのノルムをベースの残差のノルムで割ったもの)は0.06〜0.08しかなく、cos類似度は11文すべてで全層0.994以上。
Δhを語彙へ射影しても記号の羅列で、意味のある方向を向いていない。

前回「ほぼ素のQwenと同じ振る舞い」だったdepth-75は、内部もほぼ素のQwenだった。
eval_lossはdepth-25と同水準まで下がっていたのに、表現はほとんど変わっていない。

depth-25の「何もしない」は激変の上に成り立っていた

こちらが本題。9層目を学習したdepth-25は、出力こそスロップ8文中7文をそのまま返すのに、内部は大きく変わっていた。

「試金石」の文(前回、全構成の出力が割れた入力)でのcos類似度の推移はこうなった。

depth-25depth-50depth-75
L90.9921.0001.000
L150.9051.0001.000
L210.8840.9491.000
L270.9330.9490.997
L300.9650.9660.998
L340.9700.9720.998

depth-25の摂動は学習した9層目の直後は小さく(cos 0.992)、中間層で膨らんでL21で底を打ち(0.884)、最終層に向けて0.97まで戻る。摂動の相対ノルムは中間層で最大0.47。この形は11文すべてで同じだった。
出力が同じでも、途中の表現は2割近く別物になっている。

では、その摂動はどっちを向いているのか。
Δhを学習層より上の層で語彙に射影すると、上位層で入力自身の内容語が出てきた。

入力ΔhのL30/L33射影の上位
ここで壁に当たった。wall、壁、Wall
つまり、データが物語っているのだ。therefore、thus、hence
この検証を通じて、多くの学びを〜verification、validation
結局のところ、重要なのは〜finally、conclusion、結
この経験は、〜試金石となるはずだ。experience、experiences
モデルの挙動を注意深く〜model、models
条件を整えれば、誰でも〜requisite、prerequisites
4Bの方がlossも〜loss、bigger
量子化はf16で8.05GB〜quant、量化、float、FP
eval_lossは0.729で〜validation、evaluation、loss

11文中10文でこのパターンになる(残る1文「浮かび上がる」だけは明瞭な内容語が出なかった)。
壁の文ではwall、つまりの文ではtherefore、量子化の文ではquantと、摂動の向きが入力ごとに変わり、しかもほぼ英語側の概念で出る。
日本語入力の計算が英語の共有表現を経由する形は、J-space論文が中国語の実験で見せた言語共有ワークスペースと同じだった。

摂動が入力の内容概念をワークスペースに書き戻す方向を向いているということは、depth-25の「何もしない」は表現の無変化ではない。
入力をそのまま保持し直す方向の、学習された積極的な書き込みとして実装されていた。

位置の分布にも偏りがあった。
スロップの目印になる語(試金石、物語、観察など)のトークン位置でのΔhは相対ノルムが文末の半分以下で、射影しても解釈できる語が出ない。
書き込みは、応答の最初のトークンを決める文末の位置に集中している。

depth-50は否定の方向から同じ「何もしない」に行き着く

18層目のdepth-50は、前回の評価で11文すべての出力がdepth-25と完全一致した2本目の「何もしない」層。
内部は別物だった。

L21〜L27のΔhの射影は、nothing、not、no、only、maybe、perhapsのような否定と保留の語彙を指した。11文中10文で同じクラスタが出た。
入力の内容語のエコーは、depth-25より弱く、その上の層(L33付近)にずれて現れる。

同じ「何もしない」でも、depth-25が入力概念の保持し直しで実現しているのに対し、depth-50は「直すものが何もない」と読める方向の書き込みで実現している。
前回のeval_loss局在は、この2つの別々の内部変化と、depth-75の無変化を、同じような数字として拾っていたことになる。

何もしなくても教師データの半分は正解になる

このSFTの正解は教師が用意した出力文で、そこにどれだけ近い文を返せたかだけが学習に反映される。
自然文を判断して原文のまま返しても、スロップに気づかずスルーしても、返した文が同じなら同じ正解になる。この2つの違いは学習に反映されない。

そして教師データの半分は、過剰修正を抑えるために入れた「自然文を入れたら原文をそのまま返す」ペアになっている。
つまり、スロップかどうかを判断しなくても、教師データの半分は正解できてしまう。

depth-25は入力を保持して書き戻す方向、depth-50は「直すものがない」と判断する方向で、どちらも「そのまま返す」の実装になっている。
スロップの書き換えは、LoRAでも、深さを変えた単一層SFTの3本でも学習されなかった。原因はモデルの容量ではなく、そのまま返すだけで半分正解できるデータ構成にある。

教師データ側は、「ここで壁に当たった。」は直して「ここでビルドが失敗した。」は直さない、のような、形が近くて正解が逆のペアを増やす。そのまま返す動作が正解にならなくなり、スロップ語を見た判断が必要になる。
誤差側は、文全体の一致ではなく修正箇所の重みを大きくする。たとえば30トークンの文でスロップの言い換えが2トークンなら、丸写しでも28トークンは正解になり、肝心の2トークンを外しても誤差はわずかしか増えない。修正した箇所だけ重く数えれば、この不釣り合いがなくなる。

ただ、SFTをやり直す以外の組み方もある。
素のQwenはスロップ8文を8文とも書き換えていた。そのかわり自然文まで書き換える。書き換える能力は最初からベースにあって、「どこを直してどこを残すか」の判断だけができていない。これまでの学習は、この判断まで同じ重みに学習させようとして、毎回書き換えのほうごと止めてしまった。
判断と書き換えを分けて、判断は検出器と今回観測した「直すものがない」方向の書き込みに持たせ、両者が食い違う文だけ大きいモデルや人間に回す。書き換えは素のQwenに任せる。
この書き込みが本当に判断の役割を果たしているかどうかは、depth-50からこの方向を除去したときにスルーが書き換えに変わるか、素のQwenに注入したときに書き換えが止まるかで確かめられる。この2つが起きるなら、この校正器はSFTなしで、素のQwenにゲートのベクトルを1本足すだけで組める。