<?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: b1ackjack12</title>
    <description>The latest articles on DEV Community by b1ackjack12 (@b1ackjack12).</description>
    <link>https://dev.to/b1ackjack12</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%2F4178028%2F73382ce8-418c-4b2f-8812-be886daa9b88.png</url>
      <title>DEV Community: b1ackjack12</title>
      <link>https://dev.to/b1ackjack12</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/b1ackjack12"/>
    <language>en</language>
    <item>
      <title>I Slowed My IMU's Clock 4x and Accuracy Went Up</title>
      <dc:creator>b1ackjack12</dc:creator>
      <pubDate>Tue, 03 Nov 2026 01:25:53 +0000</pubDate>
      <link>https://dev.to/b1ackjack12/i-slowed-my-imus-clock-4x-and-accuracy-went-up-4lpe</link>
      <guid>https://dev.to/b1ackjack12/i-slowed-my-imus-clock-4x-and-accuracy-went-up-4lpe</guid>
      <description>&lt;p&gt;&lt;a href="https://b1ackjack12.github.io/posts/har-window-length-measured" rel="noopener noreferrer"&gt;The window-length experiment&lt;/a&gt; ended with a clean dose-response: every halving of my HAR model's 2.56-second window cost accuracy, down to −0.87 points and a −1.98 collapse for the worst subject at 32 samples. But cutting the window short is only one of two ways to shrink an input. The other is to keep the full 2.56 seconds and sample them with a slower clock — the same stretch of walking, described by fewer, sparser measurements. For a wearable, that second knob is the interesting one: the sample rate drives the sensor's power draw, the radio bytes, and the model's FLOPs, and 50 Hz is simply what the dataset shipped with. Nobody chose it for the task.&lt;/p&gt;

&lt;p&gt;So: &lt;a href="https://b1ackjack12.github.io/posts/har-augmentation-fifty-splits" rel="noopener noreferrer"&gt;the usual harness&lt;/a&gt;, 50 paired subject splits, and the 50 Hz windows downsampled to 25, 12.5 and 6.25 Hz over the same time span. Each rate comes in two flavors, because downsampling has a right way and a lazy way — naive striding (&lt;code&gt;x[:, :, ::k]&lt;/code&gt;, just drop samples) versus proper decimation (low-pass filter first, then drop; &lt;code&gt;scipy.signal.decimate&lt;/code&gt;). The gap between those two flavors is itself a measurement, of how much aliasing actually hurts on real signals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Half the data is free. A quarter of it is a raise
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;rate&lt;/th&gt;
&lt;th&gt;samples&lt;/th&gt;
&lt;th&gt;flavor&lt;/th&gt;
&lt;th&gt;accuracy&lt;/th&gt;
&lt;th&gt;Δ vs 50 Hz&lt;/th&gt;
&lt;th&gt;t&lt;/th&gt;
&lt;th&gt;worst subject Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50 Hz&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;92.66 ±2.75&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25 Hz&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;naive&lt;/td&gt;
&lt;td&gt;92.75&lt;/td&gt;
&lt;td&gt;+0.09&lt;/td&gt;
&lt;td&gt;+1.6&lt;/td&gt;
&lt;td&gt;+0.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25 Hz&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;anti-aliased&lt;/td&gt;
&lt;td&gt;92.73&lt;/td&gt;
&lt;td&gt;+0.08&lt;/td&gt;
&lt;td&gt;+1.2&lt;/td&gt;
&lt;td&gt;+0.21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12.5 Hz&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;naive&lt;/td&gt;
&lt;td&gt;92.84&lt;/td&gt;
&lt;td&gt;+0.18&lt;/td&gt;
&lt;td&gt;+2.1&lt;/td&gt;
&lt;td&gt;+0.47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;12.5 Hz&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;32&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;anti-aliased&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;93.12&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.47&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+4.3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+1.93&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6.25 Hz&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;naive&lt;/td&gt;
&lt;td&gt;92.37&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−0.29&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;−2.8&lt;/td&gt;
&lt;td&gt;−0.69&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6.25 Hz&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;anti-aliased&lt;/td&gt;
&lt;td&gt;92.57&lt;/td&gt;
&lt;td&gt;−0.09&lt;/td&gt;
&lt;td&gt;−0.8&lt;/td&gt;
&lt;td&gt;−0.81&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three findings, in ascending order of surprise. First, 25 Hz is simply free: both flavors sit within noise of the baseline, so half the sample rate — half the bytes, half the conv-layer input — costs nothing. Second, at the bottom of the table, 6.25 Hz is where real information finally runs out, and &lt;em&gt;how&lt;/em&gt; you get there matters: naive striding loses a statistically solid −0.29 while the anti-aliased version turns that into a statistical nothing (−0.09, t = −0.8). That gap is textbook aliasing made visible — above-Nyquist energy folding back into the band as noise when you skip the low-pass filter.&lt;/p&gt;

&lt;p&gt;Third, and the reason this post exists: &lt;strong&gt;12.5 Hz with proper decimation beats the full-rate baseline.&lt;/strong&gt; Not "matches" — beats, +0.47 with a t of 4.3, 35 wins in 50 splits. And the gain concentrates exactly where this dataset hurts: the worst subject in each split improves by +1.93 on average, the same hardest-subjects margin that &lt;a href="https://b1ackjack12.github.io/posts/imu-rotation-angle-sweep" rel="noopener noreferrer"&gt;every augmentation experiment&lt;/a&gt; has been fighting for. A quarter of the data, same time span, better model — with the improvement delivered by the step everyone treats as a chore (the anti-aliasing filter), which at this rate doubles the naive flavor's gain (+0.47 vs +0.18).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiqn9c3ria1m3ykrzo1kt.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiqn9c3ria1m3ykrzo1kt.webp" alt="Three flipbooks of a runner: a thick one with smooth motion, a thinner one slightly choppy, and a sparse one where the pose jumps between pages" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The physics was on the label
&lt;/h2&gt;

&lt;p&gt;None of this is mysterious after the fact; it's Nyquist arithmetic. Human gait has its fundamental around 1–2 Hz with discriminative harmonics mostly below ~6 Hz — and the UCI pipeline even low-pass filtered everything at 20 Hz before windowing. Sampling at 12.5 Hz (Nyquist 6.25 Hz) still covers essentially the whole band that distinguishes walking from climbing stairs; 50 Hz was oversampling the activity by an order of magnitude. Push to 6.25 Hz (Nyquist ≈ 3 Hz) and you finally start clipping real harmonics, which is precisely where the table's first significant loss appears. The &lt;em&gt;improvement&lt;/em&gt; at 12.5 Hz plausibly stacks two effects I can't fully separate: the decimation filter throws away high-frequency sensor noise the activity never needed (a free denoiser), and the same fixed-size conv kernels now span 4x more of the gait cycle, seeing whole strides instead of fragments. Both point the same direction; I'll leave their shares unassigned.&lt;/p&gt;

&lt;p&gt;Put today's table next to the window post's and the contrast is the real lesson. Thirty-two samples as a &lt;em&gt;cropped&lt;/em&gt; window (0.64 s of context at 50 Hz): −0.87, worst subjects −1.98. The same thirty-two samples as a &lt;em&gt;decimated&lt;/em&gt; window (full 2.56 s at 12.5 Hz): +0.47, worst subjects +1.93. Identical input tensors, shape-wise — a four-point swing depending on whether those samples cover the whole gait cycle or a fragment of it. The model doesn't need a fast clock; it needs the full sentence, even if every word is shorter.&lt;/p&gt;

&lt;p&gt;For the config file: if you're shipping an IMU model, sweep the sample rate downward before you shrink anything else — my deployment numbers from the &lt;a href="https://b1ackjack12.github.io/posts/cpu-vs-gpu-serving-crossover" rel="noopener noreferrer"&gt;CPU serving post&lt;/a&gt; already showed this model class runs in 66 microseconds at batch 1, and a 4x shorter input only widens that margin while the &lt;em&gt;sensor&lt;/em&gt; (the actual battery cost on a wearable) does 4x less work. Just don't skip the low-pass filter: it's one &lt;code&gt;scipy.signal.decimate&lt;/code&gt; call at training time and a trivial IIR on-device, and it's the difference between a free lunch and a slow leak. Scope: one dataset whose signals were already cleanly filtered at capture, one small CNN, and subjects walking in a lab — a noisier consumer IMU may shift the cliff, which is an argument for running this same cheap sweep on your own data, not for trusting 50 Hz.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://b1ackjack12.github.io/posts/imu-sampling-rate-measured/" rel="noopener noreferrer"&gt;b1ackjack12.github.io&lt;/a&gt; — an AI tech blog where every claim comes with a measurement. More experiments like this one live there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>iot</category>
      <category>sensors</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Weight Decay 0.05: Catastrophe in Adam, Optimum in AdamW</title>
      <dc:creator>b1ackjack12</dc:creator>
      <pubDate>Tue, 03 Nov 2026 01:25:37 +0000</pubDate>
      <link>https://dev.to/b1ackjack12/weight-decay-005-catastrophe-in-adam-optimum-in-adamw-3lna</link>
      <guid>https://dev.to/b1ackjack12/weight-decay-005-catastrophe-in-adam-optimum-in-adamw-3lna</guid>
      <description>&lt;p&gt;Every recipe on this blog since &lt;a href="https://b1ackjack12.github.io/posts/sgd-vs-adam-fixed-budget" rel="noopener noreferrer"&gt;the optimizer face-off&lt;/a&gt; has carried &lt;code&gt;weight_decay=5e-4&lt;/code&gt; — a number I originally picked for SGD because every CIFAR repo on the internet uses it, then ported to Adam when Adam won, with exactly zero measurement of whether it belongs there. That's the precise shape of inherited config this &lt;a href="https://b1ackjack12.github.io/posts/label-smoothing-dose-response" rel="noopener noreferrer"&gt;dose-response series&lt;/a&gt; exists to audit. So: Adam at four doses spanning 2.5 orders of magnitude plus a zero control, AdamW at the matching doses, five seeds each on the usual 10-epoch CIFAR ResNet18 — forty-five runs, with each optimizer's arms compared against its own wd=0 baseline on identical seeds.&lt;/p&gt;

&lt;p&gt;The reason to sweep &lt;em&gt;both&lt;/em&gt; optimizers is that "weight decay" is secretly two different mechanisms sharing a keyword argument. In Adam, the classic implementation adds &lt;code&gt;wd * w&lt;/code&gt; to the &lt;em&gt;gradient&lt;/em&gt;, which then flows through the adaptive machinery and gets divided by the running RMS of the gradients. In AdamW, the decay is decoupled — subtracted directly from the weights, scaled only by the learning rate, never touching the adaptive denominator. Same config key, different algorithms. The question a sweep can answer is whether that difference is pedantry or points.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two curves, two orders of magnitude apart
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;optimizer&lt;/th&gt;
&lt;th&gt;wd&lt;/th&gt;
&lt;th&gt;accuracy (5 seeds)&lt;/th&gt;
&lt;th&gt;Δ vs own wd=0&lt;/th&gt;
&lt;th&gt;t&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Adam&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;89.32 ±0.29&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adam&lt;/td&gt;
&lt;td&gt;1e-4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;89.81 ±0.16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.49&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+3.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adam&lt;/td&gt;
&lt;td&gt;5e-4&lt;/td&gt;
&lt;td&gt;89.77 ±0.33&lt;/td&gt;
&lt;td&gt;+0.46&lt;/td&gt;
&lt;td&gt;+3.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adam&lt;/td&gt;
&lt;td&gt;5e-3&lt;/td&gt;
&lt;td&gt;87.33 ±0.35&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−1.99&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;−11.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adam&lt;/td&gt;
&lt;td&gt;5e-2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64.97 ±5.39&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−24.34&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;−10.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AdamW&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;89.32 ±0.14&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AdamW&lt;/td&gt;
&lt;td&gt;5e-4&lt;/td&gt;
&lt;td&gt;89.29 ±0.26&lt;/td&gt;
&lt;td&gt;−0.03&lt;/td&gt;
&lt;td&gt;−0.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AdamW&lt;/td&gt;
&lt;td&gt;5e-3&lt;/td&gt;
&lt;td&gt;89.32 ±0.21&lt;/td&gt;
&lt;td&gt;+0.00&lt;/td&gt;
&lt;td&gt;+0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AdamW&lt;/td&gt;
&lt;td&gt;5e-2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;89.66 ±0.15&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.34&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+3.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read Adam's column first: it's the &lt;a href="https://b1ackjack12.github.io/posts/ema-decay-dose-response" rel="noopener noreferrer"&gt;EMA-decay topology&lt;/a&gt; again — a shelf, a cliff, a crater. The benefit saturates at the smallest dose I tried (1e-4 already buys the full +0.49; 5e-4 adds nothing more), then 5e-3 falls off a cliff, and 5e-2 doesn't converge so much as thrash — 65% mean accuracy with a ±5.4 standard deviation, the largest variance any arm has posted on this blog. My inherited 5e-4, for once, sits safely mid-shelf; the audit acquits it.&lt;/p&gt;

&lt;p&gt;Now read AdamW's column, which is the same curve shifted right by roughly two orders of magnitude. At the doses where Adam does all its living and dying — 5e-4, 5e-3 — AdamW registers &lt;em&gt;nothing&lt;/em&gt;: deltas of −0.03 and +0.00, flat zeros. Its effect only begins at 5e-2, the exact dose that costs Adam twenty-four points. One number, two optimizers: catastrophe in one, optimum in the other. Copying a &lt;code&gt;weight_decay&lt;/code&gt; between Adam and AdamW isn't tuning, it's &lt;a href="https://b1ackjack12.github.io/posts/ema-weights-measured" rel="noopener noreferrer"&gt;copying a time constant between schedules&lt;/a&gt; — the same mistake this blog has now measured in its third costume.&lt;/p&gt;

&lt;p&gt;The ~100x shift even has a plausible size, not just a direction. Decoupled decay shrinks weights by &lt;code&gt;lr * wd&lt;/code&gt; per step — at lr 1e-3 and wd 5e-2, that's 5e-5 of each weight. Coupled decay rides the adaptive update, where the gradient (decay term included) gets divided by the running gradient RMS; if that RMS sits around 10⁻², the division inflates the decay's bite by roughly 100x, so Adam at wd 5e-4 lands at about the same effective shrinkage — 5e-5 — as AdamW at 5e-2. Two arms I measured as equally optimal, agreeing through arithmetic I didn't tune to make them agree. I'll flag it as consistency rather than proof (the RMS varies per weight and per epoch; that's the &lt;em&gt;point&lt;/em&gt; of the coupling being a bug), but the curves' alignment is hard to read any other way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fye6cp4vgqckt8vabztz3.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fye6cp4vgqckt8vabztz3.webp" alt="Five identical plants watered with increasing doses: parched, healthy, thriving, drooping, and fully submerged" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The screening I owe a correction
&lt;/h2&gt;

&lt;p&gt;One row above is a confession. September's face-off chose AdamW's dose by single-seed screening, and that screening said 0.005 beat 0.05. Today's five-seed sweep says the opposite: 0.05 is better by +0.34 (t = 3.0), and 0.005 is indistinguishable from &lt;em&gt;no decay at all&lt;/em&gt;. The screening seed flipped a coin and I published the coin flip — exactly the failure mode the &lt;a href="https://b1ackjack12.github.io/posts/seed-noise-vs-real-improvements" rel="noopener noreferrer"&gt;seed-noise post&lt;/a&gt; warned about and &lt;a href="https://b1ackjack12.github.io/posts/pytorch-same-seed-different-model" rel="noopener noreferrer"&gt;the nondeterminism post&lt;/a&gt; explained, now caught in my own pipeline. For the record, the correction doesn't change the face-off's verdict: AdamW at its true best dose reaches 89.66, which still only ties Adam's 89.77–89.81 at this budget. Adam survives as the house optimizer; its challenger just deserved a fairer fight than one seed gave it.&lt;/p&gt;

&lt;p&gt;Config-file summary, in the series' usual shape. For Adam at lr 1e-3 on a run like mine: anything in 1e-4–5e-4 is on the shelf and the choice within it doesn't matter; 10x more is a cliff. For AdamW: the PyTorch default of 0.01 sits comfortably inside the active range (my 5e-2 arm is the shelf's measured edge in the good direction), and anything at Adam-ish magnitudes is a silent no-op — you think you're regularizing, and you're paying for nothing. And across optimizers: never port the number. Not because of superstition, but because the units differ — one is a gradient-space dose filtered through adaptive statistics, the other a direct fraction of the weights per step. The config key being spelled identically is the trap. Scope: one model, one dataset, one 10-epoch cosine budget, lr fixed at 1e-3 — wd and lr interact (decoupled decay scales &lt;em&gt;with&lt;/em&gt; lr), so these shelf locations travel only as far as the recipe does. The shape, I'd bet on; the x-axis, re-measure.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://b1ackjack12.github.io/posts/adam-weight-decay-dose-response/" rel="noopener noreferrer"&gt;b1ackjack12.github.io&lt;/a&gt; — an AI tech blog where every claim comes with a measurement. More experiments like this one live there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pytorch</category>
      <category>machinelearning</category>
      <category>deeplearning</category>
      <category>hyperparameters</category>
    </item>
    <item>
      <title>EMA's Decay Isn't a Dial. It's a Plateau, a Cliff, and a 10%-Accuracy Crater</title>
      <dc:creator>b1ackjack12</dc:creator>
      <pubDate>Wed, 21 Oct 2026 23:43:08 +0000</pubDate>
      <link>https://dev.to/b1ackjack12/emas-decay-isnt-a-dial-its-a-plateau-a-cliff-and-a-10-accuracy-crater-294</link>
      <guid>https://dev.to/b1ackjack12/emas-decay-isnt-a-dial-its-a-plateau-a-cliff-and-a-10-accuracy-crater-294</guid>
      <description>&lt;p&gt;&lt;a href="https://b1ackjack12.github.io/posts/ema-weights-measured" rel="noopener noreferrer"&gt;September's EMA experiment&lt;/a&gt; measured exactly two decay values on the SGD recipe and left a dangling thread: 0.999 catastrophically bad, 0.99 cosmetically good, nothing in between and nothing beyond. Then the &lt;a href="https://b1ackjack12.github.io/posts/stacking-the-months-wins" rel="noopener noreferrer"&gt;stacking post&lt;/a&gt; carried decay 0.99 onto the Adam recipe without re-measuring it — an inherited number of precisely the kind this blog keeps catching in other people's configs. The fix is the same one that worked for &lt;a href="https://b1ackjack12.github.io/posts/label-smoothing-dose-response" rel="noopener noreferrer"&gt;label smoothing&lt;/a&gt;: stop defending a point and sweep the curve. Six decays — 0.9, 0.97, 0.99, 0.995, 0.999, 0.9999 — on the tuned Adam recipe, ten seeds.&lt;/p&gt;

&lt;p&gt;The instrument deserves a sentence, because it's the best part. EMA's audit trick from September scales: &lt;em&gt;all six&lt;/em&gt; shadows ride inside the same training run, updated from the same weight trajectory after every optimizer step. One run yields seven evaluations — raw plus six decays — so every comparison below is paired at the strongest possible level: same seed, same batches, same trajectory, different averaging window. The entire six-arm, ten-seed sweep cost ten training runs, about twenty-five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A shelf, then a cliff, then a crater
&lt;/h2&gt;

&lt;p&gt;Each decay's effective averaging window is roughly 1/(1−d) optimizer steps, against a total budget of 3,900 steps. That number turns out to be the only thing on the table that matters.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;decay&lt;/th&gt;
&lt;th&gt;window&lt;/th&gt;
&lt;th&gt;accuracy (10 seeds)&lt;/th&gt;
&lt;th&gt;paired delta vs raw&lt;/th&gt;
&lt;th&gt;t&lt;/th&gt;
&lt;th&gt;wins&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;raw weights&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;89.75 ±0.18&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;td&gt;~10 steps&lt;/td&gt;
&lt;td&gt;89.80&lt;/td&gt;
&lt;td&gt;+0.050&lt;/td&gt;
&lt;td&gt;+2.7&lt;/td&gt;
&lt;td&gt;8/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.97&lt;/td&gt;
&lt;td&gt;~33&lt;/td&gt;
&lt;td&gt;89.86&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.109&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+3.6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;~100&lt;/td&gt;
&lt;td&gt;89.85&lt;/td&gt;
&lt;td&gt;+0.104&lt;/td&gt;
&lt;td&gt;+2.0&lt;/td&gt;
&lt;td&gt;8/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.995&lt;/td&gt;
&lt;td&gt;~200&lt;/td&gt;
&lt;td&gt;89.83&lt;/td&gt;
&lt;td&gt;+0.080&lt;/td&gt;
&lt;td&gt;+1.4&lt;/td&gt;
&lt;td&gt;6/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.999&lt;/td&gt;
&lt;td&gt;~1,000&lt;/td&gt;
&lt;td&gt;85.00&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−4.75&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;−18.4&lt;/td&gt;
&lt;td&gt;0/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.9999&lt;/td&gt;
&lt;td&gt;~10,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11.55&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;−78.2&lt;/td&gt;
&lt;td&gt;−159&lt;/td&gt;
&lt;td&gt;0/10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the top four rows first. Across a 20x range of window sizes — 10 steps to 200 — the paired deltas are +0.05 to +0.11, all within a whisker of each other. There is no optimum to find up here; it's a shelf. The agonizing-over-decimals question ("0.97 or 0.99 or 0.995?") that EMA discussions love is, on this recipe, worth at most six hundredths of a point, and the pairing is tight enough to say so with confidence: the best row (0.97, t = 3.6) is statistically real, but its advantage over its neighbors isn't. Any window that fits comfortably inside the cosine schedule's annealed tail buys the same small, genuine, cosmetic improvement — consistent with September's hypothesis that a schedule annealing to zero already delivers the endpoint pre-smoothed, leaving EMA little to do.&lt;/p&gt;

&lt;p&gt;Then the window crosses out of the tail and the curve stops being polite. At 1,000 steps — a quarter of the whole run — the shadow blends in weights from epochs where the learning rate was still high and the model measurably worse: −4.75, ten losses in ten. That's steeper than the −3.08 the same decay cost on &lt;a href="https://b1ackjack12.github.io/posts/sgd-vs-adam-fixed-budget" rel="noopener noreferrer"&gt;the SGD recipe&lt;/a&gt;, so the cliff isn't an SGD quirk; if anything Adam's early trajectory is worse to remember. And at 10,000 steps the window is now 2.5x &lt;em&gt;longer than the entire training run&lt;/em&gt;, which means the shadow is approximately a uniform average over the whole trajectory — including the random initialization it started from. That model scores 11.55 ±1.55 percent on a 10-class problem. Not degraded: statistically indistinguishable from guessing. The same codebase, the same run, the same trick that earns +0.1 at decay 0.97 hands you a coin-flipper at 0.9999, four keystrokes away.&lt;/p&gt;

&lt;p&gt;One more diagnostic hiding in the deltas: their seed-to-seed standard deviation grows monotonically with the window — 0.06 at decay 0.9, 0.16 at 0.99, 0.18 at 0.995, 0.82 at 0.999. The longer the window, the more of the run's noisy history it drags into the final weights, so even &lt;em&gt;before&lt;/em&gt; the cliff you can see the averaging reaching back into territory that varies run by run. The shelf isn't just flat in the mean; it gets quietly riskier toward its edge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpy8duhv8xl7i10jhu9s3.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpy8duhv8xl7i10jhu9s3.webp" alt="A dog walker with six leashes of wildly different lengths: the short-leash dogs trot in step at the heel, while the dog on the longest leash is still far back down the path" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The knob is secretly binary
&lt;/h2&gt;

&lt;p&gt;The honest summary of this curve: EMA's decay is not a dial that trades off more-versus-less smoothing benefit. The benefit is a constant — about +0.1 on this recipe, take it or leave it — and the decay only decides whether you get that constant or walk off a cliff. A continuous hyperparameter with a binary outcome: &lt;em&gt;does the averaging window fit inside the schedule's converged tail, or doesn't it?&lt;/em&gt; Every value that fits pays the same; every value that doesn't is somewhere on the slope between −4.75 and −78.&lt;/p&gt;

&lt;p&gt;Which makes the config-file advice almost embarrassingly simple. Compute 1/(1−d), compare it to your total step count, and keep it in the low single-digit percents — for my 3,900-step runs anything from 0.9 to 0.995 is on the shelf, and I'll keep writing 0.99 since the &lt;a href="https://b1ackjack12.github.io/posts/stacking-the-months-wins" rel="noopener noreferrer"&gt;stacking recipe's&lt;/a&gt; choice turns out to have been safely mid-shelf all along. What you should &lt;em&gt;not&lt;/em&gt; do is copy a decay out of a repo that trains 100x longer, because the number doesn't carry — a lesson that is now measured twice, on two optimizers, with the second measurement steeper than the first. Scope as always: one model, one dataset, one 10-epoch budget, and the shelf's height (+0.1) is specific to short cosine schedules — on constant LR or longer runs EMA has real work to do and the curve's left half will look different. The cliff's location, though, is arithmetic, and arithmetic travels.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://b1ackjack12.github.io/posts/ema-decay-dose-response/" rel="noopener noreferrer"&gt;b1ackjack12.github.io&lt;/a&gt; — an AI tech blog where every claim comes with a measurement. More experiments like this one live there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pytorch</category>
      <category>machinelearning</category>
      <category>deeplearning</category>
      <category>hyperparameters</category>
    </item>
    <item>
      <title>One Horizontal Flip at Test Time Beat Every Training Trick I've Measured</title>
      <dc:creator>b1ackjack12</dc:creator>
      <pubDate>Tue, 20 Oct 2026 02:26:43 +0000</pubDate>
      <link>https://dev.to/b1ackjack12/one-horizontal-flip-at-test-time-beat-every-training-trick-ive-measured-44ba</link>
      <guid>https://dev.to/b1ackjack12/one-horizontal-flip-at-test-time-beat-every-training-trick-ive-measured-44ba</guid>
      <description>&lt;p&gt;Test-time augmentation has a reputation problem: it's the trick Kaggle winners confess to in the last paragraph, the thing production engineers wave off as leaderboard cosmetics. The idea is almost embarrassingly simple — run inference on several transformed copies of the input, average the probabilities — and because it multiplies serving cost, most advice online rounds it down to "not worth it in the real world" without a number attached. After a month of measuring training tricks whose true effects turned out to be &lt;a href="https://b1ackjack12.github.io/posts/ema-weights-measured" rel="noopener noreferrer"&gt;+0.08&lt;/a&gt;, &lt;a href="https://b1ackjack12.github.io/posts/seed-noise-vs-real-improvements" rel="noopener noreferrer"&gt;+0.14&lt;/a&gt;, or &lt;a href="https://b1ackjack12.github.io/posts/har-augmentation-fifty-splits" rel="noopener noreferrer"&gt;zero&lt;/a&gt;, I wanted TTA on the same scale. Ten seeds of the usual tuned CIFAR ResNet18 recipe, each model evaluated four ways — plain, mirror-flip averaging, five 2-pixel shifts, and shifts-plus-flips — with every comparison paired within run, plus clean warmed-up GPU timings for the actual price tag.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (2026-10-22):&lt;/strong&gt; the cost column below is the &lt;em&gt;worst case&lt;/em&gt; — these timings run each view as a separate forward pass at a saturating batch size. A good question about batching the views together prompted &lt;a href="https://b1ackjack12.github.io/posts/tta-serving-price-measured/" rel="noopener noreferrer"&gt;a full repricing&lt;/a&gt;: with views concatenated into one batch, flip TTA costs ~1.0–1.4x at small serving batches, and the full 10-view set drops to 1.08x at batch 1. The accuracy numbers here are unaffected.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The table that made me sit up
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;view set&lt;/th&gt;
&lt;th&gt;views&lt;/th&gt;
&lt;th&gt;paired delta vs plain&lt;/th&gt;
&lt;th&gt;wins&lt;/th&gt;
&lt;th&gt;inference cost (512 imgs)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;plain&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;24.8 ms (1.0x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;+ horizontal flip&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.56 (t = 11.5)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10/10&lt;/td&gt;
&lt;td&gt;49.5 ms (2.0x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 shifts&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;+0.27 (t = 7.3)&lt;/td&gt;
&lt;td&gt;10/10&lt;/td&gt;
&lt;td&gt;123.9 ms (5.0x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 shifts × flips&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;+0.71 (t = 19.9)&lt;/td&gt;
&lt;td&gt;10/10&lt;/td&gt;
&lt;td&gt;247.6 ms (10.0x)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Baseline 89.00 ±0.15 across seeds. For calibration: this blog's whole training-side campaign — EMA sweeps, label smoothing, augmentation tournaments — has been fighting over effects between +0.08 and +0.36, most of them needing ten-plus seeds and paired statistics just to be visible at all. &lt;strong&gt;Averaging each prediction with its mirror image gained +0.56, won in ten runs out of ten, and required changing nothing about training whatsoever.&lt;/strong&gt; No schedule, no optimizer, no data pipeline — four lines in the eval loop. It is, by a factor of roughly seven over EMA, the largest reliable accuracy gain this hardware has produced, and the paired standard deviation of 0.15 means it isn't remotely close to the noise floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The views are not equal, and the ordering is telling
&lt;/h2&gt;

&lt;p&gt;The naive model of TTA — more views, more better — dies in row three. Four extra translation views bought +0.27 in total; one extra flip view bought +0.56. &lt;strong&gt;A single flip is worth more than four shifts combined&lt;/strong&gt;, per-view a difference of nearly an order of magnitude. And the reason is visible from the training config: the model trained with &lt;code&gt;RandomHorizontalFlip&lt;/code&gt;, so left-right mirroring is a symmetry the data genuinely has and the model was explicitly taught to respect — averaging over it cancels the model's residual asymmetry almost for free. Two-pixel translations are a weaker, blurrier invariance; the views mostly agree with the original and contribute mostly redundancy. The combined ten-view set confirms the diminishing returns: going from 2 views to 10 quintuples the compute and adds just +0.15 on top of the flip. TTA is not a volume business. Pick the transformations that match your data's true symmetries — which in practice means the ones in your training augmentation — and stop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F11is6atld9r47wuznu5q.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F11is6atld9r47wuznu5q.webp" alt="A hall of mirrors where one true mirror reveals a clear second view of a figure, while a row of slightly-shifted funhouse mirrors just show blurry near-duplicates" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  So why doesn't everyone ship it?
&lt;/h2&gt;

&lt;p&gt;Because of &lt;em&gt;where&lt;/em&gt; the bill arrives. A training trick costs once, on your GPU, before deployment; TTA costs at serving time, on every request, forever — the 2.0x in the table is exactly the throughput cut your serving fleet takes, with none of the muddiness of &lt;a href="https://b1ackjack12.github.io/posts/gpu-inference-batching-measured" rel="noopener noreferrer"&gt;the batching curves&lt;/a&gt; to hide behind (the timing scaled exactly with view count, 2.0x/5.0x/10.0x, no fixed overhead to amortize). Whether that trade is good depends entirely on which side of the meter you live on. If your model serves millions of requests a day at tight latency, +0.56 for double the fleet is probably a bad deal. If you run batch evaluation, offline scoring, medical or safety-critical inference where accuracy is the whole point — half-price throughput for the biggest single gain I've measured is a bargain, and you can have it this afternoon.&lt;/p&gt;

&lt;p&gt;The scope line, as always: one dataset, one small model, 32×32 images where a 2-pixel shift is proportionally large; flip-TTA only makes sense for data with genuine mirror symmetry (photos yes, digits and text no — a mirrored "3" is not a "3"). But the meta-lesson travels: the cheapest accuracy on the menu wasn't in the training loop, where I'd spent a month digging for scraps with paired t-tests. It was sitting in the eval loop, dismissed as a competition hack, waiting for someone to actually price it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://b1ackjack12.github.io/posts/test-time-augmentation-measured/" rel="noopener noreferrer"&gt;b1ackjack12.github.io&lt;/a&gt; — an AI tech blog where every claim comes with a measurement. More experiments like this one live there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pytorch</category>
      <category>machinelearning</category>
      <category>deeplearning</category>
      <category>computervision</category>
    </item>
    <item>
      <title>The GPU Has a 0.8 ms Floor, and My Sensor Model Lives Under It</title>
      <dc:creator>b1ackjack12</dc:creator>
      <pubDate>Mon, 19 Oct 2026 05:14:23 +0000</pubDate>
      <link>https://dev.to/b1ackjack12/the-gpu-has-a-08-ms-floor-and-my-sensor-model-lives-under-it-32p3</link>
      <guid>https://dev.to/b1ackjack12/the-gpu-has-a-08-ms-floor-and-my-sensor-model-lives-under-it-32p3</guid>
      <description>&lt;p&gt;I've measured &lt;a href="https://b1ackjack12.github.io/posts/cpu-inference-threading-measured" rel="noopener noreferrer"&gt;CPU serving&lt;/a&gt; and &lt;a href="https://b1ackjack12.github.io/posts/gpu-inference-batching-measured" rel="noopener noreferrer"&gt;GPU batching&lt;/a&gt; in separate posts, which quietly dodged the question that actually decides a deployment: &lt;em&gt;for this specific model, do I need a GPU at all?&lt;/em&gt; The folk answer is "GPU is faster, CPU is cheaper," which treats speed as a property of the hardware. It isn't — it's a property of the pairing. So, the head-to-head: four models spanning my whole zoo (the 0.1M-parameter HAR CNN, the 1.15M SmallResNet, ResNet18, ResNet50), each served both ways — CPU via ONNX Runtime at default settings, GPU via PyTorch fp16 &lt;em&gt;including&lt;/em&gt; the host-to-device copy from pinned memory, because your production inputs don't start life on the GPU — at batch sizes 1, 16 and 128.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table with a diagonal line through it
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;batch 1&lt;/th&gt;
&lt;th&gt;batch 16&lt;/th&gt;
&lt;th&gt;batch 128&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HAR CNN (0.1M)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;CPU wins 11.7x&lt;/strong&gt; (0.066 vs 0.77 ms)&lt;/td&gt;
&lt;td&gt;GPU 1.3x&lt;/td&gt;
&lt;td&gt;GPU 8.8x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmallResNet (1.15M)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;CPU wins 1.6x&lt;/strong&gt; (0.57 vs 0.91 ms)&lt;/td&gt;
&lt;td&gt;GPU 8.9x&lt;/td&gt;
&lt;td&gt;GPU 61.8x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ResNet18 (11M)&lt;/td&gt;
&lt;td&gt;GPU 2.5x&lt;/td&gt;
&lt;td&gt;GPU 39.5x&lt;/td&gt;
&lt;td&gt;GPU 51.5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ResNet50 (24M)&lt;/td&gt;
&lt;td&gt;GPU 2.4x&lt;/td&gt;
&lt;td&gt;GPU 39.3x&lt;/td&gt;
&lt;td&gt;GPU 38.8x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the batch-1 GPU column of the two small models: 0.77 ms and 0.91 ms — nearly the same number for models that differ 10x in size. That's the tell. At this scale the GPU isn't computing; it's &lt;em&gt;administrating&lt;/em&gt; — kernel launches, the H2D copy, synchronization — and that overhead has a floor around &lt;strong&gt;0.8 ms on my machine&lt;/strong&gt; that exists regardless of how little work follows it. The HAR model's entire forward pass costs 0.066 ms on a CPU, so the GPU loses by an order of magnitude before its first kernel finishes queuing; &lt;a href="https://b1ackjack12.github.io/posts/ort-graph-optimization-levels" rel="noopener noreferrer"&gt;quantize the model to int8&lt;/a&gt; and CPU's margin stretches to nearly 30x. The rule this table teaches is pleasantly mechanical: &lt;strong&gt;measure your model's CPU batch-1 latency, and if it's under the GPU's overhead floor, the GPU cannot win that workload&lt;/strong&gt; — not with more money, not with a better card, because you'd be paying to accelerate the part that was never the bottleneck.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgblwbqaj1yvxmht8ldrw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgblwbqaj1yvxmht8ldrw.webp" alt="A tiny package being loaded onto a massive cargo freighter whose loading crane takes longer than a bicycle courier who has already delivered it" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One batch dimension changes the winner everywhere
&lt;/h2&gt;

&lt;p&gt;The same HAR model that humiliated the GPU at batch 1 loses at batch 16 and loses 8.8x at batch 128 — and notice &lt;em&gt;how&lt;/em&gt; the GPU wins: its latency barely moves (0.77 → 0.54 → 0.61 ms) while CPU time grows linearly with work. The GPU's overhead floor doesn't shrink; it &lt;em&gt;amortizes&lt;/em&gt;. Feed it 128 inputs and the fixed cost divides by 128 while the compute — the thing GPUs are actually good at — finally dominates. SmallResNet makes the same point louder: a 62x GPU win at batch 128, from a model that CPU served faster at batch 1. There is no such thing as "this model is faster on GPU"; there is only "this model &lt;em&gt;at this batch size&lt;/em&gt; is faster on GPU," and the flip can happen between batch 1 and batch 16.&lt;/p&gt;

&lt;p&gt;Which turns the deployment question into a question about your &lt;em&gt;traffic&lt;/em&gt;, not your model. Online serving of single sensor windows — the natural shape of an IMU product, one prediction per device per moment — is a batch-1 workload, and for my HAR model the correct hardware is the CPU it's already running on: faster, simpler, no CUDA driver in the container, no GPU instance on the bill. Offline scoring, backfills, or anything where requests can pool for even a few milliseconds is a batched workload, and there the GPU is worth 9–62x even for tiny models. The &lt;a href="https://b1ackjack12.github.io/posts/cpu-inference-threading-measured" rel="noopener noreferrer"&gt;threading post's&lt;/a&gt; concurrency results compose with this: a fleet of single-threaded CPU sessions is the batch-1 answer, dynamic batching onto a GPU is the throughput answer, and the worst configuration in the whole space is the default one — a big model on CPU (ResNet50 at batch 128: 1.7 &lt;em&gt;seconds&lt;/em&gt; per batch) or a tiny model on GPU, each paying for the other's hardware. Scope: one desktop CPU, one consumer GPU, PyTorch eager fp16 rather than TensorRT — a serving-optimized runtime would lower the GPU floor somewhat, but it can't repeal it, and under it my smallest model comfortably lives.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://b1ackjack12.github.io/posts/cpu-vs-gpu-serving-crossover/" rel="noopener noreferrer"&gt;b1ackjack12.github.io&lt;/a&gt; — an AI tech blog where every claim comes with a measurement. More experiments like this one live there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>mlops</category>
      <category>onnx</category>
      <category>performance</category>
    </item>
    <item>
      <title>The One Knob I Never Swept Was the Optimizer. Adam Beat My Tuned SGD by 0.7</title>
      <dc:creator>b1ackjack12</dc:creator>
      <pubDate>Fri, 16 Oct 2026 00:02:27 +0000</pubDate>
      <link>https://dev.to/b1ackjack12/the-one-knob-i-never-swept-was-the-optimizer-adam-beat-my-tuned-sgd-by-07-1gep</link>
      <guid>https://dev.to/b1ackjack12/the-one-knob-i-never-swept-was-the-optimizer-adam-beat-my-tuned-sgd-by-07-1gep</guid>
      <description>&lt;p&gt;When I ranked &lt;a href="https://b1ackjack12.github.io/posts/training-knob-effect-sizes" rel="noopener noreferrer"&gt;training knobs by effect size&lt;/a&gt;, I swept the learning rate, the weight decay, the batch size and the augmentation — and never once questioned the &lt;code&gt;torch.optim.SGD&lt;/code&gt; at the top of the file. That wasn't a decision; it was a reflex backed by the most durable folklore in deep learning: &lt;em&gt;SGD generalizes better than Adam for convnets&lt;/em&gt;. Every baseline this blog has produced — the &lt;a href="https://b1ackjack12.github.io/posts/seed-noise-vs-real-improvements" rel="noopener noreferrer"&gt;seed-noise study&lt;/a&gt;, the &lt;a href="https://b1ackjack12.github.io/posts/ema-weights-measured" rel="noopener noreferrer"&gt;EMA sweep&lt;/a&gt;, the &lt;a href="https://b1ackjack12.github.io/posts/early-stopping-checkpoint-selection-measured" rel="noopener noreferrer"&gt;early-stopping autopsy&lt;/a&gt; — sits on that reflex. So, a fair fight at my standard fixed budget (CIFAR-10 ResNet18, 10 epochs, cosine): each optimizer family gets a one-seed screening grid over its own learning rates and weight decays, the winner of each grid advances to a five-seed final. No optimizer gets my SGD-tuned hyperparameters imposed on it — that's how these comparisons usually get rigged, in either direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The final
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;optimizer (tuned)&lt;/th&gt;
&lt;th&gt;test accuracy (5 seeds)&lt;/th&gt;
&lt;th&gt;vs SGD&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SGD, lr 0.05, wd 5e-4&lt;/td&gt;
&lt;td&gt;88.95 ±0.31&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Adam, lr 1e-3, wd 5e-4 (coupled L2)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;89.66 ±0.14&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.71&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AdamW, lr 1e-3, wd 0.005&lt;/td&gt;
&lt;td&gt;89.18 ±0.16&lt;/td&gt;
&lt;td&gt;+0.23&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Adam wins by +0.71 — nearly five standard errors of the difference (t ≈ 4.7), larger than every training-side effect I've measured this month, and it comes with a bonus I didn't expect: &lt;strong&gt;less than half the seed variance&lt;/strong&gt; (±0.14 against SGD's ±0.31). The optimizer I never swept was worth nine EMAs. For a blog whose founding sin was &lt;a href="https://b1ackjack12.github.io/posts/batch-512-textbook-fixes-measured" rel="noopener noreferrer"&gt;publishing untested defaults&lt;/a&gt;, there's something fitting about discovering that the deepest default of all — the one below the hyperparameters, the thing the hyperparameters belong &lt;em&gt;to&lt;/em&gt; — was costing seven-tenths of a point the whole time.&lt;/p&gt;

&lt;p&gt;But the folklore isn't wrong so much as &lt;em&gt;scoped&lt;/em&gt;: "SGD generalizes better" comes from the 100-to-200-epoch regime where SGD's noisier trajectory has time to pay off. At 10 epochs, the race is mostly about how fast you can descend, and adaptive per-parameter step sizes are simply better at descending. My result doesn't overturn the long-schedule claim — it bounds it. Optimizer advice without a schedule attached is as incomplete as &lt;a href="https://b1ackjack12.github.io/posts/batch-512-textbook-fixes-measured" rel="noopener noreferrer"&gt;LR-scaling advice without one&lt;/a&gt;, and short fine-tuning-length budgets are where most practical training now happens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0nq85e2l21gqc40c2ha3.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0nq85e2l21gqc40c2ha3.webp" alt="Two racers on a short track: a steady long-distance runner built for marathons losing to a sprinter perfectly geared for the distance actually being run" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two footnotes that earn their keep
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Plain Adam beat AdamW.&lt;/strong&gt; The modern reflex says always AdamW — decoupled weight decay is &lt;em&gt;correct&lt;/em&gt;, coupled L2 in Adam is a known theoretical wart. My tuned AdamW finals at 89.18 against plain Adam's 89.66: the theoretically wrong optimizer won by half a point. I screened AdamW at wd 0.05 and 0.005 (89.50 and 89.70 on the screening seed) so it wasn't sabotaged by an untuned decay; the coupled variant just worked better on this short schedule and small dataset. Theoretical cleanliness and empirical performance are, once again, separate axes. Also worth knowing: Adam's learning-rate cliff is real and close — at lr 3e-3 it scored 87.67, worse than everything in the table, so its lead lives inside a narrower LR window than SGD's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The screening flattered the winner.&lt;/strong&gt; Adam's screening run scored 90.02; the identical config and seed in the finals scored 89.70. Same seed, same code, same GPU — a 0.32 gap, courtesy of &lt;a href="https://b1ackjack12.github.io/posts/pytorch-same-seed-different-model" rel="noopener noreferrer"&gt;the GPU non-determinism I measured in July&lt;/a&gt;. One-seed screening numbers are for &lt;em&gt;choosing&lt;/em&gt; configs, never for &lt;em&gt;reporting&lt;/em&gt; them; if I'd quoted the screen, a third of Adam's headline margin would have been atomics and floating-point reassociation. Scope line: one dataset, one small model, one short budget — flip any of those and the ranking may flip with it, which is precisely the point. The optimizer is a hyperparameter. Sweep it like one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://b1ackjack12.github.io/posts/sgd-vs-adam-fixed-budget/" rel="noopener noreferrer"&gt;b1ackjack12.github.io&lt;/a&gt; — an AI tech blog where every claim comes with a measurement. More experiments like this one live there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pytorch</category>
      <category>machinelearning</category>
      <category>deeplearning</category>
      <category>datascience</category>
    </item>
    <item>
      <title>I Set Every Seed and Trained Twice. The Models Diverged at Step 1</title>
      <dc:creator>b1ackjack12</dc:creator>
      <pubDate>Mon, 12 Oct 2026 01:16:31 +0000</pubDate>
      <link>https://dev.to/b1ackjack12/i-set-every-seed-and-trained-twice-the-models-diverged-at-step-1-2h5b</link>
      <guid>https://dev.to/b1ackjack12/i-set-every-seed-and-trained-twice-the-models-diverged-at-step-1-2h5b</guid>
      <description>&lt;p&gt;There's a ritual at the top of every training script, mine included: &lt;code&gt;torch.manual_seed(0)&lt;/code&gt;, &lt;code&gt;np.random.seed(0)&lt;/code&gt;, and the comfortable belief that the run is now reproducible. I've typed it for years. Last week, while chasing a one-point accuracy difference I couldn't explain, I finally tested the belief directly: same seed, same data order, same model init, same machine, same everything — train ResNet18 for 200 steps, twice, and compare.&lt;/p&gt;

&lt;p&gt;The two loss curves matched at step 0 and &lt;strong&gt;diverged at step 1&lt;/strong&gt;. After 200 steps, the largest difference between corresponding weights was 0.237 — for parameters that mostly live between −0.1 and 0.1, that's not drift, that's a different model. Both runs were "seeded." Neither was reproducible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the randomness sneaks in
&lt;/h2&gt;

&lt;p&gt;The seeds did their job: initialization, data order, and every explicit random draw were identical, which is why step 0 agreed perfectly. The divergence enters in the &lt;em&gt;backward pass&lt;/em&gt;, and it isn't randomness in the usual sense at all — it's floating-point arithmetic meeting parallel hardware.&lt;/p&gt;

&lt;p&gt;Floating-point addition is not associative: &lt;code&gt;(a + b) + c&lt;/code&gt; and &lt;code&gt;a + (b + c)&lt;/code&gt; can differ in the last bit. GPU kernels sum gradient contributions across thousands of threads, many using atomic operations whose completion &lt;em&gt;order depends on the scheduler&lt;/em&gt; — which threads won the race on this particular run, on this particular clock. Same numbers, different addition order, last-bit differences in the gradients. Then SGD amplifies them: a 1e-8 disagreement in a gradient becomes a slightly different weight, which becomes a different activation, and the gap compounds every step. By my run's end, noise that began around the last decimal place a float can represent had grown into 0.237. It's the butterfly effect with a commit hash.&lt;/p&gt;

&lt;p&gt;None of this is a PyTorch bug; it's documented behavior, and &lt;code&gt;cudnn.benchmark&lt;/code&gt; (which I've &lt;a href="https://b1ackjack12.github.io/posts/tf32-cudnn-benchmark-measured" rel="noopener noreferrer"&gt;previously measured&lt;/a&gt; and left enabled) adds a second layer by autotuning to potentially different — nondeterministic — conv algorithms per run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3sjg27j1wjq8tdv65ko.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3sjg27j1wjq8tdv65ko.webp" alt="Two identical glowing racing lines leaving one starting gate and slowly fanning apart into visibly different paths across a dark circuit board landscape" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Does 0.237 matter? Only when you're asking small questions
&lt;/h2&gt;

&lt;p&gt;For final accuracy, this nondeterminism behaves like one more seed you didn't choose — my &lt;a href="https://b1ackjack12.github.io/posts/batch-level-gpu-augmentation-accuracy" rel="noopener noreferrer"&gt;augmentation experiment&lt;/a&gt; put ordinary seed-to-seed spread at ±0.3–0.5 points on a small CIFAR setup, and same-seed GPU noise lands in the same statistical bucket. If your effect size dwarfs that, you can ignore this whole post.&lt;/p&gt;

&lt;p&gt;The cases where it bites are the small questions, which are most of debugging. "Did my refactor change the numerics?" — you can't diff against the pre-refactor run, because &lt;em&gt;nothing&lt;/em&gt; diffs clean, ever. "Is this 0.4-point improvement my new loss term or luck?" — under nondeterminism you can't even hold the luck constant while you toggle the loss term. A/B comparisons where the honest answer requires the same noise on both sides are exactly where "seeded but nondeterministic" quietly wastes days. I know because the one-point mystery that started this post turned out to be two runs of the &lt;em&gt;same code&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and its real price
&lt;/h2&gt;

&lt;p&gt;PyTorch ships the switch; it just isn't the seed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use_deterministic_algorithms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backends&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cudnn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;benchmark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="c1"&gt;# and, for CUDA &amp;gt;= 10.2, before the process starts:
# CUBLAS_WORKSPACE_CONFIG=:4096:8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With those set, my two 200-step runs matched &lt;strong&gt;bit-for-bit&lt;/strong&gt;: max weight difference exactly 0, every loss value identical to the last digit. Two footnotes from the setup: the &lt;code&gt;CUBLAS_WORKSPACE_CONFIG&lt;/code&gt; environment variable is mandatory (you get a clear RuntimeError without it), and &lt;code&gt;use_deterministic_algorithms&lt;/code&gt; will refuse to run ops that simply have no deterministic GPU implementation — ResNet18 sailed through, but if your model contains one of the holdouts, the flag fails loudly and your options are CPU fallback or living without determinism for that op. Loud failure, for once, is the feature.&lt;/p&gt;

&lt;p&gt;The folklore price for all this is "2–3x slower," usually delivered as a reason not to bother. Measured, on the ResNet18 training step:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;precision&lt;/th&gt;
&lt;th&gt;default&lt;/th&gt;
&lt;th&gt;deterministic&lt;/th&gt;
&lt;th&gt;cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fp32&lt;/td&gt;
&lt;td&gt;17.94 ms&lt;/td&gt;
&lt;td&gt;20.90 ms&lt;/td&gt;
&lt;td&gt;+16.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bf16 autocast&lt;/td&gt;
&lt;td&gt;12.42 ms&lt;/td&gt;
&lt;td&gt;14.37 ms&lt;/td&gt;
&lt;td&gt;+15.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sixteen percent. Some of that isn't even determinism proper — it includes giving up &lt;code&gt;cudnn.benchmark&lt;/code&gt;, which &lt;a href="https://b1ackjack12.github.io/posts/tf32-cudnn-benchmark-measured" rel="noopener noreferrer"&gt;I measured at ~3%&lt;/a&gt; on this model. The 2–3x stories presumably come from specific unlucky ops or older stacks, and maybe your model contains one; that's what the two-minute measurement is for. On mine, bit-exact reproducibility costs about as much as a modestly larger batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the switch sits now
&lt;/h2&gt;

&lt;p&gt;My default stays fast and nondeterministic — for training throughput, 16% is real money, and ordinary training doesn't need bit-exactness. But the flag has earned a permanent spot in three situations: any debugging session that compares two runs (which is to say, any debugging session), numerics-sensitive refactors where I want &lt;code&gt;git diff&lt;/code&gt;-grade certainty that outputs didn't change, and small-effect experiments where the noise floor would otherwise eat the result. The deciding question is one sentence: &lt;em&gt;am I about to compare two runs and interpret the difference?&lt;/em&gt; If yes, 16% is the cheapest insurance in this entire optimization series — and unlike the seeds ritual, it actually buys what it claims to.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://b1ackjack12.github.io/posts/pytorch-same-seed-different-model/" rel="noopener noreferrer"&gt;b1ackjack12.github.io&lt;/a&gt; — an AI tech blog where every claim comes with a measurement. More experiments like this one live there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pytorch</category>
      <category>machinelearning</category>
      <category>deeplearning</category>
      <category>reproducibility</category>
    </item>
  </channel>
</rss>
