<?xml version="1.0" encoding="UTF-8"?>
<rss  xmlns:atom="http://www.w3.org/2005/Atom" 
      xmlns:media="http://search.yahoo.com/mrss/" 
      xmlns:content="http://purl.org/rss/1.0/modules/content/" 
      xmlns:dc="http://purl.org/dc/elements/1.1/" 
      version="2.0">
<channel>
<title>Michael Leung</title>
<link>https://mikkeyboi.github.io/</link>
<atom:link href="https://mikkeyboi.github.io/index.xml" rel="self" type="application/rss+xml"/>
<description>ML engineering, post-training, and notes from a neuroscientist-turned-modeller.</description>
<generator>quarto-1.6.42</generator>
<lastBuildDate>Sat, 02 May 2026 00:00:00 GMT</lastBuildDate>
<item>
  <title>Why SFT learned the words but GRPO learned the rules</title>
  <dc:creator>Michael Min Wah Leung</dc:creator>
  <link>https://mikkeyboi.github.io/posts/01-sft-grpo-hvac/</link>
  <description><![CDATA[ 




<section id="the-three-letter-problem" class="level2">
<h2 class="anchored" data-anchor-id="the-three-letter-problem">The three-letter problem</h2>
<p>After supervised fine-tuning, our Phi-4 model could recite every tag in our naming table. Ask it the canonical question, <em>“what is the tag for the supply air static pressure setpoint?”</em>, and it would answer correctly. Ask it the <em>inverse</em>, <em>“give me the supply air static pressure setpoint”</em> without the word “tag”, and it would confidently emit:</p>
<blockquote class="blockquote">
<p><strong><code>SupplyAirStaticPressureSetpoint</code></strong></p>
</blockquote>
<p>A perfectly reasonable BACnet-style name. Discoverable, self-documenting, and completely absent from our system. The correct answer was three letters: <strong><code>SPS</code></strong>.</p>
<p>This post is about how I closed that gap with roughly 250 lines of reward function and a quarter-epoch of GRPO, and what the experience taught me about RL on language models that I had not gotten from reading papers about it.</p>
</section>
<section id="the-setting" class="level2">
<h2 class="anchored" data-anchor-id="the-setting">The setting</h2>
<p>I work on an internal AI assistant for an industrial domain that has its own naming taxonomy. The tags are short and opinionated, and they look nothing like the open-source conventions an LLM has seen during pretraining. Think <code>SPS</code>, <code>DAT</code>, <code>ZN-2_RHC</code> instead of the verbose, hierarchical strings that public BMS tutorials and BACnet documentation are full of.</p>
<p>The first instinct is RAG: index the table, retrieve the right row at query time. We tried it. It works for <em>direct lookup</em> (tag to description) and breaks for almost everything else: paraphrases, partial matches, descriptions that do not quote the table verbatim, anything that requires the model to <em>reason</em> about the structure of the names rather than recall a row.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/01-sft-grpo-hvac/images/01-rag-vs-finetuning.png" class="img-fluid figure-img"></p>
<figcaption>RAG retrieves examples; fine-tuning internalises the rules. For a fixed, rule-based vocabulary, the right tool is the one that learns the structure, not the one that looks it up.</figcaption>
</figure>
</div>
<p>The choice was straightforward. Teach the model the vocabulary directly. SFT got us most of the way; it did not get us all the way, and the gap was instructive.</p>
</section>
<section id="what-sft-got-right-and-what-it-didnt" class="level2">
<h2 class="anchored" data-anchor-id="what-sft-got-right-and-what-it-didnt">What SFT got right, and what it didn’t</h2>
<p>SFT (QLoRA on Phi-4, ~3 epochs over ~5k synthetic examples covering 7 scenario types) gave us a model that could:</p>
<ul>
<li>Recall the table verbatim when asked directly.</li>
<li>Answer multiple-choice distractor questions with the right tag.</li>
<li>Tolerate moderate paraphrasing in the <em>direct</em> direction (description provided, tag returned).</li>
</ul>
<p>It still failed at:</p>
<ul>
<li><strong>Reverse lookup.</strong> Given a description without the cue word “tag”, it would invent a plausible BACnet-style name instead of using ours.</li>
<li><strong>Refusing the unknown.</strong> Asked about equipment that was not in the taxonomy, it would confidently produce <em>something</em>, usually a tag from a related family, rather than acknowledging it did not know. Phi-4 SFT alone scored <strong>60% on <code>unknown_tag</code> refusal</strong>; SFT+GRPO took it to <strong>86.7%</strong>.</li>
<li><strong>Generic-naming drift.</strong> Given a typo or an ambiguous phrasing, it would back off to the verbose, English-sounding form it had seen during pretraining. SFT scored 40% on <code>typo_robustness</code>; SFT+GRPO took it to <strong>60%</strong>.</li>
</ul>
<p>These are not accuracy failures you fix with more SFT data. They are <em>preference</em> failures: the model’s distribution over plausible answers is wrong in a way that more cross-entropy loss does not address. Cross-entropy rewards being close to the target token. It does not punish a confident, fluent answer that happens to be drawn from the wrong vocabulary.</p>
<p>That is what RL is for.</p>
</section>
<section id="why-grpo-not-ppo" class="level2">
<h2 class="anchored" data-anchor-id="why-grpo-not-ppo">Why GRPO, not PPO</h2>
<p>I went with GRPO over PPO for the standard reason (no critic) and one less-standard reason that mattered more in practice. GRPO’s group-relative advantage gave me a much cleaner signal for reward shaping. Every prompt produces a small group of completions; advantages are normalised within the group. That means the absolute scale of the reward function matters less than the <em>ordering</em> it induces, which is exactly what I wanted when iterating on reward design.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/01-sft-grpo-hvac/images/02-grpo-vs-ppo.png" class="img-fluid figure-img"></p>
<figcaption>GRPO sidesteps the critic by computing advantages relative to a group of sampled responses. For a small post-training run on a domain task, that is a real engineering simplification: fewer moving parts, less memory, less to debug.</figcaption>
</figure>
</div>
</section>
<section id="designing-a-reward-function-that-punishes-the-right-things" class="level2">
<h2 class="anchored" data-anchor-id="designing-a-reward-function-that-punishes-the-right-things">Designing a reward function that punishes the right things</h2>
<p>This is the section that matters. Most public GRPO write-ups use a one-line reward, a correctness flag or a regex match. Mine is ~250 lines and has <em>seven</em> scenarios with calibrated reward bands, because the failures I was trying to fix were qualitatively different from each other and a scalar correctness signal could not distinguish them.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/01-sft-grpo-hvac/images/03-grpo-reward-flow.png" class="img-fluid figure-img"></p>
<figcaption>The training loop on the left, the reward function’s per-scenario decision tree on the right. The asymmetric <code>reverse_lookup</code> band (+1.0 / -0.8) and the <code>unknown_tag</code> trap (+1.0 for refusal, -1.0 for confidently naming a real tag the model wasn’t asked about) are where most of the behavioural shift came from.</figcaption>
</figure>
</div>
<p>The reward bands, abbreviated:</p>
<table class="caption-top table">
<colgroup>
<col style="width: 25%">
<col style="width: 25%">
<col style="width: 25%">
<col style="width: 25%">
</colgroup>
<thead>
<tr class="header">
<th>Scenario</th>
<th>Correct</th>
<th>Wrong</th>
<th>Why this shape</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Reverse lookup (description to tag)</td>
<td><strong>+1.0</strong></td>
<td><strong>-0.8</strong></td>
<td>High-stakes, asymmetric. A wrong tag here is the failure mode I was trying to fix.</td>
</tr>
<tr class="even">
<td>Direct lookup (tag to description)</td>
<td>+0.7 / +0.3 (partial)</td>
<td>-0.5</td>
<td>SFT was already good here. Light reinforcement only.</td>
</tr>
<tr class="odd">
<td>Context reasoning (physical vs.&nbsp;virtual)</td>
<td>+0.6 + bonus</td>
<td>-0.4</td>
<td>Reward correct <em>reasoning keywords</em>, not just final answer.</td>
</tr>
<tr class="even">
<td>Unknown-tag refusal</td>
<td><strong>+1.0</strong></td>
<td><strong>-1.0</strong> (trap)</td>
<td>Confidently naming a real tag when asked about something out-of-table is the <em>worst</em> failure. The asymmetric trap forces the model to learn “I don’t know” as a high-reward action.</td>
</tr>
<tr class="odd">
<td>Typo robustness</td>
<td>+1.2 (recover)</td>
<td>-0.5</td>
<td>Edit-distance-based intended-tag recovery. Reward <em>correcting</em>, not refusing.</td>
</tr>
<tr class="even">
<td>Hedging penalty</td>
<td>n/a</td>
<td>-0.2</td>
<td>“I think it might be…” is worse than a confident wrong answer here, because hedging is what masked the failure during SFT eval.</td>
</tr>
<tr class="odd">
<td>Format / concision bonus</td>
<td>+0.1 each</td>
<td>n/a</td>
<td>Short, well-formatted answers preferred.</td>
</tr>
</tbody>
</table>
<p>A few specific reward-design decisions I’d defend:</p>
<p><strong>The <code>unknown_tag</code> trap (+1.0 vs -1.0) is the single most important band.</strong> It is what taught the model that “I don’t know” is an answer. Without the asymmetric penalty, the model would fall back to a related-family tag: fluent, plausible, wrong. With it, refusal becomes the high-reward action and the model stops gambling.</p>
<p><strong>The <code>reverse_lookup</code> penalty is asymmetric (-0.8 against +1.0)</strong> because the failure mode it targets, inventing a BACnet-style name, is a <em>fluent, confident</em> failure that an SFT eval set will under-measure. Symmetric rewards would let the model trade off these failures against easy wins on direct lookup. Asymmetric rewards make that trade unprofitable.</p>
<p><strong>The hedging penalty is small (-0.2) and intentional.</strong> It is not punishing the model for being uncertain; it is punishing it for <em>expressing</em> uncertainty in cases where the answer is recoverable. The right move on a typo is to recover and answer, not to hedge.</p>
</section>
<section id="conservative-grpo-refinement-not-relearning" class="level2">
<h2 class="anchored" data-anchor-id="conservative-grpo-refinement-not-relearning">Conservative GRPO: refinement, not relearning</h2>
<p>Most failure stories I have read with GRPO come from the same place. Too much learning rate, too little KL anchor, too many epochs, and the model drifts off the SFT distribution into a degenerate reward-hacking mode that scores well on the reward function and is useless in production.</p>
<p>My hyperparameters were deliberately conservative:</p>
<table class="caption-top table">
<colgroup>
<col style="width: 33%">
<col style="width: 33%">
<col style="width: 33%">
</colgroup>
<thead>
<tr class="header">
<th>Param</th>
<th>Value</th>
<th>Rationale</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>LoRA <code>r</code></td>
<td>4</td>
<td>Tiny adapter. Refine, do not relearn.</td>
</tr>
<tr class="even">
<td>LoRA <code>α</code></td>
<td>8</td>
<td>2× <code>r</code>, conventional.</td>
</tr>
<tr class="odd">
<td>Learning rate</td>
<td>5e-7</td>
<td>An order of magnitude below typical SFT.</td>
</tr>
<tr class="even">
<td>KL <code>β</code></td>
<td><strong>0.2</strong></td>
<td>Strong anchor to the SFT distribution.</td>
</tr>
<tr class="odd">
<td>Epochs</td>
<td><strong>0.25</strong></td>
<td>Stop <em>before</em> drift sets in.</td>
</tr>
<tr class="even">
<td>Group size <code>G</code></td>
<td>4</td>
<td>Smallest group that gives a meaningful relative advantage.</td>
</tr>
</tbody>
</table>
<p>The thesis: <em>SFT already knows the tags. GRPO’s job is to reshape the model’s preferences over how to use them, not to teach it new ones.</em> That framing, RLHF as preference reshaping with a hard KL leash, is consistent with what the InstructGPT and Anthropic HH-RLHF papers describe in their own runs, and it is what the production behaviour confirmed. The model did not get smarter; it got <em>opinionated</em> in the right direction.</p>
</section>
<section id="when-the-residual-failures-wouldnt-budge-targeted-dpo" class="level2">
<h2 class="anchored" data-anchor-id="when-the-residual-failures-wouldnt-budge-targeted-dpo">When the residual failures wouldn’t budge: targeted DPO</h2>
<p>GRPO closed most of the gap. One residual failure remained: on a specific subclass of reverse-lookup queries (those that paraphrased a description in a way that overlapped lexically with public BACnet conventions), the model would still occasionally drift to the verbose form. The reward function could not distinguish those queries cleanly enough at the group level.</p>
<p>So I switched tools. I generated targeted preference pairs (correct tag preferred over the BACnet-style hallucination), oversampled the failing subclass with <code>reverse_weight=3</code>, and ran DPO on top of the GRPO checkpoint. The pattern, <em>use GRPO for broad behavioural shaping, use DPO for surgical fixes on residual failures</em>, felt right and is something I would reach for again.</p>
<p>That story deserves its own post. For here, it is enough to say that the combination held the SFT+GRPO gains while quietly closing more residual cases.</p>
</section>
<section id="results" class="level2">
<h2 class="anchored" data-anchor-id="results">Results</h2>
<p>I evaluated seven model variants. Base SFT, SFT+GRPO, and SFT+DPO across two open-weights families (Phi-4 14B, Mistral 7B), plus a Phi-4-only GRPO baseline (no SFT first), plus a closed-source comparator (GPT-4.1-mini fine-tuned via Azure on the same data). All numbers below are from the same held-out eval, all scored against the same scenario harness. Models ran as quantized GGUF for inference parity.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/01-sft-grpo-hvac/overall_accuracy.png" class="img-fluid figure-img"></p>
<figcaption>Overall accuracy across the seven model variants on the held-out eval. <strong>Phi-4 SFT+GRPO leads at 83.3%</strong>, with SFT+DPO close behind at 82.2%. The 15.5-point lift over Phi-4 SFT alone (67.8%) is the headline. Phi-4 GRPO without SFT first (58.9%) underperforms even SFT alone, confirming the conservative “refine, do not relearn” thesis. The Azure-fine-tuned GPT-4.1-mini at 20% is a useful sanity check: a generic fine-tuning API on a stronger base model loses badly to a thoughtfully post-trained 14B open model on this task.</figcaption>
</figure>
</div>
<p>The per-scenario breakdown is where the reward design earns its keep:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/01-sft-grpo-hvac/accuracy_by_scenario.png" class="img-fluid figure-img"></p>
<figcaption>Per-scenario accuracy. The two scenarios that GRPO and DPO target most directly, <code>unknown_tag</code> refusal and <code>typo_robustness</code>, are exactly where the largest lifts show up. SFT alone hits 60% on <code>unknown_tag</code>; SFT+GRPO and SFT+DPO hit 86.7% and 86.7% respectively. On <code>typo_robustness</code>, SFT’s 40% becomes 60% with GRPO. The “easy” scenarios (direct lookup, multiple choice, physical/virtual classification) saturate near 100% across most variants, which is the point: the asymmetric reward bands deliberately spent learning capacity on the hard scenarios without trading away the easy ones.</figcaption>
</figure>
</div>
<p>A few specific things I’d call out from these numbers:</p>
<ul>
<li><strong>The <code>unknown_tag</code> lift is the most important result in the post.</strong> It is the failure mode the asymmetric trap (+1.0 / -1.0) was designed for, and the +26.7-point delta on Phi-4 is the cleanest evidence that the reward shaping worked as intended rather than as a happy accident.</li>
<li><strong>Phi-4 GRPO without SFT (58.9%) underperforms Phi-4 SFT alone (67.8%).</strong> Same model, same data, same RL recipe, minus the SFT warm-start. RL on language models without a strong supervised initialisation is a different (and harder) problem; this row is the empirical version of “SFT first, then RL” as a recipe rather than a slogan.</li>
<li><strong>GPT-4.1-mini via Azure fine-tuning at 20%</strong> is the comparator that surprised me most. The fine-tuning API does not expose enough of the loss function to express the asymmetric preferences this task needs; a more capable base model with a less expressive post-training surface loses to a smaller open model with a more expressive one. <em>Reward design is the work</em> is not just a slogan I picked for the section heading.</li>
</ul>
<p>One qualitative example to ground the table:</p>
<blockquote class="blockquote">
<p><strong>Prompt:</strong> <em>“give me the supply air static pressure setpoint”</em> <strong>Phi-4 SFT:</strong> <code>SupplyAirStaticPressureSetpoint</code> <strong>Phi-4 SFT+GRPO:</strong> <code>SPS</code> <strong>Ground truth:</strong> <code>SPS</code></p>
</blockquote>
<p>The post is built around that one example because every story I have about this project bottoms out in some version of it. A fluent, plausible, confident answer, drawn from the wrong vocabulary, that no SFT eval set was going to flag.</p>
</section>
<section id="a-small-detail-that-signals-this-shipped" class="level2">
<h2 class="anchored" data-anchor-id="a-small-detail-that-signals-this-shipped">A small detail that signals “this shipped”</h2>
<p>One implementation note that did not make the narrative but that I would put in a sidebar: TRL’s <code>GRPOConfig</code> and <code>GRPOTrainer</code> APIs have churned across versions (<code>max_new_tokens</code> vs <code>max_completion_length</code>, <code>processing_class</code> vs <code>tokenizer</code>, <code>reward_funcs</code> vs <code>reward_function</code>). My <code>_build_grpo_config()</code> introspects <code>GRPOConfig.__init__</code> at runtime and picks the right kwargs for the installed version. It is three small <code>inspect.signature</code> checks. It saved me twice across upgrades and is the kind of thing you only write after you have shipped something for real.</p>
</section>
<section id="what-id-do-next" class="level2">
<h2 class="anchored" data-anchor-id="what-id-do-next">What I’d do next</h2>
<ul>
<li><strong>Step-DPO</strong> for the multi-step reasoning scenarios (physical vs.&nbsp;virtual classification), where the failure is in the chain, not the answer.</li>
<li><strong>An RLAIF critic</strong> trained on a held-out slice of the taxonomy, to remove the manual-reward-tuning bottleneck for new scenarios.</li>
<li><strong>Reward-model regularisation.</strong> Measuring how much of the post-GRPO behaviour is reward-hacking the specific bands vs.&nbsp;genuine preference shift. The Anthropic Constitutional AI paper’s diagnostic ideas would translate cleanly here.</li>
</ul>
</section>
<section id="what-this-taught-me" class="level2">
<h2 class="anchored" data-anchor-id="what-this-taught-me">What this taught me</h2>
<p>A few things I did not get from reading RLHF papers and only got from running this:</p>
<ol type="1">
<li><strong>Reward design is the work.</strong> The training loop is mechanical; the reward function is where the research is. Every hour I spent on hyperparameters returned less than every hour I spent on the asymmetric reward bands.</li>
<li><strong>KL is not a tuning knob, it is a leash.</strong> Keeping <code>β</code> high and learning rate low felt unambitious until I tried lowering them, watched the model drift into reward-hacking, and put them back.</li>
<li><strong>The model already knows.</strong> SFT had the information. GRPO did not add knowledge; it changed which knowledge the model preferred to use. That distinction, between teaching and reshaping, is most of what makes RLHF feel different from SFT in practice.</li>
</ol>
<hr>
<p><em>The codebase that backs this post lives in a private repo; a sanitized public version is in progress. If you’re working on similar problems, domain taxonomies, post-training for vocabulary control, asymmetric reward design, I’d love to compare notes.</em></p>


</section>

 ]]></description>
  <category>post-training</category>
  <category>GRPO</category>
  <category>RLHF</category>
  <category>LLMs</category>
  <guid>https://mikkeyboi.github.io/posts/01-sft-grpo-hvac/</guid>
  <pubDate>Sat, 02 May 2026 00:00:00 GMT</pubDate>
  <media:content url="https://mikkeyboi.github.io/posts/01-sft-grpo-hvac/images/01-rag-vs-finetuning.png" medium="image" type="image/png" height="79" width="144"/>
</item>
<item>
  <title>From consuming a pretrained model to training my own</title>
  <dc:creator>Michael Min Wah Leung</dc:creator>
  <link>https://mikkeyboi.github.io/posts/03-cslt-seq2seq/</link>
  <description><![CDATA[ 




<section id="an-organisation-wide-hackathon-a-partner-and-a-ceiling-i-didnt-expect" class="level2">
<h2 class="anchored" data-anchor-id="an-organisation-wide-hackathon-a-partner-and-a-ceiling-i-didnt-expect">An organisation-wide hackathon, a partner, and a ceiling I didn’t expect</h2>
<p>My teammate Sharon and I entered an organisation-wide hackathon with over 11,000 project submissions. We set out to build a sign-language Copilot. Sharon owned the agent integration and tooling: Microsoft Agent Framework, the WorkIQ MCP for M365, the RAG-augmented translator that turns recognised gloss into clean English, and the Qt UI’s higher-level event handling. I owned the part you don’t see: the modelling and inference engine that turns webcam frames into something the agent can actually act on. The project went on to win first place in the challenge.</p>
<p>The reason this was a real project and not a clever demo: as far as we could find, there is no published methodology for using sign language as an interface to an AI agent, and there are no foundational models designed for continuous or conversational signing. Production sign-language systems are isolated-gloss recognisers; the research literature is dominated by isolated-sign benchmarks. Conversational signing, the way a deaf user would actually dictate to a Copilot, is an open problem. Our project was a novel attempt to train a continuous-signing model from scratch, including a novel processing workflow for the multi-angle video clips the dataset ships in.</p>
<p>We shipped a working demo by Friday night, built around a strong pretrained isolated-gloss classifier (the Kaggle 1st-place ASL TFLite model). It could recognise “hello”, “thank you”, “schedule” with high confidence. By Sunday afternoon I had realised it could only ever do one sign at a time. <em>“Schedule a meeting with Sharon next Tuesday”</em> was structurally impossible: an isolated-gloss model has no notion of <em>sequence</em>. It recognises one sign per stable window and concatenates the recognitions post-hoc, which is a fundamentally different object from a continuous-signing decoder.</p>
<p>This post is about what I did about that. It covers a small encoder-decoder Transformer trained from scratch on How2Sign in two backends, the architectural choice of attention over recurrence and why it matters for signing specifically, a subtle masked-cross-entropy detail that’s the difference between a model that stops talking and a model that doesn’t, and a hybrid runtime that knows when to defer to the simpler model. The combination, not any single component, is what reached <strong>93.6% sentence-level recognition</strong> on our internal evaluation, where the trained model alone struggles on long sentences and the pretrained model alone can’t attempt them.</p>
</section>
<section id="the-pipeline-end-to-end" class="level2">
<h2 class="anchored" data-anchor-id="the-pipeline-end-to-end">The pipeline, end to end</h2>
<p>The system is five threaded stages: webcam at ~30 FPS, MediaPipe Holistic landmarks (468 face + 21 left hand + 33 pose + 21 right hand = 543 landmarks × 3 coords per frame), an inference stage that turns landmark sequences into ASL gloss tokens, a translator that turns gloss into English with a small RAG-augmented LLM call, and a Copilot agent on top with M365 tooling. A custom event bus of bounded queues coordinates the threads and exposes a clean signing-state signal to the UI.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/03-cslt-seq2seq/images/01-architecture.png" class="img-fluid figure-img"></p>
<figcaption>End-to-end architecture for the sign-language Copilot. Webcam frames are fed through MediaPipe Holistic to produce a 543-landmark stack per frame; the inference engine turns landmark sequences into gloss tokens; a RAG-augmented translator converts gloss to English; a Copilot agent with WorkIQ MCP handles M365 tasks. The middle two stages, where modelling and inference live, are what this post focuses on.</figcaption>
</figure>
</div>
<p>Two stages are mine: the inference engine and the model that drives it. The agent layer, MCP integration, RAG provider, and Qt UI are my partner’s work, and they deserve their own write-ups.</p>
</section>
<section id="what-an-isolated-gloss-model-cant-do" class="level2">
<h2 class="anchored" data-anchor-id="what-an-isolated-gloss-model-cant-do">What an isolated-gloss model can’t do</h2>
<p>The original inference engine wraps the pretrained TFLite classifier with the obvious scaffolding: a sliding window over the landmark stream, a top-1 smoothed across five predictions, and a stability filter that only emits a sign once it’s been the top-1 for three consecutive predictions. The threshold tuning, the idle-frame <code>&lt;END&gt;</code> token, the dual vocab-format dispatcher, all of it works.</p>
<p>It’s also fundamentally a one-sign-at-a-time machine. Continuous signing produces overlapping windows whose stable top-1 changes mid-phrase, and the inference engine has no representation of a <em>phrase</em>. There’s no path from “schedule” + “meeting” + “Priya” + “next” + “Tuesday” to a coherent sentence, because the model never sees those signs as a sequence, only as five separate top-1 calls. For “hello” or “thank you” this is fine. For anything an actual user would dictate to a Copilot, it isn’t.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/03-cslt-seq2seq/images/02-landmarks.gif" class="img-fluid figure-img"></p>
<figcaption>A short sample of the continuous-signing input the new model has to handle. Each frame is a 543-landmark stack; the <em>sequence</em> of these stacks carries the sentence-level meaning, not any single frame. A pretrained isolated-gloss classifier evaluates frame windows in isolation and has no representation of the sequence beyond a sliding aggregator on top.</figcaption>
</figure>
</div>
</section>
<section id="why-an-attention-based-encoder-decoder-and-not-an-rnn-gru-or-lstm" class="level2">
<h2 class="anchored" data-anchor-id="why-an-attention-based-encoder-decoder-and-not-an-rnn-gru-or-lstm">Why an attention-based encoder-decoder, and not an RNN, GRU, or LSTM</h2>
<p>The model is a small encoder-decoder Transformer with multi-head self-attention in the encoder, multi-head self-attention plus cross-attention in the decoder, and <em>no recurrence anywhere</em>. The encoder consumes the 1629-dimensional flattened landmark stack per frame; the decoder produces English tokens autoregressively from a 5000-token vocabulary trained on How2Sign captions. Sizes are deliberately modest: <code>embed_dim=256</code>, <code>dense_dim=512</code>, <code>num_heads=4</code>, two encoder layers, two decoder layers, around 10–11M parameters. The intent isn’t SOTA on How2Sign; it’s a model small enough to overfit gracefully on the available budget while being a structurally honest sequence-to-sequence model.</p>
<p>The choice of attention over recurrence wasn’t aesthetic. Continuous signing has two properties that an RNN, GRU, or LSTM handles poorly:</p>
<p><em>Variable signing rate.</em> The same sentence can take 1.5 seconds or 4 seconds depending on the signer. Recurrent encoders fold information through a hidden state at a fixed cadence and develop a strong recency bias; a fast signer’s early signs decay before the decoder ever attends to them, and a slow signer’s signs blur into each other through the gating. Self-attention has no recency bias by construction. Every encoder position attends to every other position with learned weights, so a sign that takes ten frames and a sign that takes thirty frames are weighted on content, not on temporal distance from the decoder’s current step.</p>
<p><em>Spatial structure that evolves over time.</em> A frame is 543 spatial landmarks, and the <em>configuration</em> of those landmarks (the relative positions of the right-hand keypoints to the face keypoints to the left-hand keypoints) is what carries the gloss. Recurrent models have no native way to factor “what’s spatially co-occurring inside this frame” from “how is the spatial pattern changing across frames”. An encoder built on attention treats the per-frame landmark stack as a single token whose embedding can carry the spatial structure, and lets the inter-frame attention layer carry the temporal structure separately. The two axes get their own machinery, which is exactly what a problem with non-trivial spatial <em>and</em> temporal structure needs.</p>
<p>The non-obvious architectural choice that follows is the <strong>dual positional embedding</strong>. The encoder uses <code>FramePositionalEmbedding</code> over continuous frame indices; the decoder uses ordinary <code>PositionalEmbedding</code> over discrete token positions. Modalities have different position semantics, and trying to share one positional code across them produces a model that’s confused about which axis is which. The cleanest way to phrase the framing is that <strong>MediaPipe Holistic is a frozen perceptual frontend</strong>, and the encoder is learning the temporal-linguistic mapping between landmark sequences and language. That’s the same shape as freezing the vision tower and training only the projector in modern multimodal LLMs.</p>
</section>
<section id="the-masked-cross-entropy-detail-that-actually-matters" class="level2">
<h2 class="anchored" data-anchor-id="the-masked-cross-entropy-detail-that-actually-matters">The masked cross-entropy detail that actually matters</h2>
<p>Most of the implementation is mechanical. One detail isn’t.</p>
<p>In a teacher-forced sequence-to-sequence loss, you have to mask the padding positions so the model isn’t penalised for what it predicts after the real sequence ends. The reflexive way to write the mask is “wherever the target token is the padding ID, mask”. This is wrong, and it’s wrong in a way that produces a model that <em>never learns to stop</em>.</p>
<p>The fix is to mask the <code>[padding → padding]</code> transitions but <em>keep</em> the first <code>[real_token → padding]</code> transition trainable. That single transition is where the model learns where end-of-sequence lives. Mask it and the decoder produces fluent, plausible, infinite output at inference time. Keep it and the decoder learns to terminate.</p>
<div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># Wrong: mask all positions where the target is padding.</span></span>
<span id="cb1-2">mask <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> (targets <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">!=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>)</span>
<span id="cb1-3"></span>
<span id="cb1-4"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># Right: keep the first padding position (the EOS transition) trainable;</span></span>
<span id="cb1-5"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># mask only padding-on-padding.</span></span>
<span id="cb1-6">mask <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> (targets <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">!=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">|</span> (mx.roll(targets, <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>, axis<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">!=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>)</span></code></pre></div>
<p>It’s a one-line difference in the loss function. Catching it took a wasted training run and a confused afternoon staring at sample outputs. It’s the kind of subtle bug that doesn’t show up in a unit test and only surfaces when you actually look at what the trained model produces. I include it here because it’s representative of the class of problem this work surfaced: not “import the right HF class”, but “make teacher-forced training do what teacher-forced training is supposed to do”.</p>
</section>
<section id="two-backends-one-architecture" class="level2">
<h2 class="anchored" data-anchor-id="two-backends-one-architecture">Two backends, one architecture</h2>
<p>The same model is implemented twice: once in Keras, once in Apple MLX. The MLX path is what I used to actually train, on Apple Silicon, with a <code>@mx.compile</code>’d step and an async batch prefetcher built on <code>concurrent.futures</code> that keeps the GPU fed during data preparation. The Keras path is what runs by default if MLX weights aren’t available, and it’s also what the data pipeline (<code>How2SignDataset</code>) uses, with a manual upfront vectorisation step that works around a macOS TF threading deadlock that ate a non-trivial amount of debugging time.</p>
<p>The dual-backend setup wasn’t theoretical. It’s what made it possible to train the model at all on the hardware I had during the hackathon, and it’s what made the model portable enough to ship inside the Qt application my partner had built the agent integration on top of.</p>
</section>
<section id="hybrid-inference-the-runtime-as-a-model-decision" class="level2">
<h2 class="anchored" data-anchor-id="hybrid-inference-the-runtime-as-a-model-decision">Hybrid inference: the runtime as a model decision</h2>
<p>Here’s where the engineering ends up doing more than the model alone can.</p>
<p>The trained Seq2Seq is genuinely good at continuous sentences and genuinely weak on the kind of short conversational gestures (“hello”, “thank you”) that aren’t well-represented in How2Sign. The pretrained isolated-gloss model has the opposite profile. The right move isn’t to pick one. It’s to dispatch.</p>
<p>The inference engine became a hybrid: if the frame buffer holds fewer than 16 frames, fall back to the isolated TFLite classifier; if it holds 16 or more, run autoregressive Seq2Seq decode on the trained model. The 16-frame threshold is roughly half a second of signing at 30 FPS, which empirically separates “single sign” from “phrase”. Both models share the MediaPipe-Holistic frontend, so the dispatcher is purely a buffer-length decision.</p>
<p>This gets framed as engineering taste, but it’s actually a <em>modelling</em> choice. The same way mixture-of-experts gating, retrieval-augmented vs.&nbsp;parametric, and small-model-as-router-for-large-model patterns are modelling choices. The right phrasing is: don’t make one model do two jobs when the runtime can choose between two specialists.</p>
</section>
<section id="results" class="level2">
<h2 class="anchored" data-anchor-id="results">Results</h2>
<p>The honest version of the model-only numbers looks like this:</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/03-cslt-seq2seq/images/03-modeling-results.png" class="img-fluid figure-img"></p>
<figcaption>Validation results from our internal evaluation set of roughly 300 conversational-signing video clips, grouped by common sentence-starting phrases (“I’m going to…”, “you want to…”, and similar). Each row is a phrase grouping; the bars show the trained model’s recognition rate within that group. The bottom row, <em>Anchor Words</em>, contains the isolated glosses (“hi”, “okay”, “good”) and as expected scores well, since per-frame visual primitives are the easy case. Multi-word continuous sentences are substantially harder; for sentences starting with several signs in tight succession, the model’s autoregressive decode often fragments or stalls. This is the empirical motivation for the hybrid runtime.</figcaption>
</figure>
</div>
<p>A standalone Transformer Seq2Seq trained on a hackathon budget on How2Sign isn’t going to match a production-trained gloss recognition system on isolated glosses, and on long continuous sentences it pays for the small-data regime in exactly the way the figure shows. Most published continuous sign-language models struggle with the same setup, and I don’t claim mine is special.</p>
<p>What changes is what the <em>pipeline</em> achieves once the trained model is composed with the Qt threading layer’s temporal sliding window and the isolated-gloss fallback. End-to-end, on our internal evaluation of conversational signing scenarios, the composed system reached <strong>93.6% sentence-level recognition</strong>. The split of where that number comes from is roughly: short-burst gestures handled by the fallback (high precision, narrow scope), continuous phrases handled by the Seq2Seq with sliding-window temporal smoothing on top, and the dispatcher choosing between them on a buffer-length signal that’s empirically clean.</p>
<p>The reason that number is a useful signal rather than a vanity metric is that none of the three components reaches it on its own. The model alone is in the figure above. The fallback alone tops out wherever isolated-gloss accuracy tops out and can’t say sentences. The dispatcher alone is twenty lines of buffer-length logic. The composition is what works, and the composition is where the engineering taste lives.</p>
<p>Honest negatives, since this isn’t a paper:</p>
<ul>
<li>Long sentences (&gt;10 signs) still degrade. Beam search at decode and a CTC head as an alternative to teacher-forcing are the obvious next moves.</li>
<li>The model has no notion of <em>speaker</em>. Different signers have different rest poses, hand sizes, and signing speeds; a speaker-conditional encoder would help.</li>
<li>The hybrid threshold (16 frames) is empirical. A learned dispatcher would be the principled version.</li>
</ul>
</section>
<section id="the-wider-point" class="level2">
<h2 class="anchored" data-anchor-id="the-wider-point">The wider point</h2>
<p>The first version of the system shipped a UI around someone else’s model. The version that actually worked for continuous signing required training a different model. Both were the right call at their moment. The interesting work was in the <em>transition</em>: recognising the pretrained model’s ceiling, designing a different architecture, training it in two backends, getting the masked-CE detail right, and engineering the runtime that lets the two models coexist instead of replacing one with the other.</p>
<p>That loop, see-the-ceiling, design, train, integrate, is what I want to spend the next decade doing. Most of the time, in production, it’ll happen at smaller resolution than this; sometimes at much larger. The pattern is the same.</p>
</section>
<section id="whats-next" class="level2">
<h2 class="anchored" data-anchor-id="whats-next">What’s next</h2>
<ul>
<li><strong>Connectionist Temporal Classification</strong> as an alternative training objective. Teacher forcing isn’t the right inductive bias for a problem where the input and output stream lengths are decoupled and there’s no natural alignment.</li>
<li><strong>Beam search at decode</strong>, with length normalisation. Greedy autoregressive decode is leaving recall on the table, especially on phrases where one early token error cascades.</li>
<li><strong>Speaker-conditional encoder.</strong> Conditioning on a small per-signer embedding learned from a calibration sequence would close most of the cross-signer drift.</li>
<li><strong>Encoder pretraining on raw ASL video.</strong> The current encoder learns from labelled translation pairs only, which is a tiny slice of the available signal. A self-supervised pretraining stage on unlabelled signing video, masking or contrastive, is the obvious unlock.</li>
</ul>
<hr>
<p><em>Hackathon collaboration. Credit to Sharon for the agent integration, MCP work, RAG provider, and Qt UI scaffolding; my contribution centres on the modelling and inference layer described above. The project won first place in an organisation-wide hackathon with 11,000+ submissions. How2Sign is a CMU-released dataset under CC-BY-NC-4.0; trained weights derived from it are subject to the dataset’s non-commercial terms.</em></p>


</section>

 ]]></description>
  <category>seq2seq</category>
  <category>sign-language</category>
  <category>MLX</category>
  <category>hybrid-inference</category>
  <guid>https://mikkeyboi.github.io/posts/03-cslt-seq2seq/</guid>
  <pubDate>Fri, 01 May 2026 00:00:00 GMT</pubDate>
  <media:content url="https://mikkeyboi.github.io/posts/03-cslt-seq2seq/images/01-architecture.png" medium="image" type="image/png" height="79" width="144"/>
</item>
<item>
  <title>Patient-specific filters as biomarkers</title>
  <dc:creator>Michael Min Wah Leung</dc:creator>
  <link>https://mikkeyboi.github.io/posts/02-bci-spatial-filters/</link>
  <description><![CDATA[ 




<section id="a-filter-fit-to-the-patient-is-a-biomarker-of-the-patient" class="level2">
<h2 class="anchored" data-anchor-id="a-filter-fit-to-the-patient-is-a-biomarker-of-the-patient">A filter fit to the patient is a biomarker of the patient</h2>
<p>The conventional view of EEG preprocessing treats spatial and spectral filters as a kind of janitorial work. Clean the data, then do the science on whatever’s left. My graduate research convinced me of the inverse: the filter parameters <em>are</em> the science. They quotient out the structure that varies idiosyncratically across people, and the parameters they learn while doing it carry the individual’s signature. The features that remain are comparable across people only because the filter has eaten the variance that wasn’t.</p>
<p>This post is about three filters I worked with, what each one removes, what its parameters reveal, and why the same intuition shows up every time I read a mechanistic interpretability paper.</p>
</section>
<section id="the-setting" class="level2">
<h2 class="anchored" data-anchor-id="the-setting">The setting</h2>
<p>The thesis platform was a saccade-based stop-signal task in VR with simultaneous 32-channel scalp EEG, on seven healthy subjects, designed as the pilot validation for a Parkinson’s disease biomarker study. Subjects fixated on a central point, were cued to prepare a prosaccade or antisaccade, and on 20% of trials had to <em>cancel</em> the planned movement when the fixation point turned green. The neural signature of successful cancellation, frontal theta synchronisation followed by motor beta desynchronisation, is well-described in the reach literature; the question was whether a robust classifier could detect that signature on a per-subject basis with a small amount of data.</p>
<p>Off-the-shelf decoders generalise poorly here, and not for the reason most ML readers expect. The problem isn’t sample size or label noise. It’s that the relevant frequency band is genuinely different in different brains. Subject 1’s task-modulating beta peak sits at 29–32 Hz. Subject 2’s sits at 15–20 Hz. Subject 4’s is 13–19 Hz. If your classifier filters the signal at 13–30 Hz “because that’s beta”, you are doing two completely different operations on those subjects and pretending it’s the same one.</p>
<p>The same problem shows up in a much harsher form in the parallel work I did on intraoperative microelectrode recordings from deep brain structures. Different patient, different anatomy, different background spectra, and you only get the recording window the surgeon gives you. There’s no luxury of a population-fit pipeline; the filter has to work <em>on this person, today</em>.</p>
</section>
<section id="three-filters-three-levels-of-structure-removed" class="level2">
<h2 class="anchored" data-anchor-id="three-filters-three-levels-of-structure-removed">Three filters, three levels of structure removed</h2>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/02-bci-spatial-filters/images/01-pipeline.png" class="img-fluid figure-img"></p>
<figcaption>Per-subject signal-processing pipeline, reproduced from the thesis (Figure 7). The task-modulating component is selected from ICA, its spatial filter is applied to the session, the power spectrum is computed, and FOOOF separates aperiodic 1/f from narrow oscillatory peaks. The peak parameters become the per-subject β band that the downstream CSP estimator uses.</figcaption>
</figure>
</div>
<section id="ica-remove-statistical-mixtures" class="level3">
<h3 class="anchored" data-anchor-id="ica-remove-statistical-mixtures">ICA: remove statistical mixtures</h3>
<p>Independent Component Analysis assumes the recorded channels are linear mixtures of statistically independent sources, <img src="https://latex.codecogs.com/png.latex?X%20=%20AS">, and estimates the de-mixing matrix <img src="https://latex.codecogs.com/png.latex?W"> such that <img src="https://latex.codecogs.com/png.latex?U%20=%20WX"> recovers components that are as independent as possible. In EEG this works because ocular, muscle, and cardiac artifacts genuinely <em>are</em> statistically independent of cortical activity at the scales we care about, and they have stereotyped topologies (frontal-symmetric for blinks, lateral for muscle).</p>
<p>The thesis-relevant property of ICA is that it’s <em>unsupervised</em>. There’s no experimenter bias in choosing what’s “signal”. You decompose, you look at the topologies, and you keep the component whose ERP and scalp distribution match what neurophysiology says response inhibition should look like. The component is not “the signal”; the component is <em>a basis vector</em> in a decomposition the data itself proposed.</p>
</section>
<section id="fooof-remove-the-aperiodic-background" class="level3">
<h3 class="anchored" data-anchor-id="fooof-remove-the-aperiodic-background">FOOOF: remove the aperiodic background</h3>
<p>The brain’s resting power spectrum is dominated by an aperiodic 1/f component on top of which narrow oscillatory peaks live. FOOOF (<em>Fitting Oscillations and One Over F</em>, Donoghue et al.) fits the aperiodic background as a linear function in log-log space, subtracts it, and parameterises whatever peaks remain by their centre frequency, bandwidth, and amplitude.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/02-bci-spatial-filters/images/02-fooof-model.png" class="img-fluid figure-img"></p>
<figcaption>FOOOF model fit for one subject’s task-modulating component. The aperiodic 1/f line is the background; the peaks above it are the periodic components that survive subtraction. The subject’s β peak is what the next stage uses.</figcaption>
</figure>
</div>
<p>The number that mattered for the thesis is in this table:</p>
<table class="caption-top table">
<thead>
<tr class="header">
<th style="text-align: right;">Subject</th>
<th style="text-align: right;">α range (Hz)</th>
<th style="text-align: right;">β range (Hz)</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td style="text-align: right;">1</td>
<td style="text-align: right;">11–13</td>
<td style="text-align: right;"><strong>29–32</strong></td>
</tr>
<tr class="even">
<td style="text-align: right;">2</td>
<td style="text-align: right;">9–13</td>
<td style="text-align: right;"><strong>15–20</strong></td>
</tr>
<tr class="odd">
<td style="text-align: right;">3</td>
<td style="text-align: right;">none</td>
<td style="text-align: right;"><strong>28–32</strong></td>
</tr>
<tr class="even">
<td style="text-align: right;">4</td>
<td style="text-align: right;">7–10</td>
<td style="text-align: right;"><strong>13–19</strong></td>
</tr>
<tr class="odd">
<td style="text-align: right;">5</td>
<td style="text-align: right;">5–9</td>
<td style="text-align: right;"><strong>23–28</strong></td>
</tr>
<tr class="even">
<td style="text-align: right;">6</td>
<td style="text-align: right;">7–13</td>
<td style="text-align: right;"><strong>17–24</strong></td>
</tr>
<tr class="odd">
<td style="text-align: right;">7</td>
<td style="text-align: right;">8–12</td>
<td style="text-align: right;"><strong>20–25</strong></td>
</tr>
</tbody>
</table>
<p>Seven subjects, seven different β bands. “13–30 Hz beta” is not one phenomenon; it’s a population-level smear that hides the structure. Worse, in some subjects the narrow peak is only detectable <em>after</em> ICA has stripped out an artifact component that was dragging power into a different region of the spectrum. The peaks live in a basis the raw signal doesn’t expose.</p>
</section>
<section id="csp-remove-between-class-variance-you-dont-care-about" class="level3">
<h3 class="anchored" data-anchor-id="csp-remove-between-class-variance-you-dont-care-about">CSP: remove between-class variance you don’t care about</h3>
<p>Common Spatial Patterns finds spatial filters that maximise variance for one class while minimising it for the other. Given band-passed EEG matrices <img src="https://latex.codecogs.com/png.latex?X_H"> and <img src="https://latex.codecogs.com/png.latex?X_F"> for two classes, CSP solves a generalised eigenvalue problem on their normalised covariances, and the resulting filters project the data into a subspace where the two classes are maximally separable in variance.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/02-bci-spatial-filters/images/03-csp-topo.png" class="img-fluid figure-img"></p>
<figcaption>CSP scalp topologies (filter-bank CSP, reproduced from the thesis). Centrally-weighted patterns suggest the model is using motor/sensorimotor sources rather than ocular or muscle artifacts. Source localisation isn’t the point of the figure; <em>which</em> features the classifier ends up using is.</figcaption>
</figure>
</div>
<p>Two things matter here. First, CSP is parameterised by the band-pass it operates in, and that’s where the FOOOF output enters. Feeding CSP the subject-specific narrow band, instead of the conventional 13–30 Hz, lets it find spatial filters that actually correspond to task-relevant activity rather than whatever broad-band variance happens to dominate. Second, the resulting topologies become a <em>check on the rest of the pipeline</em>. A CSP filter that places its weight at the temples is using muscle, not cortex. A filter that places it centrally over sensorimotor cortex is doing what we wanted.</p>
</section>
</section>
<section id="the-reframe-filter-parameters-are-features" class="level2">
<h2 class="anchored" data-anchor-id="the-reframe-filter-parameters-are-features">The reframe: filter parameters are features</h2>
<p>Notice the move that’s happening across all three stages. ICA gives you a mixing matrix <img src="https://latex.codecogs.com/png.latex?A"> that’s specific to this subject’s recording; the columns of <img src="https://latex.codecogs.com/png.latex?A"> are this person’s source topologies. FOOOF gives you a triple <img src="https://latex.codecogs.com/png.latex?(f_c,%20%5Ctext%7Bbw%7D,%20a)"> that’s specific to this subject’s resting spectrum. CSP gives you spatial filter weights conditioned on this subject’s data and frequency band.</p>
<p>The conventional pipeline treats these as preprocessing artefacts, things you need to fit but can throw away once you have the post-filter signal. The reframe: the parameters <em>are</em> the features you actually want. They encode the individual in a low-dimensional, interpretable way. The post-filter signals become comparable across people exactly because the parameters have absorbed whatever was idiosyncratic.</p>
<p>This is the same intuition as <strong>whitening followed by per-instance layer-norm in a transformer</strong>: a per-sample reparameterisation that makes the rest of the pipeline well-conditioned, without which the downstream layers are doing different operations on different inputs and pretending it’s the same operation. The whitening matrix is sample-specific; the post-whitened space is shared.</p>
</section>
<section id="the-empirical-payoff" class="level2">
<h2 class="anchored" data-anchor-id="the-empirical-payoff">The empirical payoff</h2>
<p>Across the seven subjects, classifying Go versus successful Stop trials with a Random Forest over CSP features showed a consistent pattern: <strong>using subject-specific β bands from FOOOF, instead of the conventional 13–30 Hz broad band, lifted stop-trial recall by an average of ~10 percentage points and up to +13.8 points</strong> (subject 01). The lift is largest where it should be: subjects whose narrow β is far from the centre of the broad band gain the most, because for them the broad-band filter is paying for off-band noise.</p>
<div class="quarto-figure quarto-figure-center">
<figure class="figure">
<p><img src="https://mikkeyboi.github.io/posts/02-bci-spatial-filters/images/04-confusion-matrix.png" class="img-fluid figure-img"></p>
<figcaption>Subject-level confusion matrices for Go vs Stop classification, reproduced from the thesis. The diagonal lift between broad-band β and subject-specific β is visible across most subjects; the qualitative pattern (Stop is the harder, lower-prevalence class) is preserved.</figcaption>
</figure>
</div>
<p>This isn’t a story about elegance. It’s about whether the classifier is learning anything useful at all. When the filter band is wrong, the post-CSP features mix task signal with off-band drift, and the classifier ends up modelling whichever happens to dominate on the training day. When the filter band is right, the features become comparable across subjects, and a small amount of per-subject calibration generalises to held-out sessions. The thesis showed exactly that: decoding performance <em>improved</em> across successive recording sessions on the same subject when the per-subject features were used, where a generic pipeline would degrade as the resting state drifted.</p>
</section>
<section id="the-bridge-to-mechanistic-interpretability" class="level2">
<h2 class="anchored" data-anchor-id="the-bridge-to-mechanistic-interpretability">The bridge to mechanistic interpretability</h2>
<p>Modern transformers don’t have a 1/f spectrum to subtract, and they don’t have ICA-style independent sources to recover linearly. The math is genuinely different. The <em>question</em>, though, is the same: where in this signal does the structure live, and what filter recovers it?</p>
<p>Sparse autoencoders for mechanistic interpretability are doing roughly this. The MLP activation at a transformer layer is a dense, polysemantic mixture; an SAE finds an overcomplete sparse basis where individual directions correspond to interpretable features. The SAE <em>parameters</em> (the dictionary) are model-specific in the same way ICA’s mixing matrix was subject-specific. The post-decomposition features become legible exactly because the dictionary has absorbed the polysemantic structure.</p>
<p>The framing I find most useful is the one I left graduate school with:</p>
<ul>
<li><strong>ICA is the linear, statistically-independent ancestor of the SAE.</strong> Both find a basis for a noisy mixture in which the components are individually meaningful.</li>
<li><strong>FOOOF is a domain-specific structural prior.</strong> It says “we know there’s a 1/f component, fit it explicitly, model the residual.” The transformer analogue is recent work that explicitly subtracts low-rank “background” structure from activations before looking for sparse features.</li>
<li><strong>CSP is task-conditioned dimensionality reduction.</strong> It’s most analogous to probing classifiers: find a subspace where the labels are linearly separable, and inspect what the subspace responds to.</li>
</ul>
<p>None of these are perfect analogies. The relevant signal in a transformer isn’t the brain’s, and the failure modes of spectral filtering don’t translate directly to representation learning. But the <em>willingness to treat the individual sample as the unit of investigation</em>, rather than averaging straight to a population estimator, is a habit of mind that travels.</p>
</section>
<section id="what-this-taught-me" class="level2">
<h2 class="anchored" data-anchor-id="what-this-taught-me">What this taught me</h2>
<p>A few things I took out of this work that still shape how I think about modern ML:</p>
<ol type="1">
<li><strong>Per-sample parameters are not overhead; they’re often the answer.</strong> The reflex to “throw away the calibration” is wrong when the calibration is what makes the rest of the pipeline well-conditioned.</li>
<li><strong>Decomposition before classification.</strong> When the input is mixed, fit a decomposition and let the classifier work in the unmixed basis. This is true for EEG, true for vision-language fusion, and increasingly true for transformer interpretability.</li>
<li><strong>Trust the topology more than the accuracy.</strong> A model that achieves high accuracy by attending to artifact channels is failing in a way the validation set won’t tell you about. CSP scalp topologies are the cheapest sanity check I’ve ever shipped, and the equivalent in modern ML, looking at <em>which features the model is actually using</em>, is one of the few things I expect to keep doing for the next decade.</li>
</ol>
<hr>
<p><em>The full thesis is open-access at the <a href="https://ruor.uottawa.ca/items/9d22c2da-12c4-432a-87f6-6ae6c4d11f4f">University of Ottawa repository</a> for anyone who wants the complete methods or the per-subject figures. If you’re working on per-instance reparameterisation in language models, or on bridges between classical signal-decomposition and modern interpretability, I’d love to compare notes.</em></p>


</section>

 ]]></description>
  <category>neuroscience</category>
  <category>signal-processing</category>
  <category>interpretability</category>
  <guid>https://mikkeyboi.github.io/posts/02-bci-spatial-filters/</guid>
  <pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate>
  <media:content url="https://mikkeyboi.github.io/posts/02-bci-spatial-filters/images/01-pipeline.png" medium="image" type="image/png" height="62" width="144"/>
</item>
</channel>
</rss>
