技術約7分で読めます

Qwen3-4Bの1層だけをRunPodの4090でフルランクSFTし、文体校正器を深さ25/50/75%とLoRAベースラインで比べる

いけさん目次

2026-07-08 追記: 続編として、AnthropicのJ-lensをQwen3-4Bでfitし、この記事で「ほぼ何もしない」を学んだ層の内部を観測した。「何もしない」の内側は無変化ではなかった → AnthropicのJacobian lensをQwen3-4B用に作って、「何もしない」を学んだ単一層SFT校正器の内部を読み出す

前回はRunPodの4090でQwen3-4BをLoRA学習して、ブログ専用の文体校正器を作った。
結果は「クリーン文を丁寧語で壊さない抑制は学んだが、その抑制が効きすぎて肝心のスロップまで素通りする」だった。

一方で先日読んだ論文(arXiv 2607.01232)は、RL訓練の利得が深さ40〜60%の中間層に局在していて、最良の1層だけの訓練が全パラメータ訓練を上回ることもあると言っている。
ただし論文が検証したのはRLだけで、SFTでの層局在は調べていない。

今回はこれを自分のタスクで追試する。
前回と同じ文体校正器のSFTを、LoRAの代わりに「1層だけ凍結解除してフルランク学習」に置き換え、深さを変えた3本とLoRAベースラインを同一データで比べる。
モデルは前回と同じQwen3-4B-Instruct-2507、学習はRunPodの4090。

設計は深さ25/50/75%の3点

論文の層貢献度スキャンは「層数と同じ回数の訓練を繰り返す」方法で、36層のQwen3-4Bでまともにやると学習36本になる。
知りたいのは「SFTでも中間層が効くのか」だけなので、深さ25%・50%・75%の3点に絞った。

ラン対象層学習パラメータ
depth-259層目/36層100.9M(全体の2.51%)
depth-5018層目/36層100.9M
depth-7526層目/36層100.9M
LoRAベースライン全層にr16・all-linear前回と同条件

50%点だけが勝てば、論文の中間層局在がSFTでも出たことになる。
3点が同程度なら、層の位置はSFTでは効いていない。
3点とも基線に負けたら、このタスクのSFTには1層のフルランクでは足りない。

凍結の実装は論文と同じ発想で、対象層以外の全パラメータに requires_grad=False を立てるだけ。
学習率はLoRAの1e-4からフルランク向けの1e-5に下げ、それ以外(3エポック・バッチ4×勾配累積4・max_length 1024・bf16)は前回と揃えた。

LoRAベースラインは前回の成果物を流用せず、同じデータでもう1本学習し直す。
教師データが増えてvalも変わったので、前回のeval_lossとは数字を直接比べられないため。

学習データを増やした

教師データは修正履歴マイニングの仕組みそのままで、直近の記事レビューで直させた分を追加し、文体修正ペアを799件から874件に増やした。
今回増えた分には、前回の校正器記事と論文解説記事のレビュー指摘も入っている。
校正器の実験記事への指摘が、次の校正器の教師データになる。

Claudeの修正が再指摘される2段編集を「元の文 → 最終文」につなぐ処理も前回のままで、今回は24チェーンをつないで中間形25件を除いた。
最終的な学習データはtrain 1,574 / val 174。書き換え例と恒等例の比率はほぼ1:1にしてある。

成果物はフルモデルではなく層だけ回収する

前回のLoRAアダプタは132MBだったが、今回はフルランクなので普通に保存するとチェックポイントごとに8GB吐かれる。
変わるのは1層だけなので、学習した層のstate_dictだけをtorch.saveする形にした。
100.9Mパラメータのbf16で約200MB。
ローカル側にマージスクリプトを置いて、ベースモデルに load_state_dict(strict=False) で差し込んでから量子化なり評価なりに回す。

エポックごとのチェックポイント保存も切った。
1ラン15分前後なので、Podが落ちたら回し直すほうが復旧より速い。
かわりに前回の教訓の「成功マーカーは成功時だけ立てる」はそのまま残して、ラン単位のDONEと全体のALL_DONEを分けた。

アップ前にM4 mini側でQwen3-0.6Bを使って4ステップだけ流し、凍結が効いていること、層state_dictの保存、マージの往復までを確認した。
環境構築からラン4本までをシェル1本にまとめ、scpしてnohupで叩いたら無人で完走する形にした。

flowchart TD
    A[ローカル: データとスクリプトをscp] --> B[環境構築<br/>torch 2.6.0+cu124固定]
    B --> C[モデルDL<br/>Pod側で直接取得]
    C --> D[depth-25 学習]
    D --> E[depth-50 学習]
    E --> F[depth-75 学習]
    F --> G[LoRAベースライン 学習]
    G --> H{全ラン成功?}
    H -->|はい| I[ALL_DONE]
    H -->|いいえ| J[HAD_FAILURES<br/>DONEの無いランのログを見る]

ローカルからは2分おきにrsyncで成果物ディレクトリを吸い出し続ける。
Podがいつ落ちても、ローカルには直近2分までの成果物が残る。これも前回のまま。

Podが立つまでに出た2つのエラー

Community Cloudの4090($0.35/h)は、在庫表示が出ているのに「This machine does not have the resources to deploy your pod」でデプロイできなかった。
これで3回連続なので、もうCommunityの在庫表示は当てにしていない。

Secure Cloud($0.70/h)では今度はコンテナが起動しなかった。

nvidia-container-cli: requirement error: unsatisfied condition: cuda>=12.8,
please update your driver to a newer version

テンプレートのRunPod PyTorch 2.8.0がCUDA 12.8ビルドで、割り当てられた機体のドライバが550.127.05(CUDA 12.4)までしか対応していない。
テンプレートをPyTorch 2.4.0に下げて回避した。

この影響でtorchはcu124系の上限である2.6.0に固定。
ただ、後続のpip installでtransformers等を入れると、依存解決が「もっと新しいtorch」としてPyPI既定のcu128ビルドを選び、先に入れたcu124を上書きすることがある。
2本目のpipにも torch==2.6.0 を並記して固定した。
組み合わせ(torch 2.6.0・transformers 5.13.0・trl 1.7.1)は、学習を流す前にpipの —dry-run で依存が衝突しないことを確認してある。

前回詰まった箇所の再発防止(torchvision/torchaudioのアンインストール、1.7Bを回す場合の正しいリポジトリID)もブートストラップに焼き込んだ。
アップロードしたのはデータ約1.6MBとスクリプト2本だけで、モデルDLはPod側で13ファイル6秒だった。

eval_lossには論文と同じ局在が出た

4本とも1ラン12〜15分で完走し、Podの起動から成果物回収まで含めて1時間強・$1未満だった。

ラン対象層eval_loss
depth-259層目/36層1.775
depth-5018層目/36層1.132
depth-7526層目/36層1.723
LoRAベースライン全層r160.760

深さ50%だけが明確に低く、両端の2本はほぼ同じ数字で並んだ。
論文がRLで報告した「貢献は深さ40〜60%帯の中間層に集中する」と同じ形が、SFTのeval_lossでも出たことになる。

一方で、最良の中間層(1.132)でも全層LoRA(0.760)には及ばなかった。
論文に無かったLoRA比較の答えは、少なくともこのタスクでは「1層のフルランクは全層LoRAに負ける」だった。

書き換えを実際に流すと数字と食い違った

前回と同じ検証をした。
レビューで指摘された型のスロップ8文と、手を入れてほしくない自然文3文を、素のQwen・深さ3点・LoRAベースラインの5構成に同条件で流す。
M4 mini側でベースモデルを1回だけロードし、各ランの層state_dictをメモリ上で差し込み・復元しながら生成した。

手を入れた数だけ見ると、5構成がきれいに割れた。

構成スロップ8文のうち書き換えた数自然文3文のうち壊した数
素のQwen83
depth-2510
depth-5010
depth-7582
LoRAベースライン22

depth-25とdepth-50は、11文すべてで出力が完全に一致した。
どちらも自然文3文は無傷で、スロップも8文中7文を素通しする。
前回のLoRA校正器で見た「抑制だけ学んで除去まで止める」が、単一層ではさらに強く出た形になる。

depth-75は逆で、ほぼ素のQwenと同じ振る舞いをした。
スロップ8文全部に手を入れる代わりに、自然文の「loss」を「損失」に和訳し、「量子化はf16で8.05GBだった」を「量子化を施すと8.05GBになる」に書き換える。
eval_lossはdepth-25とほぼ同じなのに、片方は「何もしない」を学び、もう片方はほぼ何も学ばずベースのままだった。

LoRAベースラインには前回と同じ過剰修正が再発した。
自然文の「eval_loss」を「評価損失」に、「loss」を「損失」に和訳する。
コード上の語を英字のまま残すというこのブログの方針に対しては、教師データを75ペア増やしても直らなかった。

1文だけ、全構成の差が見える入力があった。
「この経験は、今後の開発における試金石となるはずだ。」への出力が、構成ごとに分かれた。

構成出力
depth-25 / depth-50この経験は、今後の開発に役立つはずだ。
depth-75この経験は今後の開発に役立つだろう。
LoRAベースラインこの経験は、今後の開発における試金石となる。

単一層の3本は「試金石」という比喩そのものを落とし、LoRAベースラインは比喩を残して文末の「はずだ」だけ削った。
唯一手を入れた文に限れば、単一層のほうが比喩の除去に踏み込んでいる。

書き換えの質のほうは、学習手法や層の選び方を変えても変わらなかった。
生の差分ペアを見本にする教師データの作り方と、文単位で渡す入力の粒度という前回の結論は、SFTの形を変えた今回もそのままだった。