技術約11分で読めます

Claude Codeの会話履歴とWordPress日記からAI文体検出器の学習データを集める

いけさん目次

このブログの技術記事は自分、Claude、Codexなどいろんな状況で書かれている。 公開前に文体チェックを回しているが、それでもいわゆるAIスロップのような表記が怪しい箇所は毎回修正しきれていない。
判定基準は用意してあるが、AI任せで書かせるとどれだけ注意させてもやはりAIの文章になってしまい、修正すべき箇所もスルーしてしまう。

だったら検出だけを小型のエンコーダモデルに学習させて、LLMスキャンの前段に置けばいいのではないか。
文単位の分類はBERT系の得意分野で、生成もいらない。

何文あれば学習できるか

エンコーダの教師ありファインチューニングは、二値分類でもスロップ文1千文規模から。
手元の検証済みコーパス(自分が指摘して確定した見落とし文)は21件しかない。

次の候補は、記事を公開した時のgitコミットだった。
ドラフトを公開する時のリライト差分には、指摘前のスロップ文と修正後の文がペアで残る。
ところが数えてみると、公開処理のコミット213件のうち既存記事を30行以上書き換えているものは21件だけ。
しかも差分の中身は内容の増補やリンク追加が混ざっていて、文体修正はそのうち4割程度だった。
クリーンなペアはせいぜい150〜300文で、1桁足りない。

会話履歴からコーパスを作る

Claude Codeは会話を ~/.claude/projects/ 配下にJSONLで保存している。
記事の修正はEditツールで行われるので、old_string(修正前)と new_string(修正後)がツール呼び出しごと履歴に残っている。
つまり「指摘 → 修正」のペアは、gitに乗る前の段階で記録されている。

# 会話履歴JSONLからEditツール呼び出しを抽出する骨子
for b in record["message"]["content"]:
    if b.get("type") == "tool_use" and b.get("name") == "Edit":
        inp = b["input"]
        if "src/content/articles" in inp["file_path"]:
            pairs.append((inp["old_string"], inp["new_string"]))

2台のPCで回した結果。

マシンEditペア対象記事数
Mac mini82350
MacBook Pro73717
合計1,56067

git差分から取れる数の5倍以上が会話履歴に残っている。
全部が文体修正ではなく内容修正やリンク追記も混ざる。文体修正の比率は後で振り分けて確かめる。

ただしClaude Codeの履歴は初期値では30日で自動削除される。
今回取れたのは直近1ヶ月分だけで、それ以前の修正履歴はもう存在しない。
設定の cleanupPeriodDays を365に変えて、今後の分は保全するようにした。

ついでに、手打ちメッセージから文体への指摘箇所も36件抽出した。
「自動詞とか表現のワードが全体的にAIくさい」のような人間の読後感での指摘で、照合したら直近分はすべて検出ルールに変換済みだった。
指摘がルール化から漏れていないかを逆方向から確かめられた。

公開済み記事622本を一括スキャンする

文体チェックスキルは運用しながら検出パターンを増やしてきたので、導入初期の記事は弱いチェックしか通っていない。
今の定義で読み直して、未検出のスロップ文をスロップ候補として拾い出す。

Claude Codeのヘッドレスモード(claude -p --model sonnet)に、検出定義3ファイルと行番号付きの記事本文を1プロンプトで渡すバッチを書いた。
1記事あたり約51秒、対象は2026年6月より前のtech記事622本。

検出だけを行い、記事本文は書き換えない。
出力はSonnetの判定そのままの未検証データなので、自分で確認済みのコーパスとは置き場所を分けた。

204記事のところでプランの利用枠を使い切り、以降の418記事は空のエラーで流れていた。
スクリプトがエラー結果もファイルに書いて「処理済み」扱いにするバグも見つかったので、エラーは保存せず、連続5回失敗したら中断、1時間後に自動で再実行するループに直した。

完走後の確定値は検出7,208件、ユニーク文7,133、strong 2,493。
検出0件は622記事中15記事で、何でもかんでも拾うわけではない。

カテゴリ分布は「条件文で一般化した帰結に着地する」が単独23%で1位。
この型は数日前に自分がClaudeに指摘して検出定義に足させたばかりのもので、過去記事全体で見てもClaudeの一番の書き癖だったことが数字で出た。
2位以下は「英字専門用語の残り」17%、「評価の動詞で締める」12%と続き、上位5型で67%。

人間クラスはWordPress時代の日記

「確実に人間が書いた文」をどこかから調達してくる。

2011〜2018年に書いていたWordPress日記のSQLダンプが残っていた。
phpMyAdmin形式の INSERT 文をパースして、公開・非公開あわせて23記事、約2万字、文単位でおよそ600文が取れた。
Claude以前の時代の文章なので、混入の疑いがゼロの人間クラスになる。

flowchart TD
    A[会話履歴JSONL<br/>2台のPC] -->|Editペア抽出| P[修正前後ペア 1,560]
    B[公開済み記事622本] -->|Sonnet一括スキャン| H[機械ラベルのスロップ候補]
    C[WordPress日記SQL] -->|INSERTパース| N[人間の文 約600]
    D[検証済みコーパス] --> V[型ラベル付き実例 21]
    P --> T[学習データセット]
    H --> T
    N --> T
    V --> T

ひとつ注意点がある。
WP日記は全部が日記調で、スロップ文は全部技術調。この対だと、分類器が学んだのが「スロップか自然か」なのか「日記か技術記事か」なのか区別できない。
自然文側には、公開済み技術記事の修正後の文(人間が承認した技術調の文)も混ぜて、ジャンルの偏りを消す。

Editペア1,560件を文体と内容に振り分ける

会話履歴から採ったEditペアには、文体修正と内容修正(事実の訂正、リンク追加)が混ざっている。
学習に使えるのは文体修正だけなので、Sonnetに40ペアずつ渡して3値に振り分けた。

判定件数扱い
style(文体のみ)805old側をスロップ文、new側を自然文に使う
content(内容)629学習に使わない
mixed(両方)126学習に使わない

文体修正が半分を超えた。
振り分けはバッチ40件×39回で、40分かからずに終わった。

データセットに組む

ここまでの素材を、文単位の二値分類(スロップ/自然)のデータセットにした。

分割件数中身
train3,782文(スロップ1,664/自然2,118)styleペアold側899+スキャンstrong765がスロップ文。new側797+記事のクリーン文866+WP日記455が自然文
val148文記事単位で分離(同じ記事の文が学習と評価に跨るリークを防ぐ)
gold76文人間確認済みの見落とし文26+WP日記の温存50。学習には一切入れない

設計判断が2つ。
スキャンのweak判定1,586件は初回学習に入れない。スキャンエージェントには見逃しより誤検出を許す方針を与えているので、weakをそのまま学習させると誤検出ごと覚える。
goldは検証済みコーパス(自分が指摘してカテゴリ確定した実例)を丸ごと温存した。学習データと同じ作り方の評価では、新しい文に通用するかが測れない。だから自分の判定だけで作った評価セットを別に置いた。

モデルはModernBERT-jaにした

日本語BERTの定番は東北大BERTだが、今回はSB IntuitionsのModernBERT-ja-130mを選んだ。
学習コーパスが4兆トークン超と新しく、判定対象の2025〜2026年の技術文とコーパスの時期が揃うこと、MeCab系トークナイザの追加インストールが不要なこと、日本語分類ベンチで同サイズ帯の東北大BERTを上回る報告があること、が理由。
うまく動かなければ東北大v3に切り替える。

学習環境はメイン作業機のMac miniをそのまま使う。

項目内容
マシンMac mini(Apple M4、ユニファイドメモリ16GB)
OSmacOS 26.3
Python3.14.4(venv)
学習系torch 2.12.1(MPS)+ transformers 5.13.0
モデルsbintuitions/modernbert-ja-130m

学習はローカルで完結するので、Sonnetの一括スキャンとは独立に回せる。

初回学習の結果は片翼だけ完璧だった

3エポックの学習は418秒で終わった。

評価結果
val(148文)精度76.4% / F1 0.762
gold・人間文50件への誤検出0件(precision 1.0)
gold・確定した見落とし26件の検出11件(recall 42%)

結果は誤検出ゼロ・検出は半分弱と、大きく偏った。
人間の文をスロップ扱いする誤爆がゼロなので、前段に置いても既存の文を壊さない。

見逃した15件のリストを眺めると、ほぼ全部が「条件文で一般化した帰結」と「〜も見る。」の点検指示型だった。
つまりこの分類器は、Sonnetのスキャンが繰り返し見逃してきたのと同じ型を見逃した。

この2つの型のスロップ性は「段落の締めの位置に置かれている」ことに大きく依存する。
「npmの依存ツリーも見る。」という文は、手順解説の途中にあれば普通の文で、段落の末尾に単独で置かれると点検指示の言い切りになる。
文単位で切り出して渡す今の入力形式では、この位置の情報が落ちている。分類器は判定に必要な材料を最初から渡されていなかった。

細かい反省も2つ。
goldに組み立てスクリプトの正規表現ミスで不良データが2件混入していた(テンプレート例文の行と、複数語をまとめ書きしたエントリ)。除いた実質のrecall(再現率)は11/24で46%。
それと3エポック中、valのベストは1エポック目のままだった。エポック数と学習率はまだ何も詰めていない。

v2はメモリ不足で3回落ちた

622本の完走を待って、位置タグ+前文入りの全量データ(train 6,530文)で再学習に入った。
16GBのM4 Mac miniではメモリが足りなかった。

試行設定結果
1回目バッチ16・256トークンOOM(学習側7.1GB+他プロセス12.9GB)
2回目バッチ8×勾配蓄積2OOM(学習側5.3GB+他プロセス14.9GB)
3回目バッチ4×蓄積4・192トークンOOM(学習側3.5GB+他プロセス16.4GB)
4回目PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0完走

学習側を絞るたびに、ブラウザ等の他プロセスのメモリ使用が増えて、上限20GBまでの残りが減っていく。
今回の上限はモデルサイズではなく、同じマシンの他のプロセスが決めていた。
最後はtorchのウォーターマーク上限を外して通した。学習の実需要は4GB弱なので、スワップに漏れても実害はなかった。

ついでにもうひとつ詰まった話。622本のうち1本だけ、何度リトライしてもスキャンが失敗し続けた。
npmワームの解説記事で、Sonnetのサイバーセキュリティ・セーフガードがマルウェア手口の記述に反応していた。
自分のブログの公開済み記事なのに、ヘッドレスの一発呼び出しでは「書いた本人の文体チェック」という文脈が付かず、分類器にはマルウェア解析依頼に見える。この1本はスキップで確定させた。

v2の結果

指標v1(文単体)v2(位置タグ+前文+全量)
val 精度 / F176.4% / 0.76284.7% / 0.841
gold 誤検出(人間文50件)0件0件
gold recall(確定した見落とし)42%52%

valは8ポイント強の改善で、エポックを重ねるごとに伸びた(v1は1エポック目で頭打ちだった)。
人間の文への誤検出は全量データでもゼロを維持した。

goldのrecallは52%止まり。ただし、この評価は学習時と条件が1つずれている。
goldの中身は指摘された文そのものだけで、前後の文脈を記録していない。だからgold評価だけ前文なしで走っている。前文込みで学習したモデルには不利な条件で、実運用(記事全体を文脈付きで流す)の実力を過小評価している可能性が高い。
コーパスのエントリ形式に前文フィールドを足せば、次からこの歪みは消える。

残った見逃し12件は、やはり「条件文で一般化した帰結」と評価の言い切りの中核部分だった。
「JFrogが挙げている確認対象は直接的だ。」を文法的な手掛かりなしで拾うには、この文が評価の総括であるという意味側の判定が要る。130Mの二値分類が素通しで届く範囲はここまでで、次は310Mへのサイズアップと、Sonnetのweak検出1,600件を検証して足す2方向がある。

v3はラベルノイズの除去

80%台では物足りないので、原因をモデルの能力ではなくラベルの質から探した。
すぐ1つ見つかった。Editペアのold側を丸ごとスロップ文にしていたが、複数文の抜粋には「修正されなかった文」も混ざる。同じ文がold/new両方にあれば、それはスロップではないのにスロップ側のラベルが付いていた。
v3ではold側にしかない文だけをスロップ文、new側にしかない文だけを自然文にして、両方に出る文はラベル付けから外した。エポックも3→5に伸ばした。

1回目の実行は学習と関係ないところで落ちた。ディスクの空きが2.6GBしかなく、エポックごとのチェックポイント書き込みとMetalのシェーダキャッシュが書けなくなった。
16GBメモリの次は228GBディスクに引っかかった。50GB強を整理してから回し直した。

指標v1v2v3
val 精度 / F176.4% / 0.76284.7% / 0.84185.5% / 0.851
gold 誤検出(人間文50件)0件0件0件
gold recall(確定した見落とし)42%52%60%

goldのrecallは42%→52%→60%と一段ずつ上がり、人間の文への誤検出は3世代通してゼロのまま。
v2で見逃していた「JFrogが挙げている確認対象は直接的だ。」のような評価形容の言い切りを、v3は拾えるようになった。

まだ見逃す10件は「条件文で一般化した帰結」の意味判定が重い部分に集中している。
「ビルドは開発中に何度も走るため、ビルド環境を発火点にするとCI/ローカルの両方を高頻度で踏める。」を拾うには、文末が個別の事実ではなく一般論の口調だと読み取る判定が要る。130Mの二値分類はこの判定ができていない。

エンコーダ編はここまで。
誤検出ゼロのまま、確定した見落としの6割を7分の学習と数msの推論で拾える前段フィルタができた。
残り4割の意味判定は、次の実験で小型LLMのファインチューニングに渡す。修正ペア805件は「スロップ文→自然な文」の書き換えの見本としてそのまま教師データになるので、検出だけでなく修正案まで出す校正器を作る。

集めたデータの一覧

データクラス検証状態
会話履歴のEditペアstyle 805 / content 629 / mixed 126styleのold側=スロップ文、new側=自然文Sonnetで振り分け済み
公開済み記事の一括スキャン622記事・検出7,208件strong 2,493をスロップ文に採用機械ラベル
WordPress日記約600文自然文(人間)確定
検証済みコーパス28件goldテスト専用型ラベル付きで確定
文体指摘の瞬間36件ラベリングの参考資料確定