4 ways a weekly ModernBERT-ja slop detector retrain broke — one passed the gate
Contents
The encoder behind this blog’s style checker — built on training data mined from Claude Code conversation history and old WordPress diaries — retrains automatically every Sunday morning. Every week something breaks or the detection gets worse in a new way, and every week I patch it, so this is a write-up of the whole repair session.
Test environment
| Item | Details |
|---|---|
| Machine | M4 Mac mini (training on CPU, 15–20 min per run) |
| Model | sbintuitions/modernbert-ja-130m |
| Training data | 5,484–8,050 sentences (adopted config: 7,002) |
| Evaluation | fixed regression set of 352 sentences + firing rate on recent approved articles |
MPS is not used for training: it fights other processes for the shared memory pool and has been killed by the OS before.
How it works
All training data comes from this blog’s own editing history. The idea is that a corpus built from edits I personally reviewed should let the model detect “closer to human or not” — using raw AI-generated text as training data would defeat the purpose. The flow:
graph TD
A[Collect Edit pairs from conversation history] --> B[Classify with Opus: style / content / mixed]
B --> C[Build dataset<br/>positives = pre-edit sentences from style pairs]
D[455 human-written diary sentences] --> C
E[Clean sentences from past articles<br/>+ recent approved articles] --> C
C --> F[Retrain ModernBERT-ja-130m]
F --> G{Promotion gate}
G -->|all 3 conditions pass| H[Swap in the new model]
G -->|any condition fails| I[Auto-rollback to previous model]
The data breakdown is as in the figure. Positives are sentences that got rejected and rewritten — slop specimens. Negatives (“this is natural text” examples) are the rewritten versions plus sentences from approved articles, and the human side of the negatives is 455 sentences from diaries I hand-wrote back in my WordPress days. 302 confirmed slop sentences and 50 held-out human diary sentences are kept out of training as a fixed regression set I call gold.
The gold set is never used for weight updates or epoch selection, but it is reused every week for the promotion decision (whether the freshly retrained encoder replaces the one in production) and for the sweep described later. It is not a one-shot independent test of how well the model generalizes to unseen articles; it is a regression test against known failures — the model calling a fine sentence slop, or letting actual slop through, worse than before.
The promotion gate has three conditions: zero false positives on the 50 human diary sentences, gold recall (how much of the confirmed slop gets detected) not lower than the previous model, and a firing rate of 15% or less on the 3 most recently modified approved articles. Fail any one and the previous model is restored automatically. The 3 articles used for the firing-rate check are excluded from the newly added recent-article negatives, so the gate never scores sentences the model trained on. There was an incident where a regressed model that fired on nearly every sentence of approved articles got promoted, and I’d rather not go through that again.
Failure 1: Opus classification from cron had never once succeeded
The retrain log for the most recent Sunday ended with “classification batches failed: 21”. The style / content / mixed classification of collected Edit pairs calls Opus through the claude CLI, and all 21 batches had died with empty errors.
Going back through the logs, the Sunday before that had 5 out of 5 batches fail too. In other words, cron-launched classification had never succeeded even once. The cause is the macOS keychain: in a cron session it stays locked, so the claude CLI can’t authenticate. Running the same script by hand from an interactive session put all 21 batches through without a single error.
The fix was to move from crontab to a launchd LaunchAgent, which runs in the logged-in user’s context. Per Apple’s documentation, a StartCalendarInterval job whose scheduled time passes during sleep also runs on wake. On my setup the plist load is confirmed, but the first post-migration Sunday run hasn’t happened yet — whether keychain-backed classification actually goes through will show up in the next scheduled log.
806 pairs had been sitting unclassified and excluded from training. Rerunning classification by hand labeled about 60% of them style, which meant that many more positives. At this point I was thinking “the next training run should come out much better.”
Failure 2: a regression that slipped through the promotion gate
The retrain with those 806 pairs scored gold recall 0.434 with 1 false positive on human text, so the zero-FP condition rejected it automatically. So far the gate was working as designed.
In a later attempt, a model trained on a negatives-heavy configuration got promoted through the gate legitimately — while its recall had degraded to 0.192. Zero false positives, 2% firing rate, every condition passed.
The mechanism is simple: the gate only had conditions against over-detection, and I had never put in a lower bound against under-detection. A model that detects almost nothing produces no false positives and no firing, so it aces every check. On top of that, the gold set had grown from 260 to 352 sentences that week, which invalidates the recall comparison to the previous run and resets the baseline — nothing was left to stop the recall collapse.
I fed the promoted model 4 known slop sentences, and it judged all 4 natural at p=0.00. This is a detector that looks at 「結論から言うと、この方法で全て解決した。」(“Long story short, this method solved everything.”) and calls it natural text. Obviously useless, so I rolled it back by hand and reset the recall baseline to measured values.
Failure 3: full rewrites were never used for training
I started digging from a different question — “once a sentence becomes a false positive, won’t a growing corpus just keep overfitting on it?” — and found that the classification scheme itself was simply wrong.
In this blog’s revision flow, besides small wording fixes, whole paragraphs or articles sometimes get rewritten: the human hands over their own words as the correct version and swaps the text wholesale. For the detector this should be the richest teacher data there is. In practice, almost all of it was being thrown away.
The reason is the classification design. Large replacements change content and style at the same time, so Opus labels them content or mixed, which excludes them from training. Measuring the style-label rate by pair size: 63% for pairs under 300 characters, 10% for 300–1,000, 4% for over 1,000. Worse, the classification prompt only ever received the first 300 characters of each pair, so large pairs were being judged by their head alone.
Routing rewrites into negatives collapsed recall
I took the rewritten side of the discarded pairs, assumed it was correct text, and routed it into the negative pool. The first one-pass dataset picked up 2,840 sentences.
The first thing that surfaced was label contradiction: with the flow “AI adds a sentence, it gets approved, and later that same sentence gets a style fix,” the same sentence shows up on both the positive and negative side. 298 sentences matched, and because of deduplication order, 258 of them had entered training as “natural text.” Sentences a human had already corrected were being taught as natural. That was fixed with a two-pass build that registers positives first.
In the contradiction-free two-pass version, the content/mixed-derived negatives came to 2,570 sentences after dedup. The model that finished training on it still collapsed from recall 0.434 to 0.192 — the very model behind the gate-slipping regression above. The other problem was data distribution: the rewritten side of content pairs was overwhelmingly polished, approved, AI-written article text, not the “human colloquial rewrites” I was after. Stacking that many negatives with a distribution close to the slop positives pushed the decision toward detecting almost nothing.
Failure 4: blank-spot firing on the newest article
Iterating on the data and retraining eventually produced a model with recall 0.685, the highest ever. But the firing-rate gate showed it flagging 34 of 83 sentences in a single published article — the newest one. The hits were ordinary descriptive sentences like 「参照音声は5〜10秒あれば足りる。」(“5–10 seconds of reference audio is enough.”), so this wasn’t hidden slop being unearthed; it was plain over-firing.
This too traced back to skew in the teacher data. The process that feeds approved-article sentences into the negative pool had been stalled for two months, so not a single “clean” sentence from any recent article existed in the negatives. Meanwhile, 21 pre-edit sentences from that same article’s editing session were sitting in the positives. From the model’s point of view, the newest article is one where it saw 21 slop specimens and zero clean examples.
Treating that as a blank spot in the negatives, I added negatives from June–July approved articles (excluding the 3 used by the firing-rate gate), and firing dropped from 34/83 to 19/83. But false positives on the human diary went from 1 to 5. In this configuration the 455 diary sentences — the negatives that represent broken, colloquial human fragments — had been diluted to 9.2% of all negatives. All 5 false positives were clipped fragments like 「開始前の予想通りの大混雑。」(“Huge crowd before opening, as expected.”). Then again, an AI will happily write short fragmented sentences too, so my honest impression is that this class of false positive is going to stay hard.
Sweeping the three-way trade-off
To summarize the situation: gold recall, false positives on human text, and firing rate on approved articles trade off against each other, and in these runs improving any one of them made another worse. The two knobs available are the oversampling factor for the human diary and the cap on recent-article negatives, so I ran 5 configurations.
| Config | gold recall | Human-text FP | Newest-article firing | 3-article firing rate |
|---|---|---|---|---|
| Previous model (v3, baseline) | 0.235 | 1 | 1/83 | — |
| diary ×3 + article-neg cap 1200 | 0.248 | 1 | 4/83 | 2% |
| diary ×4 + article-neg cap 1200 | 0.228 | 0 | 2/83 | 1% |
| diary ×3 + article-neg cap 600 | 0.285 | 1 | 5/83 | 2% |
| diary ×2 + article-neg cap 600 | 0.493 | 2 | 32/83 | 20% |
| diary ×2 + article-neg cap 1200 | 0.430 | 1 | 11/83 | 9% |
The 1200 is a sampling cap; after excluding the gate articles and dedup, the actual injection was 1,062 sentences. Comparing the results, the heavier the diary weighting, the more false positives and firing settle down — and the lower recall goes. Cutting the article-negative cap to 600 while keeping diary ×2 sent firing back to 20%, so holding recall while filling the newest-article blank spot takes the 1200-cap configuration. The only zero-FP config was diary ×4, and its recall came in below the old baseline model.
I adopted diary ×2 + article-neg cap 1200. Recall is about 1.8× the old model’s, and the single false positive on the 50 held-out diary sentences is the exact same sentence the old model missed (「という感じのまた役に立つかどうかわからない処理を。」— a trailing-off diary fragment, roughly “…that kind of processing, no idea if it’ll ever be useful.”). On this fixed regression set, neither the count nor the content of the false positives got worse. The 9% firing rate is inside the gate. The weekly gate’s zero-FP condition stays as is — this was a one-off manual adoption by my judgment, as an exception. The cost is candidate noise: on a new article, around 10 ordinary sentences will come up as candidates. Since this detector’s output just gets appended to the scan results as reference candidates, I judged that skimming and discarding them is acceptable. The configuration is now the default for dataset builds.
Every configuration was run once with the data split and training seed fixed at 42. I haven’t measured repeat runs with other seeds or confidence intervals, so what got decided here is an operational setting against this blog’s fixed regression set, not a generally optimal value. This encoder is tuned to keep this blog’s style in line — it was never meant to be a detector for AI slop in general.
Scanning this article with all three models
Finally, I took the draft of this very article and scanned it with the newly adopted model, the conservative diary ×3 + article-neg 600 configuration, and the old model, then compared. The article about the detector’s training data becomes the detector’s own test input — that’s the trick.
Against the 83 sentences left after stripping code blocks and the figure, the new model detected 33, the old model 30, and the conservative config 22. Every model fired at 3–4× its rate on a normal article. This article is dense with vocabulary straight out of the training corpus — positives, negatives, recall — and full of sentences that close out an experiment in a short declarative. The distribution of slop the detector learned and the distribution of text written about the detector simply overlap.
13 sentences were detected by all three models. Lined up, the trend is clear: nominal-ending sentences like 「この記事はその点検と修理の記録。」(“This article: the record of that inspection and repair.”), short verdict sentences like 「仮説は当たった。」(“The hypothesis held.”), and explanation-heavy sentences stuffed with numbers and jargon. I genuinely do have the habit of closing experimental milestones in exactly that form, and the 13 unanimous sentences were almost all of that shape. Those two quoted sentences are from the draft: the pre-publication style check flagged the same spots, and in the version you’re reading they’ve been rewritten.
Sentences flagged by only one model showed each configuration’s character. The new model’s solo detections were 7 declarations of fixes and adoptions, like 「対処はcrontabをやめてlaunchdのLaunchAgentに載せ替えた。」(“The fix was to drop crontab and move to a launchd LaunchAgent.”). The old model’s solo detections were also 7, mostly plain opening statements like 「学習はM4 Mac miniのローカルで回している。」(“Training runs locally on an M4 Mac mini.”), and the conservative config was the quietest with 4. Three models trained on the same corpus split this far apart in which phrasings they distrust, purely through data composition.
Quoting exposed a scanner limitation too. Sentence splitting cuts on periods even inside quotation marks, so 「参照音声は5〜10秒あれば足りる。」— quoted above in Failure 4 as an over-firing example — gets judged as a standalone sentence in this article’s scan as well. It’s an ordinary descriptive line from another published article, but stripped of context, the new model and the conservative config fired on it again (the old model didn’t). Filtering quoted text out would take a preprocessing step, and I haven’t gone that far.
The practical reality is that the encoder only does detection, so no amount of work gets detection accuracy to 100%. It can’t understand the context-dependent feelings a human reader gets — “this reeks of AI,” “this person just writes badly,” and “this is nicely put together.”
That’s why the style check also sends everything through LLM-based scanning, as covered in the previous article. Detect from as many angles as possible and judge the total.
Since it’s a total judgment, sentences that every AI check waves through still stack up into “yep, this is AI after all!” That detection only happens when a human actually reads it.
But — and maybe this applies to readers too — after reading AI text nonstop, you acclimate, and “no, this one’s fine” misjudgments start happening. Honestly, these days I keep catching myself thinking “this is my own writing, and it still smells like AI.”
In the end, as written above, the line between “AI slop” and “natural human writing” is largely a matter of feel. If the whole world gets used to AI, everyone might start talking in that register, and an era may come where neither AI nor humans can tell each other apart.