<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Panagiotis Gkilis</title>
    <description>The latest articles on DEV Community by Panagiotis Gkilis (@bedvibe_studios).</description>
    <link>https://dev.to/bedvibe_studios</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4057536%2F53ace3a5-ee4f-4cc1-abcb-f85ebf51ddc6.jpg</url>
      <title>DEV Community: Panagiotis Gkilis</title>
      <link>https://dev.to/bedvibe_studios</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bedvibe_studios"/>
    <language>en</language>
    <item>
      <title>I spent months training a 730M model with no tool that could tell me it was already dead</title>
      <dc:creator>Panagiotis Gkilis</dc:creator>
      <pubDate>Mon, 03 Aug 2026 12:16:57 +0000</pubDate>
      <link>https://dev.to/bedvibe_studios/i-spent-months-training-a-730m-model-with-no-tool-that-could-tell-me-it-was-already-dead-220l</link>
      <guid>https://dev.to/bedvibe_studios/i-spent-months-training-a-730m-model-with-no-tool-that-could-tell-me-it-was-already-dead-220l</guid>
      <description>&lt;p&gt;I spent months trying to train a 730M-parameter TTS model on my own hardware. It wouldn't converge, and nothing in my stack would tell me why — not the loss curve, not TensorBoard, not the checkpoints. Every tool I had showed me numbers. None of them would say &lt;strong&gt;this run is already dead, stop paying for it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That situation isn't rare. A fine-tune quietly goes to NaN after step 300 and trains on garbage the rest of the night. A run trains at learning-rate-&lt;em&gt;zero&lt;/em&gt;, updating nothing, for six hours. A dataset carries a few hundred broken rows you find at the end. Every time the pattern is identical: the run &lt;strong&gt;looks&lt;/strong&gt; fine, and nothing tells you otherwise until the hours are spent.&lt;/p&gt;

&lt;p&gt;So I built the linter I needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;trainproof
trainproof doctor &lt;span class="nb"&gt;.&lt;/span&gt;   &lt;span class="c"&gt;# discovers and judges every training log it finds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT, and the interesting part isn't the rules — it's the constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  No ML judging ML
&lt;/h2&gt;

&lt;p&gt;There is no model inside trainproof. No "87% confidence this looks unhealthy." Every verdict is a deterministic rule that fires or doesn't, and every finding prints the number it fired on.&lt;/p&gt;

&lt;p&gt;That's not minimalism for its own sake. &lt;strong&gt;A reliability tool that hallucinates is worse than no tool&lt;/strong&gt;, because now you don't trust your own alarms — and an alarm you don't trust is just noise. When trainproof can't be sure, it is required to say so rather than guess.&lt;/p&gt;

&lt;p&gt;That requirement has a concrete shape. Severity and exit code are separate axes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Exit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FAIL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your run is broken&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WARN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Something is worth your attention&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NOT-CHECKED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;I could not judge this&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PASS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Checked, and fine&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;NOT-CHECKED&lt;/code&gt; is the one that matters. A checking tool can say two very different sentences — &lt;em&gt;"your run failed"&lt;/em&gt; and &lt;em&gt;"I couldn't read your file"&lt;/em&gt; — and a tool that can't tell them apart is lying to your CI in a quiet, expensive way. An unreadable log reports no verdict rather than inventing a failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating the rules: controlled fault injection
&lt;/h2&gt;

&lt;p&gt;You validate a detector by feeding it faults whose answer you already know, then measuring what it catches. One Qwen2.5-3B QLoRA on an RTX 5080, run six ways, three seeds each — 18 runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;healthy&lt;/strong&gt; — the control&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lr_hot&lt;/strong&gt; — learning rate 100× too high&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lr_zero&lt;/strong&gt; — learning rate at zero&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fp16_nan&lt;/strong&gt; — an fp16 overflow that NaNs the loss&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bad_labels&lt;/strong&gt; — labels shuffled into pure noise&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;overfit&lt;/strong&gt; — 64 samples, many epochs, with a held-out eval set to prove it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four of the five failures got caught. &lt;code&gt;lr_hot&lt;/code&gt; spiked gradient norm to &lt;strong&gt;2,650 — about 4,900× its own median&lt;/strong&gt;, flagged in seconds. NaN and flatline were trivial.&lt;/p&gt;

&lt;p&gt;One beat every loss rule I had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that fooled it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;bad_labels&lt;/code&gt; is a dataset that &lt;strong&gt;cannot be learned&lt;/strong&gt; — the labels no longer correspond to the inputs. That run &lt;strong&gt;reduced its loss by 69.8%&lt;/strong&gt; (18.9 → 5.7).&lt;/p&gt;

&lt;p&gt;On its own curve it looked like textbook-healthy training. A clean downward slope. It was learning nothing; it was memorizing the statistics of noise, which any sufficiently large network will happily do.&lt;/p&gt;

&lt;p&gt;Be precise about the failure, because you can run this yourself: &lt;code&gt;bad_labels&lt;/code&gt; doesn't come back clean, it comes back &lt;code&gt;WARN&lt;/code&gt; — and the only rule that fires is &lt;code&gt;TP-GRAD-SPIKE&lt;/code&gt;, an unrelated gradient blip. &lt;strong&gt;Not one loss rule fired.&lt;/strong&gt; The tool noticed something twitched and completely missed that the dataset was noise.&lt;/p&gt;

&lt;p&gt;The lesson is bigger than the tool: &lt;strong&gt;a loss curve going down is not evidence that anything real is being learned.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I wrote that straight into the README rather than papering over it. It's also &lt;em&gt;why&lt;/em&gt; there's a &lt;code&gt;compare&lt;/code&gt; mode — put the run next to a known-good baseline and the relative floor gives it away instantly. One run in isolation lies. Two runs side by side tell the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I pointed it at my own real work
&lt;/h2&gt;

&lt;p&gt;Injected faults are a lab. Eventually I ran the shipped rules against real fine-tunes I had actually paid for, on my own GPU. Two results, both in the repo under &lt;code&gt;evidence/&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Real run&lt;/th&gt;
&lt;th&gt;Steps&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Coqui XTTS v2&lt;/td&gt;
&lt;td&gt;125,000&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;FAIL&lt;/strong&gt; — &lt;code&gt;TP-DIVERGE&lt;/code&gt;, &lt;code&gt;TP-THROUGHPUT&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fish Speech LoRA (Lightning)&lt;/td&gt;
&lt;td&gt;2,049&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;WARN&lt;/strong&gt; — &lt;code&gt;TP-OVERFIT&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;TP-OVERFIT&lt;/code&gt; fires when eval-loss climbs past 1.2× its own minimum while train-loss keeps falling. It warns rather than fails, because early stopping is your call — but it means &lt;strong&gt;your best checkpoint has already gone by&lt;/strong&gt;, and if you're keeping only the last one, you kept the wrong one.&lt;/p&gt;

&lt;p&gt;The XTTS run is logged twice, by two entirely separate readers — Coqui's text log and its TensorBoard event file, same run. They produce the &lt;strong&gt;same verdict and the same rule set&lt;/strong&gt;, which is the check I care about most: agreement between independent readers is how you find out your parser is inventing things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'd defend hardest: it was wrong, and I shipped the fix
&lt;/h2&gt;

&lt;p&gt;Running against real logs also made trainproof report a &lt;strong&gt;false FAIL on a perfectly healthy run&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TP-ZERO-GRAD&lt;/code&gt; fired whenever every finite gradient norm was exactly &lt;code&gt;0.0&lt;/code&gt;, and reported a severed backward graph. Coqui writes &lt;code&gt;avg_grad_norm&lt;/code&gt; as &lt;code&gt;0.0&lt;/code&gt; when gradient clipping is off. So a healthy 125,000-step XTTS fine-tune whose loss reached &lt;code&gt;0.017&lt;/code&gt; was reported &lt;strong&gt;FAIL&lt;/strong&gt; by my own tool.&lt;/p&gt;

&lt;p&gt;The fix is a piece of reasoning, not a threshold tweak: &lt;strong&gt;a run cannot both learn and receive no gradient.&lt;/strong&gt; The check now stands down when the loss improved beyond a minimum threshold — and records &lt;em&gt;why&lt;/em&gt; it stood down as an explicit skip, rather than silently not running.&lt;/p&gt;

&lt;p&gt;No test caught that. A real run did. That's the entire argument for shipping evidence logs in the repo instead of only fixtures.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;

&lt;p&gt;Every stage exits with a status code, so it drops into CI as-is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before a GPU-second — &lt;code&gt;preflight&lt;/code&gt; and &lt;code&gt;env&lt;/code&gt;.&lt;/strong&gt; Dataset and tokenizer: malformed JSONL &lt;em&gt;with the line number&lt;/em&gt;, empty rows, duplicates, missing &lt;code&gt;eos_token&lt;/code&gt;, &lt;code&gt;pad == eos&lt;/code&gt;. Then the environment: does the training entrypoint import (&lt;strong&gt;in a subprocess&lt;/strong&gt;, so a segfaulting extension can't take the linter with it), is the checkpoint intact, is there RAM and disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;During — the guardian.&lt;/strong&gt; A one-line Hugging Face callback. Warns by default; opt into &lt;code&gt;stop_on_fail&lt;/code&gt; and it aborts a doomed run itself. It killed a diverging demo run at &lt;strong&gt;step 20 of 300&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After — &lt;code&gt;epoch&lt;/code&gt; and &lt;code&gt;doctor&lt;/code&gt;.&lt;/strong&gt; Diverged, flatlined, NaN'd, spiked, overfitting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Against a baseline — &lt;code&gt;compare&lt;/code&gt;.&lt;/strong&gt; The relative-floor rules that catch shuffled labels.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reads what you already produce: Hugging Face &lt;code&gt;trainer_state.json&lt;/code&gt;, Coqui, &lt;strong&gt;TensorBoard event files&lt;/strong&gt;, JSONL, CSV. Nothing to instrument.&lt;/p&gt;

&lt;p&gt;One note on the TensorBoard reader, since it's the part I enjoyed most: it's written from the wire format — TFRecord framing plus the &lt;code&gt;Event&lt;/code&gt; / &lt;code&gt;Summary&lt;/code&gt; / &lt;code&gt;TensorProto&lt;/code&gt; fields it needs. It imports no tensorflow, no tensorboard, no protobuf, no torch. It's validated byte-exact against &lt;code&gt;EventAccumulator&lt;/code&gt; on a real 2,049-step Lightning run: all 13 tags, all point counts, all values. And a &lt;strong&gt;truncated&lt;/strong&gt; event file — the normal state of a killed run — is read up to the cut instead of raising, because the killed run is the run most in need of judging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checkpoints are read without unpickling
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;torch.load&lt;/code&gt; executes arbitrary code by design. That's the reason torch 2.6 flipped &lt;code&gt;weights_only&lt;/code&gt; to &lt;code&gt;True&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A tool whose job is to tell you a checkpoint is safe to use, which must &lt;strong&gt;run&lt;/strong&gt; that checkpoint to find out, is not a safety tool. So trainproof reads &lt;code&gt;.pt&lt;/code&gt; / &lt;code&gt;.pth&lt;/code&gt; / &lt;code&gt;.ckpt&lt;/code&gt; as the ZIP archives they are — entry table, storage count, CRC, straight from the archive directory. It distinguishes missing, zero-byte, truncated, CRC-corrupt, and legacy pre-1.6 pickle, which it reports as &lt;code&gt;NOT-CHECKED&lt;/code&gt;, because refusing to unpickle is correct behaviour rather than an error.&lt;/p&gt;

&lt;p&gt;The truncated case is the one that earns its keep. A save killed mid-write leaves a ZIP header with no central directory, and &lt;code&gt;zipfile.is_zipfile()&lt;/code&gt; returns &lt;code&gt;False&lt;/code&gt; for it — so the most common real checkpoint failure was being reported as "not a checkpoint at all." That's the difference between resuming from your previous checkpoint and hunting for a file that was never written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is now
&lt;/h2&gt;

&lt;p&gt;17 releases. &lt;strong&gt;84 stable rule IDs, 230 tests, &lt;code&gt;schema_version&lt;/code&gt; 3.&lt;/strong&gt; Rule IDs are stable and the JSON schema has a written contract in &lt;code&gt;CONTRACTS.md&lt;/code&gt; — what each exit code means, when the format may change, that verdicts won't silently drift.&lt;/p&gt;

&lt;p&gt;Writing that document found a bug I had already shipped: &lt;code&gt;doctor --baseline&lt;/code&gt; printed &lt;code&gt;[FAIL]&lt;/code&gt; findings and still &lt;strong&gt;exited 0&lt;/strong&gt;, because the exit code was computed from single-run verdicts and never consulted the comparison. The terminal had been telling the truth; the exit code had been lying. No test caught it, because no test had ever written down which of the two was authoritative.&lt;/p&gt;

&lt;p&gt;Every verdict the tool produces on its example runs is frozen in 38 golden snapshots. A rule that silently stops firing fails the build — and so does one that starts firing spuriously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;trainproof
trainproof doctor &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo&lt;/strong&gt; (with the fault-injection logs, the real 125k-step evidence, and every locked verdict): &lt;a href="https://github.com/Mormolykos/trainproof" rel="noopener noreferrer"&gt;github.com/Mormolykos/trainproof&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI&lt;/strong&gt;: &lt;a href="https://pypi.org/project/trainproof/" rel="noopener noreferrer"&gt;pypi.org/project/trainproof&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full write-up&lt;/strong&gt;: &lt;a href="https://ai.bedvibe.studio/trainproof/" rel="noopener noreferrer"&gt;ai.bedvibe.studio/trainproof&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sibling&lt;/strong&gt;: &lt;a href="https://pypi.org/project/ttsproof/" rel="noopener noreferrer"&gt;ttsproof&lt;/a&gt; — failure-mode QA for text-to-speech&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More of what I've built&lt;/strong&gt;: &lt;a href="https://tts.bedvibe.studio/portfolio/" rel="noopener noreferrer"&gt;tts.bedvibe.studio/portfolio&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MIT.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you fine-tune, you've hit at least one of these. I'd genuinely like to know &lt;strong&gt;which failure mode has burned your GPU hours&lt;/strong&gt; — if a deterministic check would have caught it, open an issue and it goes in, with credit.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>opensource</category>
      <category>mlops</category>
    </item>
  </channel>
</rss>
