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 mini | 823 | 50 |
| MacBook Pro | 737 | 17 |
| 合計 | 1,560 | 67 |
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(文体のみ) | 805 | old側をスロップ文、new側を自然文に使う |
| content(内容) | 629 | 学習に使わない |
| mixed(両方) | 126 | 学習に使わない |
文体修正が半分を超えた。
振り分けはバッチ40件×39回で、40分かからずに終わった。
データセットに組む
ここまでの素材を、文単位の二値分類(スロップ/自然)のデータセットにした。
| 分割 | 件数 | 中身 |
|---|---|---|
train | 3,782文(スロップ1,664/自然2,118) | styleペアold側899+スキャンstrong765がスロップ文。new側797+記事のクリーン文866+WP日記455が自然文 |
val | 148文 | 記事単位で分離(同じ記事の文が学習と評価に跨るリークを防ぐ) |
gold | 76文 | 人間確認済みの見落とし文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) |
| OS | macOS 26.3 |
| Python | 3.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×勾配蓄積2 | OOM(学習側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 精度 / F1 | 76.4% / 0.762 | 84.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強を整理してから回し直した。
| 指標 | v1 | v2 | v3 |
|---|---|---|---|
val 精度 / F1 | 76.4% / 0.762 | 84.7% / 0.841 | 85.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 126 | styleのold側=スロップ文、new側=自然文 | Sonnetで振り分け済み |
| 公開済み記事の一括スキャン | 622記事・検出7,208件 | strong 2,493をスロップ文に採用 | 機械ラベル |
| WordPress日記 | 約600文 | 自然文(人間) | 確定 |
| 検証済みコーパス | 28件 | goldテスト専用 | 型ラベル付きで確定 |
| 文体指摘の瞬間 | 36件 | ラベリングの参考資料 | 確定 |