技術約11分で読めます

ModernBERT-jaで週次学習している文体検出器が四重に壊れていた

いけさん目次

会話履歴とWordPress日記から学習データを集めて作った文体検出器のエンコーダは、毎週日曜の朝に自動で再学習させている。ただこの再学習、事故ったり検出が悪かったりで毎度テコ入れしているので、その話をまとめとこうと思う。

検証環境

項目内容
マシンM4 Mac mini(学習はCPU、1周15〜20分)
モデルsbintuitions/modernbert-ja-130m
学習データtrain 5,484〜8,050文(採用構成は7,002文)
評価固定回帰セット352文 + 直近の承認済み記事への発火率

MPSは他プロセスと共有プールを奪い合ってOSに落とされたことがあるので、学習には使っていない。

仕組み

学習データはこのブログの編集履歴を使用している。自分で確認した履歴からコーパスを作れるので、より人間に近いか近くないかを検出できるだろう、という予想でやってて、AI生成テキストをそのまま学習データに使ったら意味がないので。流れは次の図にまとめた。

graph TD
    A[会話履歴からEditペアを収集] --> B[Opusでstyle / content / mixedに分類]
    B --> C[データセット構築<br/>正例 = styleペアの修正前の文]
    D[人間が書いた日記455文] --> C
    E[過去記事の非検出文<br/>+ 直近の承認済み記事] --> C
    C --> F[ModernBERT-ja-130mを再学習]
    F --> G{昇格ゲート}
    G -->|3条件クリア| H[新モデルに差し替え]
    G -->|どれか欠けたら| I[前モデルへ自動ロールバック]

データの内訳は図の通り。正例(ダメ出しされて直される前の文=スロップの見本)に対して、負例(「これは自然な文」と教える側の見本)は直された後の文と承認済み記事の文で、人間側の負例には、昔WordPressに書いていた手書き日記の455文を使っている。確認済みのスロップ文302件と人間日記の温存50文は、goldと呼ぶ固定回帰セットとして学習から外してある。

このgoldは重み更新とエポック選択には使わない一方、週次の昇格判定(学習し直したエンコーダを実際に使う側に差し替えるかどうかの判定)と後述のスイープには繰り返し使っている。未知の記事にどれだけ効くかを一度だけ測定する独立テストではなく、既知の退行(合っている文を違うと出したり、違う文をスルーしたりと、検出が前より悪くなること)を止めるための回帰テストという位置づけになる。

昇格ゲートは3条件で、人間の日記50文への誤検出がゼロであること、gold recall(確定スロップをどれだけ検出できたか)が前回を下回らないこと、ファイル更新時刻順で直近の承認済み記事3本への発火率が15%以下であること。どれか欠けたら前のモデルに自動で戻す。発火率を測定する3記事は、今回追加した直近記事の負例から除外してあり、学習に使った文をそのまま採点する構造にはしていない。過去に「承認済み記事のほぼ全文に発火する」退行モデルを昇格させてしまった事故があり、同じことになると面倒なので。

壊れていたもの1 cronからのOpus分類が一度も成功していなかった

まず再学習ログを確認すると、直近の日曜の実行に「分類バッチ失敗 21件」と残っていた。収集したEditペアのstyle / content / mixed分類はOpusをclaude CLI経由で叩いているのだが、これが21バッチ全部、空のエラーで死んでいた。

過去ログを遡ると前々回の日曜も5バッチ全滅。つまりcron起動での分類は一度も成功していなかった。原因はmacOSのkeychainで、cronのセッションでは未解錠のためclaude CLIの認証が通らない。同じスクリプトを対話セッションから手で叩いたら21バッチ全部がエラーなしで通った。

対処はcrontabをやめてlaunchdのLaunchAgentに載せ替えた。LaunchAgentはログイン中ユーザーのコンテキストで動く。Appleの仕様では、StartCalendarIntervalをスリープ中に過ぎたジョブも起床時に実行される。動作させている環境では、plistのロードまでは確認済みだが、載せ替え後の日曜定期実行はまだ一度も走っていない。keychain経由の分類まで通るかは次回の定期ログで確認する。

未分類のまま学習から除外されていたペアは806件あった。手動で分類を回し直すと6割がstyle判定で、正例がそのぶん増えた。この時点では「これで次の学習はだいぶ良くなるだろう」と思っていた。

壊れていたもの2 昇格ゲートをすり抜ける退行

806件を入れて回した再学習は、gold recall 0.434 / 人間文への誤検出1件で、誤検出ゼロ条件に引っかかって自動見送りになった。ここまではゲートの設計通りに動いている。

その後の試行で、負例を増やした構成で学習したモデルが、recall 0.192まで悪化しているのにゲートを正規に通過して昇格した。誤検出0、発火率2%、全条件クリア。

仕組みは単純で、ゲートには検出しすぎを弾く条件しかなく、検出しなさすぎを弾く下限をつけてなかった。ほとんど何も検出しないモデルは誤検出も発火もしないので、全条件を満点で通る。さらにgoldセットが260件から352件に増えた週はrecallの前回比較が成立しないため基準がリセットされ、recallの崩壊を止めるものが何もなかった。

昇格後のモデルに既知のスロップ文4つを読ませたら、4つともp=0.00の自然な文判定だった。「結論から言うと、この方法で全て解決した。」を自然な文と判定する検出器である。当然役に立たないので、手動でロールバックし、recall基準も実測値に設定し直した。

壊れていたもの3 丸ごと書き換えが学習に使われていなかった

「エラー検出になった文は、コーパスが増えても過学習気味に誤検出し続けるのでは」という疑問から調べたら、分類の仕組み自体がそもそもおかしかった。

このブログの修正フローでは、細かい語句の修正のほかに、段落や記事を丸ごと書き直させることがある。人間が自分の言葉の方を正解として文章を渡してフルに入れ替えるパターンで、検出器にとっては一番濃い教師データのはずだった。それが実際にはほぼ全部捨てられていた。

理由は分類の仕組みにある。大きい置換は内容と文体が同時に変わるので、Opusがcontentかmixedと判定し、学習対象から外す設計になっていた。実測でペアのサイズ帯別にstyle判定率を出すと、300字未満が63%に対して、300〜1000字は10%、1000字超は4%。しかも分類プロンプトにはペアの先頭300字しか渡していなかったので、大きいペアは頭だけ見て判定されていた。

書き直し候補を負例に回したらrecallが崩壊した

捨てられていたペアの、修正したものの方を正解文と仮定し、負例候補に回してみた。最初の1パス版データセットには2,840文が入った。

まず見つかったのはラベル矛盾で、「AIが追記した文を一旦承認し、後日その文を文体修正する」という流れがあると、同じ文が正例側と負例側の両方に現れる。実測で298文が該当し、重複除去の処理順のせいで258文が「自然な文」側として学習データに含まれていた。人間が一度直した文を、わざわざ自然だと教えていた。これは正例を先に登録する2パス処理で解消した。

矛盾を直した2パス版では、content / mixedペア由来の負例は重複除去後2,570文になった。それでも完走したモデルのrecallは0.434から0.192へ崩壊し、上の「ゲートすり抜け退行」を起こした。もう一つの問題はデータ分布で、contentペアの修正したものの方は、狙っていた「人間の口語的な書き直し文」よりも、AIが書いて承認された整った記事文が圧倒的に多かった。スロップ正例と分布が近い文を負例へ大量に積んだこの構成では、判定がほとんど何も検出しない側へ偏った。

壊れていたもの4 最新記事への空白発火

データを直しながら再学習を繰り返していると、recall 0.685という歴代最高値を出すモデルができた。ただし発火率ゲートで測定すると、公開済みの最新記事1本に対して83文中34文に発火していた。挙がっていたのは「参照音声は5〜10秒あれば足りる。」のような普通の描写ばかりで、隠れたスロップを見つけたのではなく明確な過剰発火だった。

これも調べると教師データの分布に偏りがあった。承認済み記事の文を負例に取り込む工程が2ヶ月前から止まっていて、それ以降の記事の「きれいな文」が負例プールに1文も存在しなかった。一方でその記事の編集セッションから採れた修正前の文21件は正例に含まれている。モデルから見ると最新記事は「スロップの見本だけ21回見せられて、きれいな側の見本はゼロ」の記事になる。

この偏りを負例の空白とみて、発火率ゲートの3記事を除く6〜7月の承認済み記事から負例を追加したら、発火は34/83から19/83へ減った。ただし今度は人間日記への誤検出が1件から5件に増えた。この構成では、人間の崩れた口語断片側の負例データである日記455文が負例全体の9.2%まで下がっていた。誤検出した5件は「開始前の予想通りの大混雑。」のような言いさしの断片文ばかりだった。しかしAIに任せても結構断片的な文章を短く書いてきたりするんで、この辺の誤検出はかなり難しいかなあという感想。

三すくみをスイープで割る

整理すると、gold recall、人間文への誤検出、承認済み記事への発火率の3つは互いにトレードオフで、今回の試行ではどれか一つを改善すると別のどれかが悪化した。動かせるのは人間日記のオーバーサンプリング倍率と直近記事負例の上限数の2つで、組み合わせを5構成回した。

構成gold recall人間文誤検出最新記事発火3記事発火率
比較基準の旧モデル(v3)0.2351件1/83
日記×3 + 記事負例上限12000.2481件4/832%
日記×4 + 記事負例上限12000.2280件2/831%
日記×3 + 記事負例上限6000.2851件5/832%
日記×2 + 記事負例上限6000.4932件32/8320%
日記×2 + 記事負例上限12000.4301件11/839%

記事負例1200はサンプリング上限で、発火率ゲート対象の除外と重複除去を通した実投入は1,062文だった。結果を見比べると、日記の重みを上げた構成ほど誤検出と発火が収まり、そのぶんrecallが下がっている。日記×2のまま記事負例の上限を600に減らすと発火が20%に戻ったので、recallを保ちながら最新記事の負例不足を補うには上限1200の構成が要る。誤検出ゼロを出したのは日記×4だけだが、recallが比較基準の旧モデルを下回った。

採用したのは日記×2 + 記事負例上限1200。recallは比較基準の旧モデルの約1.8倍で、温存日記50文への誤検出1件は旧モデルと同一の1文(「という感じのまた役に立つかどうかわからない処理を。」という日記の言いさし)だった。この固定回帰セット上では、誤検出の件数も中身も増えていない。発火率9%はゲート内に収まる。ただし週次ゲートの誤検出0条件は変えず、今回はユーザー判断で手動採用した例外にした。そのぶん増えるのは候補ノイズで、最新記事1本あたり10件前後の普通の文が候補に挙がってくる。この検出器の出力はスキャン結果の末尾に参考候補として並ぶだけなので、流し読みして捨てられる量と判断した。この構成はデータセット構築の既定値に反映した。

各構成はデータ分割と学習のシードを42に固定して1回ずつ回した。別シードでの反復や信頼区間までは測定していないので、ここで決めたのはこのブログの固定回帰セットに対する運用上の設定であり、一般的な最適値ではない。このエンコーダーはあくまでこのブログの文体を整えるための検出精度の調整であって、世の中一般的なAIスロップを無くそう、というものではないからだ。

この記事自体を3モデルでスキャンする

最後に、ここまで書いたこの記事のドラフトそのものを、採用した新モデル、堅実枠だった日記×3 + 記事負例600の構成、旧モデルの3つでスキャンして突き合わせた。検出器の学習データについて書いた記事を、そのまま検出器のテストとして使おうという寸法だ。

コードブロックと図を除いた83文に対して、検出数は新モデル33文、旧モデル30文、堅実構成22文。どのモデルも通常の記事の3〜4倍の頻度で発火した。この記事は正例・負例・recallといった学習コーパス由来の語彙が高密度で、しかも実験結果を短い言い切りで締める文が多い。検出器が学んできたスロップの分布と、検出器について書いた文章の分布が、そもそも重なっている。

3モデルの検出が全部一致した文は13文あった。並べると傾向がはっきりしていて、「この記事はその点検と修理の記録。」のような体言止め、「仮説は当たった。」のような結果を短く言い切る文、それと数値と専門語が詰まった説明文に集中している。実験の節目をこの形で締める癖が実際にあって、全部一致の13文もほぼその形だった。この2文はドラフト時点の文で、公開前の文体チェックでも同じ箇所に指摘が入り、いま読んでいる稿では書き直されている。

逆に1モデルだけが挙げた文には構成ごとの差が出た。新モデルの単独検出は「対処はcrontabをやめてlaunchdのLaunchAgentに載せ替えた。」「採用したのは日記×2 + 記事負例1200。」のような対処と採用の宣言文が7件。旧モデルの単独検出は「学習はM4 Mac miniのローカルで回している。」のような冒頭の平叙文が同じく7件で、堅実構成の単独は4件と最も少なかった。同じコーパスから学習した3つでも、データ構成の違いで疑う言い回しがここまで分かれた。

引用の扱いにはスキャナ側の限界もあった。文分割が鉤括弧の中の句点でも切れるため、上の「壊れていたもの4」で過剰発火の例として引用した「参照音声は5〜10秒あれば足りる。」が、この記事のスキャンでも独立した1文として判定される。もとは別の公開記事にある普通の描写だが、新モデルと堅実構成は文脈から切り離されたこの文に今回も発火した(旧モデルは検出しなかった)。引用文を判定から外すには前処理を足すことになるが、そこまではやっていない。


実際問題エンコーダーは単純に検出をしているだけなので、どこまでやっても検出精度が100%になることはない。それは文脈によって人間が感じる「AIくせえな」と「こいつ文章下手だな」と「綺麗に纏まってるな」という感覚が理解できないからだ。

そのため、文体チェック自体も実際にはLLMに投げる処理も入れているのは前の記事でも紹介している。いろんな角度から検出してみて、総合的に判断しようという話だ。

総合的なので、当然AI的にはOKなのが重なった結果、「やっぱAIじゃねえか!」は残る。この検出は当然人間が読まないとわからない。

しかし読者ももしかしたらそうかもしれないが、ずっとAI文章を読んでいると、読み慣れてしまって「いや大丈夫だなこれ」という誤判断が発生する可能性がある。実際問題自分で書いてても「自分の文章なのにAIくさいなこれ」と思う瞬間が最近多い。

結局上に書いた通り「AIスロップ」か「人間の自然な文章」かの違いは感覚的なところが大きいと思うので、全人類AIに慣れてしまったら、そういう文章でみんな話し出して、AIも人間も判別できない時代が来るのかもしれない。