技術約15分で読めます

RunPodの4090でQwen3-4BをLoRA学習してブログ専用のAI文体校正器を作る

いけさん目次

前回、自分のブログの修正履歴からAI文体検出器を作った。
ModernBERT-ja-130mの分類器は、人間が後から「スロップだった」と認めた文の6割を、誤検出ゼロで拾えた。
残り4割は「条件文で一般化した帰結」のような、文末が個別の事実か一般論かを見分ける意味判定の領域で、130Mの二値分類では判定できなかった。

今回はその4割を小型LLMに任せる。
検出だけでなく、スロップ文を自然な日本語に書き換えた本文を返す校正器をQwen3-4BのLoRA学習で作る。

799件の修正ペアを教師データにする

前回集めたEditペアのうち、文体修正と判定された836件を書き換えの見本に使う。
そのまま使う前に、編集の連鎖を整理した。

前回のレビューで「Claudeの修正がまた別のスロップを産む」ケースが4件出ている。
履歴上は「元の文 → 微妙な置換文 → 再指摘後の最終文」という2段の編集になっていて、単純にペア化すると中間の微妙な置換文が「自然な文」側の教師に混ざる。
同一記事内で前段の修正後と後段の修正前が一致するペアをつなぎ、「元の文 → 最終文」だけを残した。チェーン20件をつないで中間形21件を除外し、799ペアになった。

もう1つ、過剰修正の抑制を入れた。
書き換え例だけで学習すると、問題のない文にも手を入れるモデルになる。
WordPress日記と公開済み記事のクリーンな段落を「そのまま返す」例として同数混ぜ、train 1,439 / val 159のチャット形式にした。

サイズは利用先で決める

校正器は手元の全マシンで動いてこそ意味がある。だから、いちばん非力な環境に載るサイズから逆算した。

サイズQ4量子化後RTX 4060 Laptop 8GBM4 Mac mini 16GB(共用)
Qwen3-1.7B約1.2GB動く動く
Qwen3-4B約2.6GB動く動く
Qwen3-8B約5GB文脈長次第で溢れる他プロセスと取り合いになる

本命は4B。タスクはこのブログの型40個弱の検出と書き換えに限られるので、サイズより教師データの質が効くと見込んだ。
1.7Bも同条件で学習して、どこまで小さくできるかを比較する。

学習はRunPodの4090で回す

前回のBERT学習は16GBのM4 Mac miniで回して、OOM 3連発とディスク満杯で学習より環境調整に時間を使った。
今回のLoRA学習をローカルの8GB GPUで再現すると同じ調整に余計な時間を取られる。RunPodで4090を借りれば量子化なしのbf16でバッチを積めて、この規模なら30〜60分・数百円で終わる。

以前のLoRA学習ではCPU Podで環境構築してからGPU Podに切り替える二段運用にしたが、今回は準備がpip数個とモデル直DLの10分程度なので、4090単段で立てる。

Podを立てる段階で、また前回と同じところで詰まった。
利用枠ありの表示なのにCommunity Cloud($0.34/h)はデプロイ不可で、Secure Cloud($0.69/h)にフォールバック。最近はCPU Podでも同じことが起きていて、Communityの在庫表示は当てにならなかった。

途中でPodが落ちても復帰できるよう、実行順は4B→1.7Bに固定し、ローカル側から2分おきにrsyncでチェックポイントを吸い出し続ける構成にした。
Podがいつ止まっても、手元には直近2分までの成果物が残る。

実行までに詰まった4箇所

学習コードを流す前に、環境まわりで4回落ちた。無人で回す前に潰しておく必要があった。

  1. trlのSFTTrainerがtorch 2.4で落ちる。テンプレートのPyTorch 2.4にはSFTTrainerが要求するDTensorが無く、初期化で止まる。torch 2.6.0+cu124に上げて解決した。
  2. torchを上げたらtransformersのimportが壊れた。torchvision/torchaudioが上げたtorchとバージョン不整合になり、BloomPreTrainedModelのModuleNotFoundErrorでtransformers全体が読めなくなる。今回の学習はvision/audioを使わないので両方アンインストールして回避した。
  3. Qwen/Qwen3-1.7B-Instructが存在しない。4BはQwen3-4B-Instruct-2507だが、1.7Bは素のQwen/Qwen3-1.7Bが正しいリポジトリID。命名が揃っていないので直DL時に404で気づいた。
  4. 成功マーカーを失敗時にも立てていた。無人運用のために「完了したらマーカーファイルをtouch」する設計にしていたが、コマンドの成否に関わらずtouchしていて、落ちてもSTAGE_ALL_DONEが立つ。成功時だけ立てる形に直した。無人で任せる前提だと、ここが一番危ない設計ミスだった。

学習結果

4B・1.7Bとも3エポックで、実働は合わせて20分ほど。Secure Cloudの4090が$0.69/hなので、準備込みでも$1に届かなかった。

モデル学習時間train_losseval_lossトークン精度LoRAサイズ
Qwen3-4B696.9s(11.6分)0.8930.72986.5%132MB
Qwen3-1.7B467.8s(7.8分)1.0210.80085.5%70MB

LoRAはr16・all-linear。4Bの方がlossもトークン精度も上だが、差は大きくない。数値だけ見ると1.7Bでも十分やれそうに見える。実際に書き換えを流すまでは…

量子化してローカルで動かす

配布用に4BのLoRAをベースモデルにマージし、llama.cppでGGUFに変換してQ4_K_Mに量子化した。f16で8.05GB、Q4_K_Mで2.3GB。狙い通り、8GB GPUの空きにも16GB Macの共用メモリにも収まるサイズになった。

M4 miniでllama-cli -ngl 99(Metalに全層オフロード)で動かすと、生成は約38トークン/秒。1文の校正なら待ちを感じない速度で、常駐させても他プロセスを圧迫しない。動かす分には設計目標を満たしている。RTX 4060 Laptop側は今回未検証だが、2.3GBなら8GBのVRAMに載る。

問題は速度でもサイズでもなく、その先の書き換え結果だった。

書き換え品質は数値ほど良くなかった

学習が終わったLoRAをM4 miniに落として、ベースモデル+アダプタをMPSに載せて実際に書き換えを流した。
入力は、レビューで実際に指摘されたスロップ文8つと、手を入れてほしくない自然文3つ。

1.7Bの出力はこうだった。

入力(スロップ)1.7Bの出力
ここで壁に当たった。ここで壁に当たった。(素通り)
だが、ここで一つの問題が浮かび上がる。同上(素通り)
つまり、データが物語っているのだ。同上(素通り)
この検証を通じて、多くの学びを得ることができた。同上(素通り)
結局のところ、重要なのはバランスなのだ。結局のところ、バランスが大切だ。
この経験は、今後の開発における試金石となるはずだ。同上(素通り)
モデルの挙動を注意深く観察することで、その本質が見えてくる。同上(素通り)

8文中、書き換えたのは1文だけ。しかも自然文側ではeval_lossを「評価損失」、tokenを「トークン」に和訳してきた。このブログは一般技術語をカタカナに開く一方でeval_lossのようなコード上の語は英字で残す方針なので、これは直す必要のない箇所を触った過剰修正であり、それ自体が新しいスロップになっている。

4Bは1.7Bより手を入れる回数は増えたが、質は変わらなかった。

入力(スロップ)4Bの出力
ここで壁に当たった。ここで壁にぶつかった。
この検証を通じて、多くの学びを得ることができた。この検証を通じて、多くの学びを得られた。
条件を整えれば、誰でも同じ結果に到達できるだろう。条件を整えれば、誰でも同じ結果にたどり着けるだろう。
この経験は、今後の開発における試金石となるはずだ。この経験は、今後の開発における試金石となる。
モデルの挙動を注意深く観察することで、その本質が見えてくる。同上(素通り)

「壁に当たった→ぶつかった」「得ることができた→得られた」「到達できる→たどり着ける」。動詞の言い換えは起きているが、肝心の「壁」「試金石」「データが物語っている」「本質が見えてくる」という比喩と評価総括は残ったままだ。4Bも自然文のeval_lossを「評価損失」に和訳する癖は同じ。

トークン精度86.5%と0.729のeval_lossは、次トークン予測としては悪くない。だが「次トークンをどれだけ当てるか」と「スロップを除去できるか」は別の話だった。教師にした書き換えペアは大半が小さな局所差分で、モデルはその「編集の形」(数文字の語尾・単語の入れ替え)を真似た。スロップを型として認識して除去する方向へは一般化できていない。恒等例を1:1で混ぜたことも、「なるべく変えない」方向に効いて、肝心のスロップまで手を入れさせなかった可能性がある。

学習前の素のQwenと比べる

ではLoRAは無意味だったのか。同じQwen3-4Bを、学習前の素の状態で同じ文に流して比べた。

原文(スロップ)素のQwenLoRA校正器
この経験は、今後の開発における試金石となるはずだ。今後の開発に役立つだろう試金石となる
だが、ここで一つの問題が浮かび上がる。問題が生じる素通り
つまり、データが物語っているのだ。データは物語っている素通り
結局のところ、重要なのはバランスなのだ。バランスだ素通り

素のQwenはLoRA校正器よりスロップそのものを消す。「試金石→役立つ」は比喩そのものを落としている。この時点ではファインチューニングが逆効果に見える。

だが自然文を流すと逆転する。

自然文(そのままが正)素のQwenLoRA校正器
…量子化してから読み込んだ。…量子化した後に読み込みを行ったほぼ素通り
…学習は 12 分弱で終わった。…学習は約12分で終了しましたほぼ素通り

素のQwenは、直す必要のない自然文まで丁寧語に書き換える。このブログのtechカテゴリは言い切りで丁寧語を使わないので、これは文体を壊す。LoRA校正器はこの丁寧語化を確実に止めている。

つまりファインチューニングは失敗ではなかった。「クリーンな文には手を出すな」「言い切りを保て」という抑制と文体は学習できている。問題は、その抑制が効きすぎてスロップ除去まで止めてしまったこと。素のQwenはスロップそのものを消す代わりにクリーン文を丁寧語で壊し、LoRAは壊さない代わりにスロップも残す。狙いはこの中間で、次に動かすのは学習の回し方より教師データの配分だ。

エンコーダ・校正器・Sonnetの3段を見直す

当初の構想は、エンコーダで検出し、この校正器で書き換え、残りをSonnetに任せる3段だった。
今回の結果だと、2段目の校正器にはまだ任せられない。

1段目の検出は使える。ModernBERT-ja-130mが、確定した見落としの6割を誤検出ゼロで拾う。2段目の書き換えはまだ早い。今回のLoRA校正器は比喩や評価総括の除去まで踏み込めず、直す必要のない英字を和訳する副作用も出た。3段目の意味判定は、2段目が信頼できない以上、当面Sonnetに寄せたままにする。

試しに1段目と2段目を実際につないでみた。検出器に先ほどのスロップ8文を通すと、6文を確率0.90〜1.00で検出し、比喩系の2文(「壁に当たった」「学びを得ることができた」)を見逃す。自然文3つは誤検出ゼロ。検出の性格は前回のまま出た。

問題は、その検出結果を校正器に渡すところだった。検出された文に「文体検出器がスロップと判定した。書き換えて本文だけ返す」という注記を付けて校正器に流すと、校正器は注記ごと出力に丸写しして、スロップ本体は素通りした。

[後] [文体検出器がスロップと判定(確率1.00)。該当箇所を書き換えて本文だけ返す]
だが、ここで一つの問題が浮かび上がる。

入力をなるべく変えずに返す方向に振れた校正器は、学習時に見たことのない指示注記も「返すべき本文」として扱う。推論時に検出タグを添えるだけでは効かない。

もう一段変えて、検出された文をまず素のQwenに書き換えさせ、その出力をLoRA校正器に通す二段重ねも試した。素Qwenが比喩を落とし(「見えてくる→理解できる」など)、その後段をLoRAが仕上げるフローだ。結果はこうなった。

LoRA仕上げはほぼ素通りで、素Qwenが消したスロップをだいたいそのまま通した。壊しはしない。ただし1件、素Qwenが「見えてくる→理解できる」と消した比喩を、LoRAが「見えてくる」に戻した。除去できないどころか、上流が消したスロップを元に戻すことすらある。しかもエンコーダがクリーン文を弾いて素Qwenに回さないので、LoRA唯一の強みである「クリーン文を丁寧語で壊さない抑制」はこの二段重ねでは出番がない。

実際に効いているのはエンコーダのゲートと素Qwenの2段で、あいだに挟んだLoRAは素通りか、上流が消した比喩を戻すだけだった。検出でクリーン文を守りつつ素Qwenに言い切りで書かせる方が、今の校正器を挟むより副作用が少ない。ただし全体はエンコーダの再現率6割に縛られ、見逃した比喩2文は素通りのまま。

ではLoRAを外して、素Qwenで書き換え → エンコーダで判定 → まだスロップなら素Qwenで再修正、という反復ループにしたらどこまで落とせるか。エンコーダを合否判定に使って、解消するまで回す設計だ。これが収束しなかった。

原文3周後の素Qwen出力エンコーダ
この経験は、…試金石となるはずだ。この経験は今後の開発に役立つ。p=1.00 残存
モデルの…本質が見えてくる。モデルの挙動を観察すれば、本質がわかる。p=1.00 残存
結局のところ、…バランスなのだ。重要なのはバランスだ。p=1.00 残存

素Qwenは毎周書き換えて、「試金石→役立つ」「見えてくる→わかる」と比喩は落ちている。人間の目にはかなり自然な言い切りだ。それでもエンコーダはp=1.00を維持し、ループは毎回3周を使い切って解消に至らない。

これは誤検出ではなかった。ではエンコーダは何を見ているのか。130Mの分類器が「意味の空虚さ」を理解しているとは思えない。そこで、同じ文の一部だけ変えて切り分けた。

同じ構文フレームのまま中身だけ差し替えると、判定がきれいに分かれる。

空虚具体的(フレームは同じ)
この経験は今後の開発に役立つ。 p=1.00この経験でMPSのメモリ上限が約20GBだと分かった。 p=0.00
重要なのはバランスだ。 p=1.00重要なのはbf16だとVRAMを24GB食う点だ。 p=0.00
データは物語っている。 p=1.00データは1.7Bのeval_lossが0.80だと示している。 p=0.00

フレームが完全に同じでも、中身を具体的にすると1.00から0.00へ振れる。表層の語尾や語ではなく、中身に反応しているのは確かだ。語尾を「試金石→役立つ→使える」と変えても主語が抽象なら1.00のまま、逆に「今回の設定はRTX4060で再利用できるはずだ」は同じ「はずだ」でも0.00。前文を空・具体・抽象と変えても対象文の判定はほぼ変わらない。効いているのは対象文自身の中身だけだ。

ただし「空虚さの検出器」と呼ぶと言い過ぎになる。具体的な情報を徐々に抜いていくと、「学習はスムーズに進み、結果も上々だった」という評価フワフワ文はp=0.00で通り、「結局、学習はうまくいったと言える」でようやく1.00に跳ねた。反応の境目は、総括・一般化のフレームと、数値・固有名・機序といった具体的な裏づけの不在が揃うところにある。エンコーダがやっているのは意味理解ではなく、「総括フレームで、かつ具体的な裏づけを持たない文」の検出だ。具体的な文にはトークンとして数字や固有名が入り、スロップ総括には入らない。130Mが拾えるのはそこまでで、そこで十分だった。

この型のスロップは、具体的な裏づけの欠如そのものが正体だ。だから文単体の言い換えでは消せない。「役立つ」を消すには、その文を削るか、何がどう役立ったかを具体的に書き足すしかない。文脈も手がかりもない1文だけ渡された書き換え器には、これは処理できない。

採点ルールを診断に使う

採点ルールが分かったなら、それを詰まり箇所の診断に回せる。1回書き換えてもスコアが変わらない文に、エンコーダのルールを明かして「なぜ具体性が欠けるか」を考えさせ、(A) 事実を捏造せず具体的に書き足せる / (B) この文だけでは無理で削除推奨、を選ばせた。

ルールの適用は5文とも正確だった。どれも「総括フレームで、数値・固有名・機序がないため該当」と正しく説明する。ルールさえ渡せばモデルは判定根拠を再現できる。

割れたのはA/Bの選択で、そこにこの型の難しさが出た。

判定結果
試金石となるはずだB削除推奨元の文脈に手がかりがないので具体化できない
重要なのはバランスB削除推奨一般化の文は具体化困難
本質が見えてくるB削除推奨観察データがないので書き直せない
データが物語っているA補う「COVID-19の感染拡大」を捏造。再検出も p=1.00
誰でも同じ結果に到達A補う「学習環境で成績向上」を捏造。再検出も p=1.00

3文はモデル自身が「この文だけでは直せない、削除すべき」と正しく判定した。残る2文はAで直そうとして、記事に存在しない事実(COVID-19、成績向上)を捏造した。しかもその捏造した中身でもエンコーダは p=1.00 のまま。付け足した情報が本物の裏づけになっていないからだ。

では、この埋めをファインチューニング校正器にやらせればブログの声で埋まるのか。試すと逆で、FT校正器は具体的に書き足すよう指示しても原文をそのまま返した(5文中4文が完全に同一)。捏造はしない代わりに、一切埋めない。FTの持ち味である「入力から離れるな」という抑制が、そのまま「でっち上げない=埋めない」に直結している。素Qwenは埋めるが捏造し、FTは捏造しないが埋めない。具体的な裏づけを足すのは、モデルを変えても自動化できなかった。

ここまでの実験には共通の穴があった。検出も書き換えも診断も埋めも、全部スロップ文を1文だけ切り出して渡していた。文中に具体的な中身がないのは当たり前で、その文が指す中身は前後の段落にある。1文ずつ渡すと直しにくいのは、以前のOCR校正でも書いたことだった。

渡す単位を文から段落に変える

そこで、総括スロップ文と、その根拠になる具体的な文が入った段落を、丸ごと素Qwenに渡した。

入力段落の末尾スロップ出力再スコア
…量子化で2.3GB・38tps。結局、重要なのはバランスなのだ。(p=1.00)総括文を削除し、具体的な2文を残すp=0.00
…1.7Bは8文中1文しか直さない。つまり、データが物語っているのだ。(p=0.96)総括文を削除し、具体的な文を残すp=0.00
…素Qwen/FTの対比。この経験は試金石となるはずだ。(p=1.00)「重要な参考になる」に言い換えただけp=1.00

捏造は一度も起きなかった。COVID-19のような無関係な作り話が消えたのは、具体的事実が同じ段落にあるからだ。モデルはでっち上げる必要がなく、「後ろの総括は前段の記述と重複している」と見て総括文を落とした。3件中2件がこれで解消し、エンコーダも p=0.00 で確認した。残る1件(試金石)は総括を残して語だけ替えたので、段落を渡しても万能ではない。

「裏づけのない型は自動修正できない」は誤りで、正しくは「1文で渡すと直せない」だった。文単体で渡すと、その文に本来ない中身をモデルが捏造するか、諦めて素通りするかに追い込まれる。段落で渡せば、モデルは周辺の記述を根拠にできて、冗長な総括を捏造なしで落とせる。校正器の粒度は文ではなく段落だ。削除フラグに回す設計は、段落でも直らなかった場合の次善策に格下げになる。

比喩や硬い漢語のような語レベルのスロップについては、原因は教師データの作り方に寄っている。生の差分をそのまま見本にするとモデルは差分の見た目を真似るので、次はペアを型ごとに整理して書き換えを教師にするか、検出タグを学習時の入力へ織り込む。そして評価総括型には、文ではなく段落を入力の単位にする。学習の回し方より、何を入力の単位にするかが効いた、というのが一連の実験の結論だ。