bedvibe.studio

BedVibe Studios — Engineering

Loss Curves Lie: Building a Deterministic Linter for ML Training Runs

A run learning pure noise reduced its loss by 62% and looked textbook-healthy. trainproof catches the training failures that can be caught — and is honest about the ones that can't.


trainproof

v0.17.0active

Deterministic PASS/WARN/FAIL verdicts across a training run's whole life — before it starts (dataset, tokenizer, and an environment preflight that catches a stack which will not import, an unreadable checkpoint, or too little system RAM), a live guardian during training, a post-run autopsy after, and a baseline compare. Reads five log formats including TensorBoard event files, decoded from the wire format with no torch, tensorflow or tensorboard installed. No ML judging ML: every verdict is a rule that fires or doesn't, every finding cites its exact numbers, and a check that could not run is reported as NOT-CHECKED rather than as a pass.

Install: pip install trainproof
Latest Documented Release: v0.17.0 · 2026-08-02
Tests: 230 passing tests
Releases: 17 releases
License: MIT
Built on: ttsproof
  • trainproof doctor . — zero-config autopsy: discovers every training log in a directory, judges them, prints a triage-sorted summary
  • Live guardian: a one-line Hugging Face callback; opt-in stop_on_fail aborted a diverging Qwen2.5-3B QLoRA at step 20 of 300 in a real demo
  • Preflight: lints dataset + tokenizer before a single GPU-second (malformed JSONL, empty rows, duplicates, missing eos/pad, over-length)
  • compare catches shuffled-labels corruption that single-run loss rules cannot — the relative loss floor sits ~6× a known-good baseline
  • Stable rule IDs (TP-DIVERGE, TP-OVERFIT, TP-DEAD-RUN, …) documented in RULES.md, plus --json output built for CI and AI coding agents
  • CONTRACTS.md pins the promises — exit codes, JSON schema policy, rule-ID stability and a verdict-stability guarantee; SARIF 2.1.0 output turns findings into GitHub PR annotations
  • A PASS names every check that ran and every check it skipped, with the reason — a skipped check is never reported as a passed one
  • Grounded in an 18-run fault-injection study (Qwen2.5-3B QLoRA, RTX 5080) — six configurations at three seeds each, every real log shipped in-repo — plus a 9.8-hour Coqui XTTS fine-tune that diverged on its own, the first shipped failure nobody injected
  • Reads five log formats including TensorBoard event files, decoded from the wire format with no tensorflow, tensorboard, protobuf, torch or numpy — validated byte-exact against tensorboard's own EventAccumulator, and the rules are exercised on real runs from three frameworks: HuggingFace, Coqui XTTS and PyTorch Lightning

Recent Releases

v0.17.02026-08-02 — v0.17.0 — the lint gate, and one log that used to vanish
v0.16.02026-08-02 — v0.16.0 — the rule registry (no behaviour change)
v0.15.02026-08-01 — v0.15.0 — the before-the-GPU release
v0.14.02026-08-01 — The third-framework release
Full changelog on GitHub →

I kept losing GPU hours to runs that were already dead. A fine-tune that had quietly gone to NaN somewhere after step 300 and trained on garbage the rest of the night. A run that was training at learning-rate-zero the whole time and updating nothing. A dataset with a few hundred broken rows I only discovered at the end. Every time, the pattern was the same: the run looked like it was working, and nothing told me otherwise until the hours were already spent.

So I built a linter for training runs — trainproof — and then I spent days trying to prove it wrong.

pip install trainproof trainproof doctor .   # zero-config: discovers and judges every training log it finds

Repo: github.com/Mormolykos/trainproof (MIT)

The philosophy: no ML judging ML

The most important decision in trainproof is what it isn't. There's no model scoring your run. No "87%-confidence this looks unhealthy." Every verdict is a deterministic rule that either fires or it doesn't, and every finding cites the exact numbers behind it. When it can't be sure, it says so instead of guessing.

That constraint is the whole point. A reliability tool that hallucinates is worse than no tool, because now you don't trust your own alarms. trainproof's engine is IO-free and doesn't even import torch — it reads plain logs and applies rules. You can read every rule and know exactly why it fired.

Validating the rules: controlled fault injection

You validate a detector by feeding it faults whose answer you already know, then measuring what it catches. One base setup — a Qwen2.5-3B QLoRA on an RTX 5080 — run six ways:

Three seeds each: 18 runs, real logs, all shipped in the repo so you can reproduce every verdict. A nineteenth log ships beside them with no injected fault at all — a 9.8-hour Coqui XTTS fine-tune that diverged on its own.

Four of the five failures got caught fast. lr_hot spiked the gradient norm to 2,650× the median — flagged in seconds. The NaN and the flatline were trivial. But one config beat the tool completely, and it's the one that taught me the most.

The one that fooled it: shuffled labels

bad_labels was pure garbage — a dataset that cannot be learned, because the labels no longer correspond to the inputs. And that run reduced its loss by 62%.

On its own loss curve it looked like textbook-healthy training: a clean downward slope. It was learning absolutely nothing useful — just memorizing the statistics of noise, which any sufficiently large network will happily do. From a single run's loss curve, it is indistinguishable from a real run. No loss-only rule, looking at that run in isolation, can catch it.

That's not a gap I papered over — I wrote it straight into the README. It's also why trainproof has a compare mode: the failure becomes obvious the moment you put the run next to a known-good baseline and look at the relative floor it reaches. The corrupted run's loss floor sits in a different regime. One run in isolation lies; two runs side by side tell the truth.

What trainproof actually does

It covers a run's whole life, and every stage exits with a status code so it drops straight into CI:

It reads the logs you already produce — Hugging Face trainer_state.json, Coqui, plain JSONL/CSV — so there's nothing to instrument.

Try it

pip install trainproof

If you fine-tune, you've had at least one of these failures. I'd genuinely like to know which failure mode has burned your GPU hours — if a deterministic check would have caught it, tell me on the repo and it goes in, with credit.

Update: what came after

Since this article was written, trainproof has grown from the experiment above into a fuller tool. The release timeline at the top of this page is generated from GitHub Releases and PyPI, so it is always current — this section covers what changed in kind, not which version you are on:

Update: the linter failed its own test

This article argues that a training run can look healthy while learning nothing. Auditing trainproof before a release, I found it doing exactly that — to itself.

The question was mundane. What happens to a run whose loss is exactly 0.0 on every step? That is a real failure with a boring cause: if every target label is masked to -100, cross-entropy returns zero and the model learns nothing at all. trainproof's answer was PASS.

Every loss-shape check — flat curve, divergence, no-improvement — was guarded by a > 0 test, put there to avoid dividing by zero. On an all-zero curve all three guards bailed out, silently. The run reached a passing verdict, and that verdict then named those same three checks as having run.

That is worse than missing the failure. A tool that reports "I checked this" when it did not is not a weak alarm, it is a false one — and it is the same shape as the bad_labels run above: something that looks like evidence of health and is nothing of the kind. The same audit found the hole in three more places, including a baseline comparison that rated a zero-loss run as favourable, because a loss floor of zero beats every baseline there is.

v0.12.0 is the fix, and it encodes one sentence: a skipped check is not a passed check. A PASS now names every check that ran and every check that did not, each with its reason, as structured data rather than prose. Judging a log that has no evaluation set now says so out loud:

Ran: dead-run, divergence, flat-loss, grad-spike, lr, zero-grad, zero-loss. Skipped: loader (no loader_time/step_time pair in the log); overfit (no eval_loss in the log - this run has no generalisation signal at all); step-time (no step_time column in the log).

You can read that and know what the pass is worth. Nothing about how trainproof judges a run changed — all 38 locked verdicts are byte-identical to the previous release. What changed is that it stopped overstating what it had looked at.