<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>hardware-failure on Antonio Space</title>
    <link>/tags/hardware-failure/</link>
    <description>Recent content in hardware-failure on Antonio Space</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en</language>
    <lastBuildDate>Fri, 16 Oct 2026 20:00:00 +0800</lastBuildDate><atom:link href="/tags/hardware-failure/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Predicting Disk Failures with a Small LLM, Part 2: Fixing the Evaluation</title>
      <link>/posts/backblaze-disk-failure-part-2/</link>
      <pubDate>Fri, 16 Oct 2026 20:00:00 +0800</pubDate>
      
      <guid>/posts/backblaze-disk-failure-part-2/</guid>
      <description>In Part 1 I fine-tuned Qwen2.5-1.5B-Instruct with 4-bit QLoRA on my RTX 3060 to answer one question: given seven days of SMART telemetry from a Toshiba MG07ACA14TA, will this drive fail in the next seven days? Training worked, the loss dropped, and the model returned clean JSON. It also predicted &amp;ldquo;no failure&amp;rdquo; for all 168 test drives and missed every one of the 18 failures.
I left one question open. My evaluation script rejected every answer from the original model, so I had no real baseline to compare against.</description>
      <content>&lt;p&gt;In &lt;a href=&#34;/posts/backblaze-disk-failure-part-1/&#34;&gt;Part 1&lt;/a&gt; I fine-tuned Qwen2.5-1.5B-Instruct with 4-bit QLoRA on my RTX 3060 to answer one question: given seven days of SMART telemetry from a Toshiba MG07ACA14TA, will this drive fail in the next seven days? Training worked, the loss dropped, and the model returned clean JSON. It also predicted &amp;ldquo;no failure&amp;rdquo; for all 168 test drives and missed every one of the 18 failures.&lt;/p&gt;
&lt;p&gt;I left one question open. My evaluation script rejected every answer from the &lt;em&gt;original&lt;/em&gt; model, so I had no real baseline to compare against. Before training anything new, I wanted to close that gap. Changing the training data without a trustworthy evaluation would just give me a second number I couldn&amp;rsquo;t interpret.&lt;/p&gt;
&lt;h2 id=&#34;the-parser-bug&#34;&gt;The parser bug&lt;/h2&gt;
&lt;p&gt;The original model did not return bare JSON. It wrapped its answer in a Markdown code block:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;```json
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &amp;#34;failure_within_7_days&amp;#34;: false
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;```
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My parser called &lt;code&gt;json.loads&lt;/code&gt; on the raw string, failed, and recorded &lt;code&gt;predicted: null&lt;/code&gt;. All 168 base-model answers were scored as invalid, which made the original model look like it had no classification ability at all. That was a measurement artifact, not a property of the model.&lt;/p&gt;
&lt;p&gt;The fix is small: strip an optional code fence, then parse. The decision it changes is not small. &amp;ldquo;The base model can&amp;rsquo;t follow the format&amp;rdquo; and &amp;ldquo;the base model follows the format but always says no&amp;rdquo; lead to very different next steps.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; json&lt;span style=&#34;color:#f92672&#34;&gt;,&lt;/span&gt; re
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;FENCE &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; re&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;compile(&lt;span style=&#34;color:#e6db74&#34;&gt;r&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;^```(?:json)?\s*(.*?)\s*```$&amp;#34;&lt;/span&gt;, re&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;DOTALL)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;def&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;parse&lt;/span&gt;(raw: str):
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    text &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; raw&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;strip()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    m &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; FENCE&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;match&lt;/span&gt;(text)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; m:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        text &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; m&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;group(&lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;try&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; bool(json&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;loads(text)[&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;failure_within_7_days&amp;#34;&lt;/span&gt;])
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;except&lt;/span&gt; (json&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;JSONDecodeError, &lt;span style=&#34;color:#a6e22e&#34;&gt;KeyError&lt;/span&gt;, &lt;span style=&#34;color:#a6e22e&#34;&gt;TypeError&lt;/span&gt;):
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;None&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I re-scored the saved predictions in &lt;code&gt;output/base-predictions.json&lt;/code&gt; without calling the model again.&lt;/p&gt;
&lt;h2 id=&#34;corrected-results&#34;&gt;Corrected results&lt;/h2&gt;
&lt;p&gt;With the fence stripped, all 168 base-model answers parse. They are also all identical: the same fenced JSON saying &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Valid answers&lt;/th&gt;
&lt;th&gt;TP&lt;/th&gt;
&lt;th&gt;TN&lt;/th&gt;
&lt;th&gt;FP&lt;/th&gt;
&lt;th&gt;FN&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;th&gt;Failure recall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen2.5-1.5B-Instruct (original)&lt;/td&gt;
&lt;td&gt;168 / 168 (fenced)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;89.3%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fine-tuned v1 (QLoRA)&lt;/td&gt;
&lt;td&gt;168 / 168 (bare)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;89.3%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Always &amp;ldquo;no failure&amp;rdquo;&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;89.3%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The three rows are the same. On this test set, fine-tuning changed the &lt;em&gt;format&lt;/em&gt; of the answer and nothing about the &lt;em&gt;prediction&lt;/em&gt;. Precision is undefined for all three, because none of them ever raises an alert.&lt;/p&gt;
&lt;p&gt;Accuracy is the misleading number here. 150 of the 168 test examples are healthy drives, so a model that never alerts scores 89.3% without learning anything. And because I sampled healthy drives when building the dataset, about 11% of test examples are failures, far more than in the real fleet. Accuracy on the real fleet would look even better for a model that never alerts, and mean even less.&lt;/p&gt;
&lt;h2 id=&#34;what-the-training-log-says&#34;&gt;What the training log says&lt;/h2&gt;
&lt;p&gt;The v1 loss curve fits that picture. Loss was 1.08 at step 5, 0.61 at step 10 and 0.024 by step 20, then stayed flat around 0.02 for the remaining 68 of 88 steps. With 700 healthy and 79 failed drives in &lt;code&gt;train.jsonl&lt;/code&gt;, answering &lt;code&gt;false&lt;/code&gt; is right about 90% of the time. The output is also a short, fixed JSON string, so with response-only loss there are only a few tokens to learn. Low loss tells me the model learned the answer format and the majority class quickly. It does not tell me the model learned anything about SMART counters.&lt;/p&gt;
&lt;h2 id=&#34;a-note-on-the-second-run&#34;&gt;A note on the second run&lt;/h2&gt;
&lt;p&gt;I had already prepared a balanced training set, &lt;code&gt;data/train-balanced.jsonl&lt;/code&gt;, with 79 failed and 79 healthy drives. My first attempt to train on it never started. Its log is a single line: the shell could not find the &lt;code&gt;soup&lt;/code&gt; binary, because the command used an old workspace path that pointed to an empty copy left over from a migration. No v2 adapter exists yet, so there are no v2 results in this post.&lt;/p&gt;
&lt;!-- TODO (pending experiment, no results yet):
  - Balanced v2: fresh adapter from the base model, 3 epochs, soup-balanced.yaml -&gt; output/qwen-backblaze-v2
  - Evaluate v2 on val.jsonl only; report precision, recall, false alerts per week
  - Tabular baseline (XGBoost or LightGBM) on the same features and splits
  - Future-period evaluation (e.g. Q2 2025) before any operational claim
  - Touch test.jsonl once, for the final chosen model
--&gt;
&lt;h2 id=&#34;what-comes-next&#34;&gt;What comes next&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Balanced training (v2).&lt;/strong&gt; A fresh adapter from the original base model, trained for three epochs on the balanced set. Balancing removes the shortcut of always answering no, but it is not guaranteed to make the model learn real signal. It may simply trade missed failures for false alerts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validation, not test.&lt;/strong&gt; I&amp;rsquo;ll compare v2 on &lt;code&gt;val.jsonl&lt;/code&gt; and leave &lt;code&gt;test.jsonl&lt;/code&gt; alone until I&amp;rsquo;ve picked a model. Re-scoring every idea on the test set would slowly turn it into a training signal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Precision, recall and false alerts.&lt;/strong&gt; For an operator, the useful questions are how many failing drives the model catches and how many healthy drives it flags per week. A model that alerts on everything has perfect recall and is just as useless as one that never alerts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A tabular baseline.&lt;/strong&gt; Seven days of seven SMART counters is a small table. XGBoost or LightGBM on the same features and splits will show whether a language model adds anything here.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A future period.&lt;/strong&gt; My splits are random by drive, not chronological. Before drawing operational conclusions, I need to train on one period and evaluate on a later one, such as Q2 2025.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The lesson from Part 1 still holds, now with a corrected baseline behind it: the pipeline runs on consumer hardware, but a working pipeline is not yet a working predictor.&lt;/p&gt;
</content>
    </item>
    
    <item>
      <title>Predicting Disk Failures with a Small LLM, Part 1: A Working Pipeline and a Useless Model</title>
      <link>/posts/backblaze-disk-failure-part-1/</link>
      <pubDate>Thu, 15 Oct 2026 20:00:00 +0800</pubDate>
      
      <guid>/posts/backblaze-disk-failure-part-1/</guid>
      <description>Most hardware I have operated tells me only part of what is happening inside it. A disk reports a handful of counters. A switch reports interface errors. A GPU reports temperature and a few error codes. The interesting physics, firmware decisions and wear sit underneath, and I mostly see their side effects.
I wanted to know how far those side effects can take you. Can a model look at a week of a drive&amp;rsquo;s self-reported telemetry and say whether it will fail soon?</description>
      <content>&lt;p&gt;Most hardware I have operated tells me only part of what is happening inside it. A disk reports a handful of counters. A switch reports interface errors. A GPU reports temperature and a few error codes. The interesting physics, firmware decisions and wear sit underneath, and I mostly see their side effects.&lt;/p&gt;
&lt;p&gt;I wanted to know how far those side effects can take you. Can a model look at a week of a drive&amp;rsquo;s self-reported telemetry and say whether it will fail soon? And can a small language model, fine-tuned on my own GPU, learn to do that?&lt;/p&gt;
&lt;p&gt;This post covers the first attempt. The pipeline works end to end. The model it produced does not predict anything useful. Both results are worth writing down.&lt;/p&gt;
&lt;h2 id=&#34;why-disk-failure-is-hard-to-predict&#34;&gt;Why disk failure is hard to predict&lt;/h2&gt;
&lt;p&gt;A drive&amp;rsquo;s failure depends on things the telemetry only partly captures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Workload.&lt;/strong&gt; The same drive model behaves differently under a backup archive than under a busy database.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hardware design and firmware.&lt;/strong&gt; Vendors decide which events to count, when to remap a sector, and what each SMART attribute means. The same attribute number can carry different semantics across vendors.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Age.&lt;/strong&gt; Wear accumulates, but early-life failures also exist.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Temperature and operating conditions.&lt;/strong&gt; Heat, vibration, power events and the chassis a drive sits in all matter, and only some of it is recorded.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It also matters what kind of device we are talking about.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mechanical hard drives&lt;/strong&gt; have spinning platters, moving heads and motors. Many failures develop gradually: the surface degrades, sectors become hard to read, and the drive remaps them. Counters such as reallocated and pending sectors can rise before the drive dies. Some failures, such as a head crash or a motor or electronics fault, are sudden and may leave little trace in SMART beforehand.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Semiconductor devices&lt;/strong&gt; such as SSDs have no moving parts. NAND flash wears with program/erase cycles, which drives expose through wear and spare-block indicators. But controller or firmware faults can take a device down abruptly, and the attributes that matter are different and often vendor-specific. A model trained on hard drive counters says nothing about SSDs.&lt;/p&gt;
&lt;p&gt;This series stays with mechanical drives, because that is where the public data is deepest.&lt;/p&gt;
&lt;h2 id=&#34;the-data-backblaze-drive-stats&#34;&gt;The data: Backblaze Drive Stats&lt;/h2&gt;
&lt;p&gt;Backblaze publishes daily snapshots of every drive in its data centers: model, serial number, capacity, a failure flag, and raw and normalized SMART values. I used Q1 2025, which is 90 daily CSV files in one archive (&lt;code&gt;data_Q1_2025.zip&lt;/code&gt;, about 1 GB).&lt;/p&gt;
&lt;p&gt;Mixing drive models would mix vendors&amp;rsquo; SMART semantics, so I picked one model: the &lt;strong&gt;Toshiba MG07ACA14TA&lt;/strong&gt;, a 14 TB drive with a large population in the fleet. Filtering the quarter to that model gave:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;3,385,931 daily observations&lt;/li&gt;
&lt;li&gt;133 unique drives that failed during the quarter&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;From each daily row I kept seven raw SMART fields:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SMART ID&lt;/th&gt;
&lt;th&gt;Common meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Reallocated sectors count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Power-on hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;194&lt;/td&gt;
&lt;td&gt;Temperature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;196&lt;/td&gt;
&lt;td&gt;Reallocation event count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;197&lt;/td&gt;
&lt;td&gt;Current pending sector count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;198&lt;/td&gt;
&lt;td&gt;Offline uncorrectable sectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;199&lt;/td&gt;
&lt;td&gt;UDMA CRC error count&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Most of these describe the media surface, plus age, heat and cabling errors. That is a narrow window into the drive, and it is deliberate. I wanted to start with the counters people usually watch.&lt;/p&gt;
&lt;h2 id=&#34;framing-the-prediction-task&#34;&gt;Framing the prediction task&lt;/h2&gt;
&lt;p&gt;The question I gave the model:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Given seven consecutive daily observations of one drive, will it fail during the following seven days?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The answer is a single JSON object:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-json&#34; data-lang=&#34;json&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;{&lt;span style=&#34;color:#f92672&#34;&gt;&amp;#34;failure_within_7_days&amp;#34;&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;How the examples are built matters more than the model, so I was strict about it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One example per drive.&lt;/strong&gt; A drive that contributes many overlapping windows would dominate training and could leak between splits.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Positive examples end one to seven days before the recorded failure.&lt;/strong&gt; The history never includes the failure day itself, so the model cannot see the event it is supposed to predict.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Negative examples need a complete seven-day follow-up without failure.&lt;/strong&gt; A drive that disappears from the data three days later is not a confirmed healthy example.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Histories with missing selected SMART values are rejected.&lt;/strong&gt; I would rather have fewer examples than teach the model to read gaps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Serial numbers and failure flags are excluded from the input.&lt;/strong&gt; Either would let the model cheat.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Splits are by drive, stratified, and random.&lt;/strong&gt; No drive appears in more than one split. The split is &lt;em&gt;not&lt;/em&gt; chronological, which I will come back to.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Healthy drives are sampled.&lt;/strong&gt; Using every healthy drive would make failures vanishingly rare. The price is that my test set has far more failures than the real fleet, so its accuracy numbers do not transfer to production.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The examples are stored as JSONL in Alpaca format, with &lt;code&gt;instruction&lt;/code&gt;, &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;output&lt;/code&gt; fields.&lt;/p&gt;
&lt;!-- TODO: paste one real (shortened) record from data/train.jsonl here so readers see the exact instruction and input format. --&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Failures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;train.jsonl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;779&lt;/td&gt;
&lt;td&gt;79&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;val.jsonl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test.jsonl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;168&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That is 114 usable failed drives out of 133. The rest were lost to the missing-value and window rules.&lt;/p&gt;
&lt;h2 id=&#34;the-local-setup&#34;&gt;The local setup&lt;/h2&gt;
&lt;p&gt;Everything ran on my Linux box:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;NVIDIA RTX 3060 with 12 GB VRAM (11.63 GiB usable), 16 GB RAM&lt;/li&gt;
&lt;li&gt;Python 3.12.3, PyTorch 2.14.1+cu130&lt;/li&gt;
&lt;li&gt;Soup 0.75.2 (the &lt;code&gt;soup-cli&lt;/code&gt; package), which wraps Transformers, PEFT and TRL behind one YAML config&lt;/li&gt;
&lt;li&gt;Workspace and Hugging Face cache on &lt;code&gt;/srv/storage&lt;/code&gt;, so model downloads don&amp;rsquo;t fill the system disk:&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;export HF_HOME&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;/srv/storage/backblaze-soup/models/huggingface
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;soup doctor
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;soup doctor&lt;/code&gt; checks the Python, CUDA and library versions before you spend time on a run. It is worth running after every environment change. I learned that later, the hard way.&lt;/p&gt;
&lt;h2 id=&#34;fine-tuning-qwen25-15b-with-qlora&#34;&gt;Fine-tuning Qwen2.5-1.5B with QLoRA&lt;/h2&gt;
&lt;p&gt;I chose &lt;strong&gt;Qwen2.5-1.5B-Instruct&lt;/strong&gt;: small enough to train comfortably on a 3060, and already good at following a &amp;ldquo;reply with JSON&amp;rdquo; instruction.&lt;/p&gt;
&lt;p&gt;Full fine-tuning of 1.5 billion parameters does not fit in 12 GB. &lt;strong&gt;QLoRA&lt;/strong&gt; makes it fit. The base weights are loaded in 4-bit and frozen, and only small low-rank adapter matrices are trained. The adapter is a few megabytes, and the original model stays untouched.&lt;/p&gt;
&lt;p&gt;This is the whole configuration:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;base&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;Qwen/Qwen2.5-1.5B-Instruct&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;task&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;sft&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;backend&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;transformers&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;data&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;train&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;/srv/storage/backblaze-soup/data/train.jsonl&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;format&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;alpaca&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;val_split&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;0.1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;max_length&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;2048&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;train_on_responses_only&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;training&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;epochs&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;lr&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;0.0001&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;batch_size&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;gradient_accumulation_steps&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;8&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;gradient_checkpointing&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;quantization&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;4bit&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;seed&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;42&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;data_seed&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;42&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;logging_steps&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;5&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;save_steps&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;100&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;lora&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;r&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;16&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;alpha&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;32&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;dropout&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;0.05&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;target_modules&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;auto&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;output&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;/srv/storage/backblaze-soup/output/qwen-backblaze-v1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A few choices worth explaining:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Batch size 1 with gradient accumulation 8&lt;/strong&gt; gives an effective batch of 8 without holding eight long sequences in memory at once.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gradient checkpointing&lt;/strong&gt; recomputes activations during the backward pass instead of storing them. It is slower, but it saves memory.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;train_on_responses_only&lt;/code&gt;&lt;/strong&gt; computes loss only on the answer, not on the long SMART history in the prompt. I want the model graded on its prediction, not on copying numbers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;val_split: 0.1&lt;/code&gt;&lt;/strong&gt; means Soup held back 10% of &lt;code&gt;train.jsonl&lt;/code&gt; for its own validation. My separate &lt;code&gt;val.jsonl&lt;/code&gt; was not used during training.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;soup train --config soup.yaml
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The run took 88 optimizer steps and about six minutes, with GPU memory peaking around 3.5 GB. I did not enable layer streaming. The reported loss fell from 1.0847 to 0.0198.&lt;/p&gt;
&lt;p&gt;To see what the GPU was doing during that run, I later reran exactly the same configuration into a separate output folder and watched it in Grafana, with &lt;code&gt;nvidia-smi&lt;/code&gt; sampling once per second alongside. The rerun matched closely: 88 steps, 408 seconds of training (about eight minutes including model and data loading), and loss from 1.0812 to 0.0198. The first logged loss differs slightly from the original 1.0847, a reminder that a fixed seed does not guarantee bit-identical GPU runs.&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;/images/backblaze-v1-gpu-grafana.png&#34; alt=&#34;Grafana dashboard of the RTX 3060 during the QLoRA training rerun: temperature, utilization, power, memory, clocks and fan speed&#34;&gt;&lt;/p&gt;
&lt;p&gt;Two things stand out. Soup reported a 3.5 GB peak, but the GPU showed up to 8.1 GiB in use. My reading is that Soup reports memory PyTorch allocated for tensors, while the driver also counts the CUDA context and memory PyTorch&amp;rsquo;s caching allocator has reserved but not handed out. Both numbers are real; they answer different questions. The card was also busy without being maxed out: once training started, utilization averaged about 92%, power peaked at 133 W against a 170 W limit, and temperature rose from 59 °C to 71 °C. For a 1.5B model in 4-bit with batch size 1, the 3060 has headroom.&lt;/p&gt;
&lt;p&gt;On paper, that looks like success.&lt;/p&gt;
&lt;h2 id=&#34;evaluating-it&#34;&gt;Evaluating it&lt;/h2&gt;
&lt;p&gt;I ran the 168 test examples through the fine-tuned adapter and through the original model, then compared the answers with the labels.&lt;/p&gt;
&lt;p&gt;The fine-tuned model returned valid, bare JSON for all 168 cases. It also answered &lt;code&gt;false&lt;/code&gt; for all 168:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Predicted failure&lt;/th&gt;
&lt;th&gt;Predicted healthy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Actually failed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Actually healthy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Accuracy was 89.3%. Failure recall was 0%. A model that always says &amp;ldquo;no failure&amp;rdquo; gets exactly the same score, because 150 of the 168 test drives are healthy. The fine-tuned model learned the answer format and the majority class, and missed every drive that actually failed.&lt;/p&gt;
&lt;h2 id=&#34;a-bug-in-my-own-evaluation&#34;&gt;A bug in my own evaluation&lt;/h2&gt;
&lt;p&gt;The original model at first looked even worse: my script marked all of its answers invalid. When I inspected the first five, each was perfectly reasonable JSON, wrapped in a Markdown code block:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;```json
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &amp;#34;failure_within_7_days&amp;#34;: false
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;```
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My parser expected bare JSON and rejected the fences. So &amp;ldquo;0% valid&amp;rdquo; described my evaluation code, not the model. Those five answers all predicted &lt;code&gt;false&lt;/code&gt; too, but five answers are not a baseline, and the full corrected comparison is still pending.&lt;/p&gt;
&lt;h2 id=&#34;what-i-take-from-this&#34;&gt;What I take from this&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Local fine-tuning is accessible.&lt;/strong&gt; A consumer GPU, a 1.5B model and QLoRA got me from raw CSVs to a trained adapter on one machine, and the training run itself took six minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A low training loss is not a useful predictor.&lt;/strong&gt; With 90% healthy examples and a tiny fixed answer, the cheapest way to reduce loss is to always say no.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Valid JSON is not a useful predictor either.&lt;/strong&gt; Format compliance is easy to measure and easy to mistake for progress.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accuracy is the wrong headline metric&lt;/strong&gt; for rare events. I&amp;rsquo;ll report failure recall, precision and false alerts from now on.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;next&#34;&gt;Next&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;ve built a balanced training set with 79 failed and 79 healthy drives, so &amp;ldquo;always no&amp;rdquo; stops being a free win. The plan is to train a fresh adapter from the original base model for three epochs on it, and to compare it on the validation set rather than repeatedly on the test set. I also want a tabular baseline, such as XGBoost or LightGBM on the same features, to see whether a language model adds anything at all. Last, I want an evaluation on a later time period before drawing any operational conclusions.&lt;/p&gt;
&lt;p&gt;That second training run has not produced a result yet.&lt;/p&gt;
</content>
    </item>
    
  </channel>
</rss>
