<?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: Raihan</title>
    <description>The latest articles on DEV Community by Raihan (@raihan-js).</description>
    <link>https://dev.to/raihan-js</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%2F254365%2Fb923a1bc-0077-4742-bafd-fff5c5d9b555.png</url>
      <title>DEV Community: Raihan</title>
      <link>https://dev.to/raihan-js</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/raihan-js"/>
    <language>en</language>
    <item>
      <title>AWQ Looked 10 Points Better on GSM8K Until I Stopped Truncating the Answers</title>
      <dc:creator>Raihan</dc:creator>
      <pubDate>Mon, 05 Oct 2026 14:30:47 +0000</pubDate>
      <link>https://dev.to/raihan-js/awq-looked-10-points-better-on-gsm8k-until-i-stopped-truncating-the-answers-18a7</link>
      <guid>https://dev.to/raihan-js/awq-looked-10-points-better-on-gsm8k-until-i-stopped-truncating-the-answers-18a7</guid>
      <description>&lt;p&gt;&lt;em&gt;FlipGate: a release gate that counts per-item answer flips against a measured noise floor, what it found on Qwen2.5-3B, and the bug in my own baseline that I had to fix first.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Furqvaojkaxlwjjcio6uy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Furqvaojkaxlwjjcio6uy.png" alt="FlipGate GSM8K flip counts"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;When you quantise an LLM from bf16 to INT4, the standard metric is accuracy: does the quantised model get the same percentage of questions right?&lt;/p&gt;

&lt;p&gt;But accuracy hides something critical. A model can keep the same accuracy while flipping many individual answers from right to wrong, compensated by wrong-to-right flips elsewhere. For production systems, these per-item flips can break user trust, violate compliance requirements, or cause subtle bugs that aggregate metrics miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FlipGate&lt;/strong&gt; is a release gate that counts per-item answer flips, compares them against a measured noise floor, and uses paired statistical tests to determine if a quantisation change actually regressed the model.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Measure the Noise Floor
&lt;/h3&gt;

&lt;p&gt;Even with temperature=0 (greedy decoding), the same model can answer differently when the serving setup changes. Batch size, kernel choice, and floating-point reduction order all move the numbers.&lt;/p&gt;

&lt;p&gt;We measure this "noise floor" by running the same bf16 model under the conditions that vary in practice and counting how often &lt;em&gt;correctness&lt;/em&gt; flips. This establishes the baseline: how many flips are just noise?&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="c1"&gt;# Pseudocode
&lt;/span&gt;&lt;span class="n"&gt;baseline_run_1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;baseline_run_2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;noise_floor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;count_flips&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseline_run_1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseline_run_2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Evaluate the Candidate
&lt;/h3&gt;

&lt;p&gt;Run the quantised model (AWQ, GPTQ, GGUF, etc.) on the same dataset with the same prompts.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Compare with Paired Statistics
&lt;/h3&gt;

&lt;p&gt;For each item, we know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the baseline get it right?&lt;/li&gt;
&lt;li&gt;Did the candidate get it right?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives us four categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Both correct&lt;/strong&gt;: No flip&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both wrong&lt;/strong&gt;: No flip&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right → Wrong&lt;/strong&gt;: Regression (bad)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong → Right&lt;/strong&gt;: Improvement (good)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We use &lt;strong&gt;McNemar's test&lt;/strong&gt; to determine if the right→wrong flips are statistically significant compared to wrong→right flips. We also compute &lt;strong&gt;bootstrap confidence intervals&lt;/strong&gt; for the flip rate.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Gate Decision
&lt;/h3&gt;

&lt;p&gt;The gate has two separate tripwires, and it &lt;strong&gt;fails&lt;/strong&gt; if either fires:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Asymmetry:&lt;/strong&gt; McNemar's test finds significantly more right→wrong than wrong→right flips (p &amp;lt; 0.05).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Floor:&lt;/strong&gt; a noise floor was supplied (&lt;code&gt;--noise-floor&lt;/code&gt;) and the right→wrong rate exceeds that floor times a margin (default 2×).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Otherwise it &lt;strong&gt;passes&lt;/strong&gt;. Two caveats, both raised by a reader of this post. McNemar compares the two flip directions, so churn that nets out cannot trip tripwire 1 (see the IFEval section). Tripwire 2 is skipped when no floor is given (the default is 0), it is a point threshold on the floor estimate rather than a one-sided test against an interval, and the only floor measured here comes from GSM8K.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flipgate/
├── src/flipgate/
│   ├── scorers/          # GSM8K, IFEval, FedProc (registry hallucination)
│   ├── engines/          # HF generate, vLLM, llama.cpp
│   ├── stats/            # McNemar's test, paired bootstrap CI
│   ├── store.py          # Per-item JSONL results (append-only)
│   ├── manifest.py       # Frozen eval config (pinned versions)
│   └── cli.py            # flipgate check, flipgate info, etc.
├── tests/                # 146 pytest tests
├── configs/manifest.yaml # Pinned models, datasets, sampling params
└── .github/actions/      # GitHub Action for CI/CD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key Design Decisions
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Frozen Manifest&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every eval run records:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exact model versions (git SHAs)&lt;/li&gt;
&lt;li&gt;Dataset revisions&lt;/li&gt;
&lt;li&gt;Sampling parameters (temperature, top_p, etc.)&lt;/li&gt;
&lt;li&gt;Engine versions&lt;/li&gt;
&lt;li&gt;Manifest SHA&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This ensures reproducibility. If someone questions your results, you can point to the exact configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Append-Only Results Store&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Results are stored as JSONL files, one per item. We never overwrite—only append. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full audit trail&lt;/li&gt;
&lt;li&gt;Easy to re-analyze with different scorers&lt;/li&gt;
&lt;li&gt;Simple to diff between runs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. No LLM-as-Judge&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All scorers are rule-based:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GSM8K&lt;/strong&gt;: Exact numeric match on final answer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IFEval&lt;/strong&gt;: Instruction following checks (word count, formatting, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FedProc&lt;/strong&gt;: Registry hallucination check (does this clause number exist?)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LLM judges are slow, expensive, and non-deterministic. Rule-based scorers are fast, cheap, and reproducible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Qwen2.5-3B-Instruct on four task families. All sampling is greedy; the GSM8K sweeps use a 1,024-token cap and batch size 1.&lt;/p&gt;

&lt;h3&gt;
  
  
  GSM8K: both quantised models fail the gate (1,000 items)
&lt;/h3&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;Accuracy&lt;/th&gt;
&lt;th&gt;Δ vs bf16&lt;/th&gt;
&lt;th&gt;Items&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bf16 (baseline)&lt;/td&gt;
&lt;td&gt;79.7% (797/1000)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWQ (4-bit)&lt;/td&gt;
&lt;td&gt;76.2% (762/1000)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−3.5 pts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTQ-Int4&lt;/td&gt;
&lt;td&gt;76.0% (760/1000)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−3.7 pts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison&lt;/th&gt;
&lt;th&gt;Right→Wrong&lt;/th&gt;
&lt;th&gt;Wrong→Right&lt;/th&gt;
&lt;th&gt;R→W rate&lt;/th&gt;
&lt;th&gt;95% CI&lt;/th&gt;
&lt;th&gt;McNemar p&lt;/th&gt;
&lt;th&gt;Gate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bf16 vs AWQ&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;56&lt;/td&gt;
&lt;td&gt;9.1%&lt;/td&gt;
&lt;td&gt;[7.3%, 10.9%]&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0050&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bf16 vs GPTQ-Int4&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;9.1%&lt;/td&gt;
&lt;td&gt;[7.4%, 10.9%]&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0028&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each quantised model broke 91 answers that bf16 got right, 11% of the 797, while fixing 54-56 that bf16 got wrong. Every response ended naturally (3,000 of 3,000, mean 274-297 tokens), so none of this is truncation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The noise floor: near 0 for reruns, about 3% when the batch size changes
&lt;/h3&gt;

&lt;p&gt;Repeating a run at a fixed batch size gives identical output on HF generate (0 flips, 1,024-token cap) but not exactly on eager vLLM: in my first harness it gave 0 flips (256-token cap), and at the 1,024-token cap two identical batch-32 runs differed on 4 of 1,000 items (1 right→wrong). Changing the batch size changes much more. bf16 at batch size 1 vs batch size 8, same weights and prompts, first 200 items: &lt;strong&gt;124 of 200 responses differed in text, but only 13 changed correctness&lt;/strong&gt; (6 right→wrong, 7 wrong→right). That is a right→wrong floor of &lt;strong&gt;3.0% [1.0%, 5.5%]&lt;/strong&gt;, McNemar p = 1.0. The quantised models' 9.1% is three times that, and the floor's upper bound (5.5%) sits below the quantised models' lower bound (7.3%). A 24-prompt text check shows the same sensitivity for all three models: 14/24 (bf16), 5/24 (AWQ), 6/24 (GPTQ) responses differ between batch size 1 and 8. So baseline and candidate must use the same batch size, and the sweeps here use batch size 1.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on the floor:&lt;/strong&gt; the original design expressed flips as a &lt;em&gt;multiple&lt;/em&gt; of the noise floor. With a floor of exactly 0% that ratio is undefined, so the gate reports the flip rate with its 95% CI and compares it with the measured floor plus a margin.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Engine control: HF generate vs vLLM, same bf16 weights
&lt;/h3&gt;

&lt;p&gt;If you move a model from one serving engine to another, how many answers change? Same Qwen2.5-3B-Instruct bf16 weights, prompts, chat template, 1,024-token cap and scorer; HF generate at batch size 1 against vLLM 0.30.0 (eager, FlashAttention) at batch size 32 and 8, GSM8K-1000:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison (baseline → candidate)&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;th&gt;Right→wrong&lt;/th&gt;
&lt;th&gt;Wrong→right&lt;/th&gt;
&lt;th&gt;R→W rate [95% CI]&lt;/th&gt;
&lt;th&gt;McNemar p&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HF batch 1 → vLLM batch 32&lt;/td&gt;
&lt;td&gt;79.7% → 81.4%&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;4.3% [3.1%, 5.8%]&lt;/td&gt;
&lt;td&gt;0.115&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HF batch 1 → vLLM batch 8&lt;/td&gt;
&lt;td&gt;79.7% → 81.9%&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;3.6% [2.5%, 4.8%]&lt;/td&gt;
&lt;td&gt;0.030&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vLLM batch 32, repeat run&lt;/td&gt;
&lt;td&gt;81.4% → 81.6%&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.1% [0.0%, 0.3%]&lt;/td&gt;
&lt;td&gt;0.62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vLLM batch 32 → batch 8&lt;/td&gt;
&lt;td&gt;81.4% → 81.9%&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;1.9% [1.1%, 2.8%]&lt;/td&gt;
&lt;td&gt;0.54&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Swapping the engine alone broke 3.6–4.3% of previously correct answers&lt;/strong&gt; and fixed 5.8–6.0%, so about 10% of answers changed correctness while accuracy moved by about 2 points. That is less than quantisation does on the same engine (9.1% right→wrong for AWQ and GPTQ; the quantised lower bound, 7.3%, is above the engine upper bound, 5.8%). It is the opposite of the llama.cpp numbers I withdrew below. Caveats: the two engine rows share one HF baseline (p = 0.115 for batch 32, p = 0.030 for batch 8, both reported), the comparison also changes the batch size (worth 2–3% on its own), and it is bf16 only, one model, one GPU, eager mode.&lt;/p&gt;

&lt;h3&gt;
  
  
  The answer extractor decides the sign of the accuracy change
&lt;/h3&gt;

&lt;p&gt;The quantised models drift away from the &lt;code&gt;\boxed{}&lt;/code&gt; final-answer format: 57.7% of bf16 responses contain &lt;code&gt;\boxed&lt;/code&gt;, against 28.3% for AWQ and 32.8% for GPTQ. The original strict extractor scores the &lt;em&gt;same stored responses&lt;/em&gt; as bf16 60.0%, AWQ 63.2%, GPTQ 61.0%: AWQ apparently &lt;em&gt;better&lt;/em&gt; by 3.2 points (109 right→wrong, 141 wrong→right, p = 0.050) and GPTQ unchanged (125 vs 135, p = 0.58). The robust extractor (last &lt;code&gt;\boxed{}&lt;/code&gt;, then &lt;code&gt;####&lt;/code&gt;, then "answer is", then the last number) gives the table above. A change in answer &lt;em&gt;format&lt;/em&gt; is a real change a gate should surface, but it is not a change in reasoning, and a strict extractor will happily report it as an accuracy gain.&lt;/p&gt;

&lt;h3&gt;
  
  
  IFEval: instruction following degrades (541 prompts)
&lt;/h3&gt;

&lt;p&gt;541 rule-checked instruction-following prompts scored with my own reimplementation of the 25 published IFEval rules:&lt;/p&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;Accuracy&lt;/th&gt;
&lt;th&gt;Δ vs bf16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bf16 (baseline)&lt;/td&gt;
&lt;td&gt;59.5% (322/541)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWQ (4-bit)&lt;/td&gt;
&lt;td&gt;56.7% (307/541)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−2.8 pts&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTQ-Int4&lt;/td&gt;
&lt;td&gt;58.6% (317/541)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−0.9 pts&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison&lt;/th&gt;
&lt;th&gt;Right→Wrong&lt;/th&gt;
&lt;th&gt;Wrong→Right&lt;/th&gt;
&lt;th&gt;R→W rate&lt;/th&gt;
&lt;th&gt;95% CI&lt;/th&gt;
&lt;th&gt;McNemar p&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bf16 vs AWQ&lt;/td&gt;
&lt;td&gt;56&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;10.4%&lt;/td&gt;
&lt;td&gt;[7.8%, 12.9%]&lt;/td&gt;
&lt;td&gt;0.155&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bf16 vs GPTQ-Int4&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;8.3%&lt;/td&gt;
&lt;td&gt;[6.1%, 10.5%]&lt;/td&gt;
&lt;td&gt;0.664&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The aggregate moves a little and the flips quantify it: 56 and 45 previously passing instructions broke, though neither difference is significant at this sample size.&lt;/p&gt;

&lt;p&gt;This is also the case the McNemar tripwire cannot see. For AWQ, 56 instructions broke and 41 were fixed (exact p = 0.155), so with no floor supplied the gate passes it, although the 10.4% right→wrong rate has a lower bound (7.8%) above the top of the GSM8K batch-size floor (5.5%). Supplying that floor (3.0%) would trip the 2× floor threshold (6.0%), but it is a floor from a different task family. I have not measured an IFEval floor, and format-checked instructions may flip more under batch changes than GSM8K answers do, so I cannot say how much of the 10.4% is noise.&lt;/p&gt;

&lt;h3&gt;
  
  
  FedProc: hallucination rises (155 real-FAR records)
&lt;/h3&gt;

&lt;p&gt;The third family needs no accuracy at all, just a registry. Each model is prompted with a clause topic and every cited FAR/DFARS number is checked against the 1,128-entry registry built from eCFR Title 48. Score 1.0 means no fabricated numbers. Only the real-FAR slice is used; the 65 Claude-written synthetic records stay out.&lt;/p&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;No-hallucination rate&lt;/th&gt;
&lt;th&gt;Δ vs bf16&lt;/th&gt;
&lt;th&gt;New hallucinations&lt;/th&gt;
&lt;th&gt;Fixed&lt;/th&gt;
&lt;th&gt;McNemar p&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bf16 (baseline)&lt;/td&gt;
&lt;td&gt;81.9% (127/155)&lt;/td&gt;
&lt;td&gt;—&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;AWQ (4-bit)&lt;/td&gt;
&lt;td&gt;67.1% (104/155)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−14.8 pts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0010&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTQ-Int4&lt;/td&gt;
&lt;td&gt;74.2% (115/155)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−7.7 pts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0376&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both increases are significant: the gate's second tripwire, "fail when registry hallucination rises", fires with no LLM judge involved. (The outputs are short, so the generation cap does not matter here.)&lt;/p&gt;

&lt;h3&gt;
  
  
  BFCL: function calling is the most stable family (550 tasks)
&lt;/h3&gt;

&lt;p&gt;Three BFCL slices, simple (400), exec-simple (100, exact gold match) and exec-multiple (50), scored as executable-call rate:&lt;/p&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;Executable-call rate&lt;/th&gt;
&lt;th&gt;Δ vs bf16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bf16 (baseline)&lt;/td&gt;
&lt;td&gt;71.8% (395/550)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWQ (4-bit)&lt;/td&gt;
&lt;td&gt;74.0% (407/550)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+2.2 pts&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTQ-Int4&lt;/td&gt;
&lt;td&gt;71.1% (391/550)&lt;/td&gt;
&lt;td&gt;−0.7 pts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison&lt;/th&gt;
&lt;th&gt;Right→Wrong&lt;/th&gt;
&lt;th&gt;Wrong→Right&lt;/th&gt;
&lt;th&gt;R→W rate&lt;/th&gt;
&lt;th&gt;95% CI&lt;/th&gt;
&lt;th&gt;McNemar p&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bf16 vs AWQ&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;1.1%&lt;/td&gt;
&lt;td&gt;[0.4%, 2.0%]&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0247&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bf16 vs GPTQ-Int4&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;2.5%&lt;/td&gt;
&lt;td&gt;[1.3%, 4.0%]&lt;/td&gt;
&lt;td&gt;0.5403&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Structured output with explicit schemas resists flips better than free-form reasoning. At n=400 AWQ sat at p=0.055; at n=550 it crossed to significance, so treat a p-value as a statement about one harness at one sample size.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many items do you need?
&lt;/h3&gt;

&lt;p&gt;For paired binary outcomes (McNemar, 80% power, α = 0.05) the answer depends on how many items flip in either direction. In these runs about 14.7% of GSM8K items flipped (right→wrong or wrong→right) between bf16 and AWQ. Under that assumption, detecting a net change of 1 point needs about 11,500 items, 2 points about 2,900, 3.5 points about 940, and 5 points about 460. The 1,000-item sweeps sit right at the 3.5-point row, which is why the −3.5 result is significant but not by a wide margin, and why per-item flip counts are more informative than waiting for aggregate accuracy to move.&lt;/p&gt;

&lt;h3&gt;
  
  
  A bug in my own baseline: the 256-token cap
&lt;/h3&gt;

&lt;p&gt;The first version of this article was titled "AWQ raised GSM8K accuracy by 10 points and broke 77 correct answers". The GSM8K script generated with &lt;code&gt;max_new_tokens=256&lt;/code&gt; (the manifest said 2048; the script never read it). Qwen2.5-3B-Instruct usually needs more than 256 tokens for chain-of-thought, so most responses were cut off before a final answer: only 16.3% (bf16), 8.0% (AWQ) and 8.6% (GPTQ) contained a &lt;code&gt;\boxed{}&lt;/code&gt; answer, and the "accuracy" of 33.0% / 43.1% / 37.3% mostly measured who finished within 256 tokens. The more verbose bf16 model lost to the terser quantised ones for reasons unrelated to quality. I found it by reading failing responses that stopped mid-equation.&lt;/p&gt;

&lt;p&gt;Two fixes followed. First, I re-ran everything with a 1,024-token cap (the table at the top of this section). The headline reversed: AWQ went from +10.1 points to −3.5, and GPTQ from +4.3 to −3.7. Second, FlipGate now refuses to compare runs like the old ones: each stored item carries a &lt;code&gt;finish_reason&lt;/code&gt;, and &lt;code&gt;flipgate check&lt;/code&gt; exits with &lt;strong&gt;INVALID&lt;/strong&gt; if more than 10% of responses hit the cap in either run, or if the two runs' truncation rates differ by more than 5 points. The gate is supposed to catch exactly this kind of silent change, so it should not be fooled by one in its own harness.&lt;/p&gt;

&lt;p&gt;I also withdrew an engine-versus-quantisation control (llama.cpp f16 against HF generate) that I had reported: it inherited the truncated baseline, and its llama.cpp prompt used a different system message from the HF chat template, so engine and prompt were confounded. The replacement is the HF-versus-vLLM control above.&lt;/p&gt;




&lt;h2&gt;
  
  
  Challenges &amp;amp; Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Read the raw responses
&lt;/h3&gt;

&lt;p&gt;The most expensive mistake in this project was not a kernel or a statistic. It was trusting an accuracy number without reading the outputs. A frozen manifest that does not match what the script actually ran is worse than no manifest. Reading the responses is also how I found the extractor effect: the quantised models change the &lt;em&gt;format&lt;/em&gt; of their answers, not only the content.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A noise floor of zero was a statement about one condition
&lt;/h3&gt;

&lt;p&gt;I measured 0 flips for repeated runs and nearly wrote "the floor is zero". It is zero when nothing changes; change the batch size and 62% of responses differ in text and 3% of items flip from right to wrong. Measure the floor under the changes you will actually make.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Sample size and extractor decided every conclusion
&lt;/h3&gt;

&lt;p&gt;At n=30 nothing was detectable (p=1.0 everywhere). BFCL for AWQ crossed from p=0.055 to p=0.0247 between n=400 and n=550. The GSM8K sign flipped with the answer extractor. Treat a p-value as a statement about one harness at one sample size.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Tooling friction
&lt;/h3&gt;

&lt;p&gt;vLLM's flashinfer sampler needed a newer CUDA toolchain than I had (&lt;code&gt;nvcc fatal: Unknown option '--compress-mode=size'&lt;/code&gt;); the fix was disabling it and forcing FlashAttention. gptqmodel's Marlin kernels did not compile against torch 2.13 / CUDA 13, so the quantised runs use HF generate, and the installed llama.cpp binding is CPU-only. Test your inference engine on the target hardware before committing to an evaluation pipeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;h3&gt;
  
  
  For LLMOps teams
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Catch silent regressions&lt;/strong&gt;: here a 3.5-point accuracy drop came with 91 individually broken answers, and on the registry check 34 newly fabricated clauses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know your floor&lt;/strong&gt;: a flip rate means little until you have measured what your own serving changes do with the weights held fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproducible evaluations&lt;/strong&gt;: frozen manifests and append-only results let you audit a decision, and let you find a bug in the harness months later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD integration&lt;/strong&gt;: the GitHub Action gates deployments automatically, and the harness-health checks make it fail loudly on a broken comparison.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  For the community
&lt;/h3&gt;

&lt;p&gt;The problem is real (accuracy hides flips), the method is straightforward (paired tests plus a measured floor), and the tooling is missing: most teams only check accuracy.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Larger models (7B, 13B)&lt;/strong&gt; on rented GPUs, and a Marlin build so quantised models can run on vLLM (which would allow an engine-by-quantisation control).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More task families&lt;/strong&gt;, for example code generation with execution-based scoring, and a Japanese task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Noise floor under non-eager vLLM&lt;/strong&gt; and other stacks.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/raihan-js/flipgate
&lt;span class="nb"&gt;cd &lt;/span&gt;flipgate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;".[dev]"&lt;/span&gt;
pytest tests/ &lt;span class="nt"&gt;-v&lt;/span&gt;

&lt;span class="c"&gt;# Check a candidate against a baseline (same batch size!)&lt;/span&gt;
flipgate check &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--baseline&lt;/span&gt; &amp;lt;baseline_run_id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--candidate&lt;/span&gt; &amp;lt;candidate_run_id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dataset&lt;/span&gt; gsm8k
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/raihan-js/flipgate" rel="noopener noreferrer"&gt;https://github.com/raihan-js/flipgate&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Dataset&lt;/strong&gt;: &lt;a href="https://huggingface.co/datasets/raihan-js/flipgate-results" rel="noopener noreferrer"&gt;https://huggingface.co/datasets/raihan-js/flipgate-results&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One model&lt;/strong&gt;: Qwen2.5-3B-Instruct only. Results may not generalise to other families or sizes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer extractors matter&lt;/strong&gt;: the GSM8K accuracy change flips sign between a strict and a robust extractor; the robust one is used, and the format drift is itself a finding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quantised runs on HF generate only&lt;/strong&gt;: Marlin kernels did not compile here, so quantised models were not served through vLLM. The engine control is bf16 only, so there is no engine-by-quantisation interaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Noise floor&lt;/strong&gt;: the batch-size floor (3.0% [1.0%, 5.5%]) is from 200 GSM8K items on HF generate and has not been measured for IFEval, FedProc or BFCL, and the vLLM figures are from 1,000 items in eager mode, all on one GPU; the floor under other stacks, kernels and multi-GPU setups can differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap and batch size&lt;/strong&gt;: the GSM8K results are for a 1,024-token cap at batch size 1. The IFEval (1,024-token cap), FedProc (128) and BFCL (256) runs predate the finish-reason field, so truncation there was not measured; their outputs are short, but that is an assumption.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Aggregate accuracy hides item-level change, and a gate has to be right about its own measurements first. On GSM8K both quantised variants lost 3.5-3.7 points and broke 91 previously correct answers each, three times the batch-size noise floor, and the gate fails both. On the FAR registry check they fabricated more clauses (AWQ added 34, McNemar p=0.001); on function calling almost nothing moved. Which family you gate on matters, so does reading the outputs, and so does admitting when the first headline was a measurement artefact.&lt;/p&gt;

&lt;p&gt;The tool is a CLI and a GitHub Action; the framework is extensible with your own scorers, engines and datasets.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions or corrections? Open an issue on GitHub: github.com/raihan-js/flipgate&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>testing</category>
    </item>
    <item>
      <title>How Accurate Is Your Model Right Now? Estimating Accuracy Without Labels</title>
      <dc:creator>Raihan</dc:creator>
      <pubDate>Sun, 04 Oct 2026 17:26:52 +0000</pubDate>
      <link>https://dev.to/raihan-js/how-accurate-is-your-model-right-now-estimating-accuracy-without-labels-59kp</link>
      <guid>https://dev.to/raihan-js/how-accurate-is-your-model-right-now-estimating-accuracy-without-labels-59kp</guid>
      <description>&lt;p&gt;&lt;em&gt;Label-free accuracy estimation under data shift — and the monitoring sidecar that uses it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0lwhfn2jffr8snuoa7n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0lwhfn2jffr8snuoa7n.png" alt="shiftwatch results" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scope note: two intent-classification datasets (Banking77, CLINC150), one ModernBERT-base classifier each, rule-based shift ladder. No vision, no NLU, no frontier models.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Your model was 91% accurate in validation. Three months later, nobody knows what it is. Labels are expensive, slow, and often weeks away. Drift dashboards show you input statistics moved — but that doesn't tell you if the model is still good.&lt;/p&gt;

&lt;p&gt;The production question is: &lt;strong&gt;what is my accuracy right now, with no labels?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The estimators
&lt;/h2&gt;

&lt;p&gt;Six published methods (plus an MLP variant of the error predictor, which is why the result tables have seven rows), all sharing one contract: fit on labelled source-validation logits once, then estimate on shifted data with labels withheld.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Estimator&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Key idea&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mean confidence&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Naive: mean max softmax&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temperature scaling&lt;/td&gt;
&lt;td&gt;Guo et al. 2017&lt;/td&gt;
&lt;td&gt;Fit T on source val, then mean confidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DoC&lt;/td&gt;
&lt;td&gt;Liang et al. 2023&lt;/td&gt;
&lt;td&gt;Source accuracy + (source conf − target conf)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CBPE&lt;/td&gt;
&lt;td&gt;NannyML&lt;/td&gt;
&lt;td&gt;Per-class recall from calibrated probs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NLL&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Calibrated: exp(−mean NLL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error predictor&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Logistic regression on confidence features&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ATC (Garg et al. 2022) is omitted: its self-agreement requires sampling, which degenerates under greedy decoding (argmax is temperature-invariant).&lt;/p&gt;

&lt;h2&gt;
  
  
  The shift ladder
&lt;/h2&gt;

&lt;p&gt;Deterministic, rule-based corruptions — no GPU, no LLM, fully reproducible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Typos&lt;/strong&gt;: keyboard-adjacent swaps, deletions, duplications (3 severities)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abbreviations&lt;/strong&gt;: domain word → abbreviation (3 severities)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Style&lt;/strong&gt;: lowercasing, punctuation removal, politeness padding, whitespace noise (3 severities)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OOS contamination&lt;/strong&gt;: replace X% of in-scope texts with out-of-scope ones (10/20/30/40%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Labels are written to a separate holdback file. The benchmark runner reads them only after every estimator has produced its estimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Two datasets, 24 slices total (1,000 items each), ModernBERT-base classifiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading the columns.&lt;/strong&gt; MAE is the mean absolute gap between estimated and true accuracy over the 24 slices. &lt;em&gt;Detect&lt;/em&gt; is the share of slices with a true accuracy drop of at least 5 points that the estimator also flagged (estimated drop of at least 5 points). &lt;em&gt;False Alarm&lt;/em&gt; is the share of the other slices it flagged anyway. Both use the 5-point threshold the sidecar defaults to. These rates rest on small counts: in each dataset 6 slices have a true drop of at least 5 points, so a detect rate of 0.83 is 5 of 6, and Banking77's false-alarm rate of 0.25 is 1 of 4 non-drop slices.&lt;/p&gt;

&lt;h3&gt;
  
  
  Banking77 (77 classes, clean accuracy 92.7%)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Estimator&lt;/th&gt;
&lt;th&gt;MAE&lt;/th&gt;
&lt;th&gt;Bias&lt;/th&gt;
&lt;th&gt;Detect&lt;/th&gt;
&lt;th&gt;False Alarm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;temp_scaled&lt;/td&gt;
&lt;td&gt;0.0109&lt;/td&gt;
&lt;td&gt;-0.0035&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mean_confidence&lt;/td&gt;
&lt;td&gt;0.0110&lt;/td&gt;
&lt;td&gt;-0.0017&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;error_predictor&lt;/td&gt;
&lt;td&gt;0.0123&lt;/td&gt;
&lt;td&gt;+0.0043&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nll&lt;/td&gt;
&lt;td&gt;0.0174&lt;/td&gt;
&lt;td&gt;-0.0090&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;error_predictor_mlp&lt;/td&gt;
&lt;td&gt;0.0344&lt;/td&gt;
&lt;td&gt;+0.0266&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cbpe&lt;/td&gt;
&lt;td&gt;0.1237&lt;/td&gt;
&lt;td&gt;+0.1213&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;doc&lt;/td&gt;
&lt;td&gt;0.2473&lt;/td&gt;
&lt;td&gt;+0.2453&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  CLINC150 (151 classes, clean accuracy 89.6%)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Estimator&lt;/th&gt;
&lt;th&gt;MAE&lt;/th&gt;
&lt;th&gt;Bias&lt;/th&gt;
&lt;th&gt;Detect&lt;/th&gt;
&lt;th&gt;False Alarm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;error_predictor_mlp&lt;/td&gt;
&lt;td&gt;0.0101&lt;/td&gt;
&lt;td&gt;+0.0035&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nll&lt;/td&gt;
&lt;td&gt;0.0110&lt;/td&gt;
&lt;td&gt;-0.0021&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;error_predictor&lt;/td&gt;
&lt;td&gt;0.0125&lt;/td&gt;
&lt;td&gt;+0.0038&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;temp_scaled&lt;/td&gt;
&lt;td&gt;0.0140&lt;/td&gt;
&lt;td&gt;+0.0007&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mean_confidence&lt;/td&gt;
&lt;td&gt;0.0234&lt;/td&gt;
&lt;td&gt;+0.0164&lt;/td&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cbpe&lt;/td&gt;
&lt;td&gt;0.0657&lt;/td&gt;
&lt;td&gt;+0.0656&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;doc&lt;/td&gt;
&lt;td&gt;0.1308&lt;/td&gt;
&lt;td&gt;+0.1308&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The headline
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;No single estimator dominates.&lt;/strong&gt; On Banking77 temperature scaling and mean confidence are tied (MAE 0.0109 vs 0.0110, within noise of each other); on CLINC150, where out-of-scope contamination breaks calibration, the MLP error predictor leads (0.0101, with NLL at 0.0110). DoC and CBPE fail to detect drops on both datasets: they are too optimistic under shift.&lt;/p&gt;

&lt;p&gt;The practical takeaway: &lt;strong&gt;fit the error predictor on your own source data&lt;/strong&gt;. It costs one labelled validation set and a logistic regression, it is in the top three on both datasets with no false alarms, and it adapts to your model's failure modes. Mean confidence is a strong baseline when the model is well-calibrated, but it degrades under OOS contamination (MAE 0.0234 on CLINC150, detecting half the drops).&lt;/p&gt;

&lt;h2&gt;
  
  
  The sidecar
&lt;/h2&gt;

&lt;p&gt;A small FastAPI service that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accepts probability vectors (never labels)&lt;/li&gt;
&lt;li&gt;Keeps a rolling window of predictions&lt;/li&gt;
&lt;li&gt;Republishes estimated accuracy with the winning estimator&lt;/li&gt;
&lt;li&gt;Exports Prometheus gauges and an alert flag
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;shiftwatch serve &lt;span class="nt"&gt;--dataset&lt;/span&gt; clinc150            &lt;span class="c"&gt;# defaults to --estimator nll&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nll&lt;/code&gt; is the sidecar's default; see the update below for why. (It first defaulted to &lt;code&gt;cbpe&lt;/code&gt;, which never detected a drop in this benchmark; the benchmark is what caught that.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Update (2026-10-05): the sidecar now defaults to NLL
&lt;/h2&gt;

&lt;p&gt;A comment from Pawel (&lt;a class="mentioned-user" href="https://dev.to/pawel_nowak"&gt;@pawel_nowak&lt;/a&gt;) made the point that for a real sidecar a false alarm costs more than a slightly higher MAE, because an alert that cries wolf gets muted. His rule: best Detect rate first, then fewest False Alarms, MAE as the tiebreaker. I re-ran the selection on the tables above. On Banking77 four estimators tie at 1.00 Detect with zero False Alarms (&lt;code&gt;mean_confidence&lt;/code&gt;, &lt;code&gt;error_predictor&lt;/code&gt;, &lt;code&gt;nll&lt;/code&gt;, &lt;code&gt;error_predictor_mlp&lt;/code&gt;). On CLINC150 only &lt;code&gt;nll&lt;/code&gt; does; &lt;code&gt;error_predictor&lt;/code&gt; detects 5 of 6 drops. So the default is now &lt;code&gt;nll&lt;/code&gt;. The price is a less accurate point estimate on Banking77 (MAE 0.0174 vs 0.0109 for the best estimator). The counts behind these rates are small (6 drop slices per dataset), so treat the ordering as suggestive, not settled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two datasets, both intent classification — no vision or NLU.&lt;/li&gt;
&lt;li&gt;Shift ladder is rule-based; real-world shifts are messier.&lt;/li&gt;
&lt;li&gt;Estimators assume the model is reasonably calibrated on source.&lt;/li&gt;
&lt;li&gt;Sidecar is a prototype, not production-hardened.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Repo: github.com/raihan-js/shiftwatch · 99 tests green. The estimators are published methods; the contribution is the benchmark and the honest testbed.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>mlops</category>
      <category>python</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>I built the first open benchmark for federal contracting AI. Here's what it shows about frontier LLMs.</title>
      <dc:creator>Raihan</dc:creator>
      <pubDate>Tue, 12 May 2026 12:16:46 +0000</pubDate>
      <link>https://dev.to/raihan-js/i-built-the-first-open-benchmark-for-federal-contracting-ai-heres-what-it-shows-about-frontier-5a03</link>
      <guid>https://dev.to/raihan-js/i-built-the-first-open-benchmark-for-federal-contracting-ai-heres-what-it-shows-about-frontier-5a03</guid>
      <description>&lt;p&gt;If you ask GPT-4o or Claude to extract Federal Acquisition Regulation clause numbers from a federal solicitation, a non-trivial fraction of the time they will hand you a number that does not exist. There is no &lt;code&gt;FAR 52.999-99&lt;/code&gt;. The model just made it up. For a federal contractor staffing a proposal, that is the difference between a clean compliance matrix and a rejected bid.&lt;/p&gt;

&lt;p&gt;I went looking for a benchmark that measured this. There isn't one. Commercial tools in the space — Capture2Proposal, GovTribe, GovWin, OrangeSlices — all do natural-language processing on federal solicitations, but none publish benchmarks. Academic work on RFP processing is narrow and one-off. GSA's own &lt;code&gt;srt-fbo-scraper&lt;/code&gt; covers only Section 508 compliance.&lt;/p&gt;

&lt;p&gt;So I built one.&lt;/p&gt;

&lt;h2&gt;
  
  
  FedProc-Bench
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/datasets/raihan-js/fedproc-bench" rel="noopener noreferrer"&gt;FedProc-Bench&lt;/a&gt; is a multi-task benchmark for federal procurement NLP. Four tasks, drawn from real federal contracting sources:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;What it tests&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Notice type classification&lt;/td&gt;
&lt;td&gt;Eight SAM.gov notice-type buckets — Solicitation, Combined Synopsis/Solicitation, Sources Sought, and so on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;NAICS sector prediction&lt;/td&gt;
&lt;td&gt;Twenty top-level NAICS sectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Set-aside identification&lt;/td&gt;
&lt;td&gt;Multi-label across SBA, SDVOSB, WOSB, EDWOSB, 8(a), HUBZone, and SDB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;FAR / DFARS clause extraction&lt;/td&gt;
&lt;td&gt;Token-level entity recognition on canonical clause numbers like &lt;code&gt;52.219-9&lt;/code&gt; or &lt;code&gt;252.225-7042&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Task 4 is the headline. It is the task where frontier LLMs visibly fail.&lt;/p&gt;

&lt;p&gt;The data sources are public and free. SAM.gov provides the solicitations themselves through its Opportunities API. The Electronic Code of Federal Regulations gives me Title 48 — the full FAR and DFARS — as structured XML, which I parse down to 1,032 individual clause records. Claude Haiku fills in a small amount of synthetic augmentation for rare set-aside types like HUBZone and EDWOSB that real SAM data barely contains. Every record carries a &lt;code&gt;source&lt;/code&gt; and &lt;code&gt;label_origin&lt;/code&gt; field, so anyone can audit the provenance line by line.&lt;/p&gt;

&lt;p&gt;The v0 release ships 1,615 records split 1,129 / 243 / 243 train / val / test. That is small. I had originally targeted 10,000. We will come back to why I have less in the section on what bit me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model
&lt;/h2&gt;

&lt;p&gt;The companion model — &lt;a href="https://huggingface.co/raihan-js/fedproc-180m-v0" rel="noopener noreferrer"&gt;&lt;code&gt;raihan-js/fedproc-180m-v0&lt;/code&gt;&lt;/a&gt; — is a 149-million-parameter ModernBERT-base with one shared encoder and four task heads. Sequence classification heads for tasks 1 and 2 (softmax over the label set), seven sigmoid heads for the multi-label set-aside task, and a per-token BIO head for the FAR-clause extractor.&lt;/p&gt;

&lt;p&gt;The interesting design choice is the &lt;strong&gt;task mask&lt;/strong&gt;. Records from different sources contribute different supervision: SAM metadata contributes tasks 1, 2, and 3; raw FAR clause text contributes task 4; synthetic excerpts contribute all four. Inside the model's forward pass, a per-record four-boolean mask says which heads get gradient for each example. That is how a single model trains jointly on heterogeneous sources without diluting any head's signal.&lt;/p&gt;

&lt;p&gt;Training takes 4.3 minutes on a single RTX 3060 for six epochs. The whole training run cost me zero dollars and the electricity to keep my desk lamp on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I compared against
&lt;/h2&gt;

&lt;p&gt;I ran the same four tasks through three frontier systems and the trained model:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Claude Sonnet 4.6 (Anthropic)&lt;/li&gt;
&lt;li&gt;GPT-4o (OpenAI)&lt;/li&gt;
&lt;li&gt;Claude Haiku 4.5 (Anthropic)&lt;/li&gt;
&lt;li&gt;FedProc-180M v0 (the model I trained)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each system gets the same prompt and the same test split. For task 4, a clean canonical metric: entity F1 with exact match on clause-number strings, plus a &lt;strong&gt;hallucination rate&lt;/strong&gt;, which I define as the share of predicted clause numbers that do not appear anywhere in the cached real FAR + DFARS corpus. Inventing a number that does not exist is the failure mode that matters here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline results
&lt;/h2&gt;

&lt;p&gt;Aggregate scores across all four tasks (mean of per-task macro-F1; task 4 is entity F1):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Aggregate&lt;/th&gt;
&lt;th&gt;T4 entity F1&lt;/th&gt;
&lt;th&gt;T4 hallucination&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Claude Sonnet 4.6&lt;/td&gt;
&lt;td&gt;0.911&lt;/td&gt;
&lt;td&gt;0.991&lt;/td&gt;
&lt;td&gt;0.0% (0 / 493)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;GPT-4o&lt;/td&gt;
&lt;td&gt;0.896&lt;/td&gt;
&lt;td&gt;0.970&lt;/td&gt;
&lt;td&gt;4.4% (23 / 517)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;0.851&lt;/td&gt;
&lt;td&gt;0.916&lt;/td&gt;
&lt;td&gt;15.0% (88 / 587)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;FedProc-180M v0&lt;/td&gt;
&lt;td&gt;0.497&lt;/td&gt;
&lt;td&gt;0.921&lt;/td&gt;
&lt;td&gt;5.5% (26 / 473)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Claude Sonnet 4.6 is genuinely impressive — zero invented clauses across 493 predicted spans. GPT-4o is close behind. The compact model places fourth on the aggregate because tasks 2 and 3 are weak in v0 (more on this in a moment), but on task 4 — the headline task — it is right behind GPT-4o and roughly matches Claude Haiku on F1 while inventing about a third as many fake clauses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest read
&lt;/h2&gt;

&lt;p&gt;Before anyone runs away with that table: 65 of the 220 task-4 test records are Claude-generated synthetic excerpts that cite specific pinned clauses. Frontier models from the Claude family are being graded on text their own family wrote. That is a real bias.&lt;/p&gt;

&lt;p&gt;The way I disclose this in the benchmark is to break out task 4 by record source. The real-FAR slice is the honest read because no system in this comparison helped author it:&lt;/p&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;Real FAR text — F1&lt;/th&gt;
&lt;th&gt;Real FAR text — hallucination&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 4.6&lt;/td&gt;
&lt;td&gt;0.984&lt;/td&gt;
&lt;td&gt;0.0% (0 / 182)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-4o&lt;/td&gt;
&lt;td&gt;0.937&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;11.0%&lt;/strong&gt; (23 / 209)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;0.804&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;32.1%&lt;/strong&gt; (88 / 274)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FedProc-180M v0&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;13.8%&lt;/strong&gt; (22 / 159)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So on the cleanest available slice, Claude Sonnet 4.6 still wins outright. GPT-4o is solid but invents a clause number more than one in ten times. Claude Haiku 4.5 invents a clause number almost a third of the time. And FedProc-180M, the compact specialized model, matches Haiku on F1 with &lt;strong&gt;less than half&lt;/strong&gt; the hallucination rate.&lt;/p&gt;

&lt;p&gt;That last bullet is the v0 takeaway: a 150M-parameter model trained in four minutes on a consumer GPU produces task-specific extraction that is competitive with Claude Haiku and demonstrably more reliable on the failure mode that matters for the use case. At roughly fifty times lower latency and three orders of magnitude lower per-call cost, that is a real Pareto point for federal contractors who want on-prem, predictable, auditable FAR-clause extraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is actually weak
&lt;/h2&gt;

&lt;p&gt;I am not going to oversell the rest of the table. Tasks 1, 2, 3 are limited in v0 because the SAM.gov daily quota on a non-federal API key ran out during the data pull before I could fetch description text for the cached solicitations. The model sees only titles like &lt;code&gt;53--O-RING&lt;/code&gt; for task-2 NAICS prediction. That is essentially impossible. v0.1, once the quota window cycles, will retrain on the full description text and these numbers should move substantially.&lt;/p&gt;

&lt;p&gt;The other honest caveat: 1,129 training records is tiny by NLP standards. The fact that ModernBERT-base lifts task 4 to 0.921 F1 on this little data is partly attributable to ModernBERT being a genuinely strong base model, and partly to the fact that the FAR-clause-number pattern is fundamentally structural — it is easier to learn &lt;code&gt;52.&amp;lt;digits&amp;gt;-&amp;lt;digits&amp;gt;&lt;/code&gt; than to learn what makes a notice an RFI versus a Sources Sought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;I am the founding engineer and AI/ML lead at &lt;a href="https://vetrproposal.com" rel="noopener noreferrer"&gt;VETR Proposal&lt;/a&gt;, which builds AI-assisted federal proposal management for SDVOSB, WOSB, and 8(a) contractors. Reliable FAR clause handling is core to that product. Before I shipped anything to a customer that touches clause citation, I wanted to know how often current AI systems make things up. There was no public answer. So I made the measurement public.&lt;/p&gt;

&lt;p&gt;That is the other reason the benchmark and dataset are open: anyone working in this space — competitors, GSA, academic groups, internal teams at large contractors — can now use the same yardstick. The benchmark is the contribution. The model is just one entry on the leaderboard.&lt;/p&gt;

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

&lt;p&gt;The model: &lt;a href="https://huggingface.co/raihan-js/fedproc-180m-v0" rel="noopener noreferrer"&gt;&lt;code&gt;raihan-js/fedproc-180m-v0&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The dataset: &lt;a href="https://huggingface.co/datasets/raihan-js/fedproc-bench" rel="noopener noreferrer"&gt;&lt;code&gt;raihan-js/fedproc-bench&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Both are Apache 2.0. The source code that built them lives in the repo (link in the model card). To reproduce from scratch you need a SAM.gov developer key (free), an Anthropic key for the synthetic step, and a couple of hours on a GPU you already have.&lt;/p&gt;

&lt;p&gt;If you find a clause number my model misses, an obvious bug, or a hallucination my regex did not catch — open an issue. v0.1 lands tomorrow.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you build in federal contracting tech or care about the reliability of LLMs on regulated text, let me know what you find when you run it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>nlp</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Three small models for healthcare intake — and what shipping all three taught me</title>
      <dc:creator>Raihan</dc:creator>
      <pubDate>Tue, 12 May 2026 01:20:37 +0000</pubDate>
      <link>https://dev.to/raihan-js/three-small-models-for-healthcare-intake-and-what-shipping-all-three-taught-me-71l</link>
      <guid>https://dev.to/raihan-js/three-small-models-for-healthcare-intake-and-what-shipping-all-three-taught-me-71l</guid>
      <description>&lt;p&gt;Two months ago I started a portfolio project: build three small specialized language models for healthcare practice intake, benchmark each one honestly against frontier APIs, and write about what I learned. The goal was to build the case that small specialized models still have a place in the 2026 toolkit alongside frontier LLMs — not as replacements, but as the first stage of a hybrid pipeline.&lt;/p&gt;

&lt;p&gt;This is the post about the third model. It's also the post about the suite — what worked across all three, what didn't, and the pattern that emerged.&lt;/p&gt;

&lt;p&gt;The three models, all on Hugging Face:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://huggingface.co/raihan-js/clarioscope-intent-deberta-v1" rel="noopener noreferrer"&gt;&lt;code&gt;clarioscope-intent-deberta-v1&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — 184M DeBERTa-v3-base, 7-class intent classification. Within 4 pp of Claude Haiku 4.5, 22× faster on CPU. &lt;a href="https://dev.to/ryandevv/matching-frontier-llms-at-22x-lower-latency-a-184m-parameter-intent-classifier-for-healthcare-text-5ec2"&gt;methodology post →&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://huggingface.co/raihan-js/clarioscope-phi-deberta-v1" rel="noopener noreferrer"&gt;&lt;code&gt;clarioscope-phi-deberta-v1&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — 125M RoBERTa-base, 18-category PHI span detection (HIPAA Safe Harbor). Loses on aggregate but &lt;strong&gt;triples frontier F1 on geographic locations&lt;/strong&gt;. &lt;a href="https://dev.to/raihan-js/where-small-models-beat-frontier-llms-and-where-they-dont-a-125m-phi-detector-4edb"&gt;methodology post →&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://huggingface.co/raihan-js/clarioscope-insurance-v1" rel="noopener noreferrer"&gt;&lt;code&gt;clarioscope-insurance-v1&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — 125M RoBERTa-base, 12-field insurance / billing extraction. This post.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What the third model does
&lt;/h2&gt;

&lt;p&gt;A 125M-parameter RoBERTa fine-tune that extracts twelve insurance/billing fields from patient text: carrier name, plan type, member ID, group number, policy number, subscriber name, relationship, claim ID, prior-auth number, copay, deductible, and billed amount. Output is BIO-tagged token spans, which downstream code converts into a JSON object a billing system can ingest directly.&lt;/p&gt;

&lt;p&gt;The benchmark, on a 672-example held-out test set:&lt;/p&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;Macro F1&lt;/th&gt;
&lt;th&gt;Weighted F1&lt;/th&gt;
&lt;th&gt;Latency / example&lt;/th&gt;
&lt;th&gt;Cost / 1K inferences&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;clarioscope-insurance-v1&lt;/code&gt; (CPU)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.7882&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.8202&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;45.4 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-4o-2024-11-20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.9562&lt;/td&gt;
&lt;td&gt;0.9572&lt;/td&gt;
&lt;td&gt;1202 ms&lt;/td&gt;
&lt;td&gt;$1.90&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same speed/cost shape as the other two models in the suite: ~26× faster than GPT-4o, $0 marginal cost. The accuracy gap is concentrated in a small number of low-frequency fields.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fraihan-js%2Fclarioscope-insurance-v1%2Fresolve%2Fmain%2Fper_entity_f1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fraihan-js%2Fclarioscope-insurance-v1%2Fresolve%2Fmain%2Fper_entity_f1.png" alt="Per-entity F1: insurance extractor vs GPT-4o" width="799" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fine-tune is competitive on the high-volume fields.&lt;/strong&gt; &lt;code&gt;CLAIM_ID&lt;/code&gt; (0.95 vs 1.00), &lt;code&gt;MEMBER_ID&lt;/code&gt; (0.91 vs 0.99), &lt;code&gt;CARRIER&lt;/code&gt; (0.91 vs 0.96), &lt;code&gt;SUBSCRIBER_NAME&lt;/code&gt; (0.89 vs 0.91 — essentially tied). These four fields collectively cover ~70% of the test entities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gap is concentrated in a few low-volume fields.&lt;/strong&gt; &lt;code&gt;AUTH_NUMBER&lt;/code&gt; is the standout weakness: 0.30 vs 0.99. The training set has only 770 AUTH_NUMBER spans and the format space is wide (&lt;code&gt;PA-4421&lt;/code&gt;, &lt;code&gt;auth #998-2210&lt;/code&gt;, &lt;code&gt;AUTH998212&lt;/code&gt;, etc.). Same structured-ID problem as the PHI detector had with &lt;code&gt;MRN&lt;/code&gt;. &lt;code&gt;PLAN_TYPE&lt;/code&gt; is similar: short strings like "PPO", "HMO" with overloaded surface forms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three patterns that repeated across all three models
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Synthetic data is fast and noisy, and the noise is systematic
&lt;/h3&gt;

&lt;p&gt;In all three models, &lt;code&gt;gpt-4o-mini&lt;/code&gt; produced label-noise patterns I had to discover and fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Intent classifier (Model 1):&lt;/strong&gt; the LLM over-fitted to "ChatGPT-polite" message style on first attempts. Fixed by adding a mandatory realism mix (40/40/20 polished / casual / messy) to the generation prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHI detector (Model 2):&lt;/strong&gt; the LLM included cue words in entity spans — &lt;code&gt;"MRN 8472301"&lt;/code&gt; annotated as the MRN span instead of &lt;code&gt;"8472301"&lt;/code&gt;. About 8.6% of training spans had this contamination. Fixed by &lt;code&gt;clean_data.py&lt;/code&gt; (cue-word stripping + re-locating spans).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Insurance extractor (Model 3):&lt;/strong&gt; same cue-word noise pattern as PHI — &lt;code&gt;"member ID AET-998-2210"&lt;/code&gt; instead of &lt;code&gt;"AET-998-2210"&lt;/code&gt;, &lt;code&gt;"copay $35"&lt;/code&gt; instead of &lt;code&gt;"$35"&lt;/code&gt;. 7.4% of spans needed cleanup. Same fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lesson: when synthetic data is the input, label QA is part of the pipeline. The LLM that generates the annotations does not produce ground truth, it produces a draft that humans (or scripts) need to validate. The version of &lt;code&gt;clean_data.py&lt;/code&gt; that I shipped for Models 2 and 3 is now part of every future synthetic NER project I'll build.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Cross-generator test sets are not optional — and val numbers lie
&lt;/h3&gt;

&lt;p&gt;In all three models, val macro F1 was 5–17 percentage points higher than test macro F1:&lt;/p&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;Val macro F1&lt;/th&gt;
&lt;th&gt;Test macro F1&lt;/th&gt;
&lt;th&gt;Gap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Intent classifier&lt;/td&gt;
&lt;td&gt;0.886&lt;/td&gt;
&lt;td&gt;0.911&lt;/td&gt;
&lt;td&gt;-0.025 (test actually higher)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHI detector&lt;/td&gt;
&lt;td&gt;0.863&lt;/td&gt;
&lt;td&gt;0.630&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.233&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insurance extractor&lt;/td&gt;
&lt;td&gt;0.957&lt;/td&gt;
&lt;td&gt;0.788&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.169&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The intent classifier was the exception — classification with 7 categories is more robust than span extraction with 12+ categories. For both span-extraction models, the val numbers from a same-generator split would have produced overconfident model cards.&lt;/p&gt;

&lt;p&gt;Lesson: same-generator val splits are useful for early development feedback, but the headline number that goes on a model card should be from a held-out set generated by a different model with a different prompt style. Otherwise the benchmark inflates and you'll be surprised in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Small models beat frontier on linguistic entities, lose on structured-ID memorization
&lt;/h3&gt;

&lt;p&gt;This pattern showed up clearest in the PHI detector and was the central observation in that model's writeup. The insurance extractor repeats it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Linguistic + bounded vocabulary fields&lt;/strong&gt; (CARRIER from a short list of insurance companies, CLAIM_ID with predictable claim patterns, SUBSCRIBER_NAME using ordinary names): fine-tune is competitive or tied with GPT-4o.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured-ID fields with high format variance&lt;/strong&gt; (AUTH_NUMBER, PLAN_TYPE token boundaries, GROUP_NUMBER formats that vary widely): frontier wins because they've seen far more format variance during pretraining.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For both Model 2 and Model 3, the production recommendation is the same: hybrid pipeline. Fine-tuned model first, regex for highly-structured patterns, frontier API as the fallback for the long tail. Most of the cost and latency comes from the fine-tune; the frontier API runs on a small fraction of traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each model cost to build, total
&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;OpenAI (data gen)&lt;/th&gt;
&lt;th&gt;RunPod (train)&lt;/th&gt;
&lt;th&gt;Benchmark APIs&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Intent classifier&lt;/td&gt;
&lt;td&gt;$1.20&lt;/td&gt;
&lt;td&gt;$1.20&lt;/td&gt;
&lt;td&gt;$1.78&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$4.18&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHI detector&lt;/td&gt;
&lt;td&gt;$1.40&lt;/td&gt;
&lt;td&gt;$1.50&lt;/td&gt;
&lt;td&gt;$5.20&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$8.10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insurance extractor&lt;/td&gt;
&lt;td&gt;$1.50&lt;/td&gt;
&lt;td&gt;$0.80&lt;/td&gt;
&lt;td&gt;$1.10 (no Anthropic)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$3.40&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Suite total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$4.10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$3.50&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$8.08&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$15.70&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three published models with benchmark-grade write-ups for under sixteen dollars. The Anthropic credit gap in the insurance extractor benchmark is the only thing that prevents a clean head-to-head across all three, and that's just a "buy more credit" problem.&lt;/p&gt;

&lt;p&gt;Hugging Face hosting: $0. Total infrastructure cost beyond the line items above: $0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;To educate why SLM matters! The pivot story has three components: (1) I can train small transformers from scratch on a single consumer GPU (the smaller &lt;a href="https://huggingface.co/raihan-js" rel="noopener noreferrer"&gt;ORCH models&lt;/a&gt;, on an RTX 3060), (2) I can fine-tune larger base models with QLoRA (&lt;a href="https://huggingface.co/raihan-js/orch-7b" rel="noopener noreferrer"&gt;ORCH-7B&lt;/a&gt;), and (3) I can ship benchmark-grade specialized SLMs with rigorous, transparent evaluation against frontier APIs.&lt;/p&gt;

&lt;p&gt;The ClarioScope SLM Suite is the third leg. Three months of work, three published models, three dev.to write-ups, full transparency on synthetic-data limitations and where frontier still beats us. If you're hiring for AI engineering roles where the candidate needs to understand both training-from-scratch AND production benchmarking AND honest model-limitations communication, my LinkedIn is in my &lt;a href="https://github.com/raihan-js" rel="noopener noreferrer"&gt;GitHub profile&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;A v1.1 of the insurance extractor with Anthropic benchmarks once credit is restored, and a v2 of all three models trained on real (de-identified) patient text from a partner practice — which moves the project into HIPAA-eligible infrastructure (AWS SageMaker / Azure ML with a BAA) and out of the "synthetic-data v1" phase.&lt;/p&gt;

&lt;p&gt;If you've shipped a small specialized model that has the inverse story — beats frontier on aggregate but loses on a specific axis — I'd love to hear about it. The interesting trade-offs in 2026 aren't "should I use a frontier API" but "what's the right hybrid architecture for this task." This three-model suite was the project that taught me that lesson, three times in a row.&lt;/p&gt;

&lt;p&gt;Follow along on &lt;a href="https://huggingface.co/raihan-js" rel="noopener noreferrer"&gt;Hugging Face&lt;/a&gt; or &lt;a href="https://github.com/raihan-js" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>healthcare</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>Where small models beat frontier LLMs (and where they don't): a 125M PHI detector</title>
      <dc:creator>Raihan</dc:creator>
      <pubDate>Tue, 12 May 2026 00:10:50 +0000</pubDate>
      <link>https://dev.to/raihan-js/where-small-models-beat-frontier-llms-and-where-they-dont-a-125m-phi-detector-4edb</link>
      <guid>https://dev.to/raihan-js/where-small-models-beat-frontier-llms-and-where-they-dont-a-125m-phi-detector-4edb</guid>
      <description>&lt;p&gt;Last month I published a 184M-parameter intent classifier that matches frontier LLMs at 22× lower latency. The story was clean: small specialized model, narrow task, comparable accuracy, much faster, almost free per inference. People liked it.&lt;/p&gt;

&lt;p&gt;The second model in the ClarioScope SLM Suite tells a more complicated story. It's a PHI detector — a token classifier that tags spans of protected health information in inbound patient text across all 18 HIPAA Safe Harbor identifier categories. On the macro-F1 headline number, it loses to Claude Sonnet 4.6: &lt;strong&gt;0.63 vs 0.89&lt;/strong&gt;. On Claude Haiku 4.5: 0.63 vs 0.85. On GPT-4o: 0.63 vs 0.81.&lt;/p&gt;

&lt;p&gt;So the click-through headline isn't "matches frontier." It's: &lt;strong&gt;on aggregate, frontier wins&lt;/strong&gt;. But the macro number hides what's actually happening, and the per-entity breakdown reveals something more interesting than either "small model wins" or "small model loses."&lt;/p&gt;

&lt;p&gt;Model on Hugging Face: &lt;a href="https://huggingface.co/raihan-js/clarioscope-phi-deberta-v1" rel="noopener noreferrer"&gt;&lt;code&gt;raihan-js/clarioscope-phi-deberta-v1&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fraihan-js%2Fclarioscope-phi-deberta-v1%2Fresolve%2Fmain%2Fper_entity_f1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fraihan-js%2Fclarioscope-phi-deberta-v1%2Fresolve%2Fmain%2Fper_entity_f1.png" alt="Per-entity F1 across all four models" width="799" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 125M-parameter fine-tune &lt;strong&gt;beats or matches&lt;/strong&gt; every frontier model on linguistic entities — geographic locations, ages, person names, dates, phone numbers, fax numbers, IP addresses. It &lt;strong&gt;loses badly&lt;/strong&gt; on structured-ID entities — MRNs, license numbers, health-plan IDs, device serial numbers. The right production architecture is not one or the other. It's hybrid. This post is the methodology, the benchmark, and the honest interpretation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task: span detection across all 18 HIPAA Safe Harbor categories
&lt;/h2&gt;

&lt;p&gt;Patient text comes in messy — "Hi Dr. Okafor, this is Iniko Adeleke, DOB 11/03/1985, MRN OMK-44291, phone 312.555.7820, my partner's email is &lt;a href="mailto:jordan.holloway@workmail.io"&gt;jordan.holloway@workmail.io&lt;/a&gt;. We live in Brookline." A PHI detector has to locate every individually-identifying span: two names, a date, an MRN, a phone, an email, a location. Six spans, six different entity types, in a single 25-token message.&lt;/p&gt;

&lt;p&gt;The HIPAA Safe Harbor rule defines 18 categories of PHI. The Clarioscope model tags all of them: &lt;code&gt;NAME&lt;/code&gt;, &lt;code&gt;LOC&lt;/code&gt;, &lt;code&gt;DATE&lt;/code&gt;, &lt;code&gt;PHONE&lt;/code&gt;, &lt;code&gt;FAX&lt;/code&gt;, &lt;code&gt;EMAIL&lt;/code&gt;, &lt;code&gt;SSN&lt;/code&gt;, &lt;code&gt;MRN&lt;/code&gt;, &lt;code&gt;HEALTH_PLAN&lt;/code&gt;, &lt;code&gt;ACCOUNT&lt;/code&gt;, &lt;code&gt;LICENSE&lt;/code&gt;, &lt;code&gt;VEHICLE&lt;/code&gt;, &lt;code&gt;DEVICE&lt;/code&gt;, &lt;code&gt;URL&lt;/code&gt;, &lt;code&gt;IP&lt;/code&gt;, &lt;code&gt;BIOMETRIC&lt;/code&gt;, &lt;code&gt;PHOTO_REF&lt;/code&gt;, &lt;code&gt;AGE_OVER_89&lt;/code&gt;. The architecture is standard: RoBERTa-base encoder, a token-classification head outputting BIO tags across 37 labels (one &lt;code&gt;O&lt;/code&gt; plus 18 entity types times &lt;code&gt;{B-, I-}&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;A side note before going further: the repo is named &lt;code&gt;clarioscope-phi-deberta-v1&lt;/code&gt; because the original plan was DeBERTa-v3-base. During training, DeBERTa-v3 reproduced a NaN-gradient bug specific to this 37-label token classification setup — forward pass loss healthy, backward pass NaN on the first step, across fp16, bf16, and fp32, with explicit classifier head re-init and gradient clipping. After three afternoons of trying to keep DeBERTa alive, I switched to RoBERTa-base, which trained stably with the same training script. The repo name is kept for URL stability and the model card calls it out at the top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just call the API
&lt;/h2&gt;

&lt;p&gt;The same three reasons as last time, with slightly different weights:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy.&lt;/strong&gt; PHI is the canonical "you'd want this to never leave your infrastructure" data class. A frontier API with a Business Associate Agreement is one option, but BAAs aren't free, aren't available at every tier, and add legal complexity. A self-hosted model never sends the patient's address or DOB to a third party.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; The fine-tuned model runs in 28.6 ms on a CPU. Frontier API calls from my Bangladesh ISP run 1,000–2,000 ms. For redaction-before-routing where every inbound message has to be processed before it can be displayed in an inbox, that wall-clock floor matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; Claude Sonnet 4.6 with the PHI-extraction prompt costs $2.53 per 1,000 inferences. Haiku is $1.00 per 1K. GPT-4o is $1.64 per 1K. The fine-tuned model is $0 per inference after training. For a practice receiving 10K messages per day, the math is the same as last time: $3,650–$9,234 per year on frontier vs roughly free on the local model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark
&lt;/h2&gt;

&lt;p&gt;The headline numbers, on a 548-example held-out test set, with entity-level F1 measured by &lt;a href="https://github.com/chakki-works/seqeval" rel="noopener noreferrer"&gt;seqeval&lt;/a&gt; (which requires both entity type AND exact span boundary to match for a true positive):&lt;/p&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;Macro F1&lt;/th&gt;
&lt;th&gt;Weighted F1&lt;/th&gt;
&lt;th&gt;Latency / example&lt;/th&gt;
&lt;th&gt;Cost / 1K inferences&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;raihan-js/clarioscope-phi-deberta-v1&lt;/code&gt; (CPU)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.6301&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.7639&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28.6 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-haiku-4-5-20251001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.8492&lt;/td&gt;
&lt;td&gt;0.9213&lt;/td&gt;
&lt;td&gt;1294 ms&lt;/td&gt;
&lt;td&gt;$1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-sonnet-4-6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.8946&lt;/td&gt;
&lt;td&gt;0.9396&lt;/td&gt;
&lt;td&gt;1980 ms&lt;/td&gt;
&lt;td&gt;$2.53&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-4o-2024-11-20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.8094&lt;/td&gt;
&lt;td&gt;0.8912&lt;/td&gt;
&lt;td&gt;1111 ms&lt;/td&gt;
&lt;td&gt;$1.64&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fraihan-js%2Fclarioscope-phi-deberta-v1%2Fresolve%2Fmain%2Ff1_vs_latency.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fraihan-js%2Fclarioscope-phi-deberta-v1%2Fresolve%2Fmain%2Ff1_vs_latency.png" alt="Macro F1 vs latency" width="800" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you stop reading here, the takeaway is: "frontier wins, small model is 45× faster but trails 20–25 points of F1." That's true and it's the honest aggregate. But it's not the interesting part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting part: per-entity F1
&lt;/h2&gt;

&lt;p&gt;Same benchmark, broken out by entity type, sorted by the fine-tuned model's F1 (best to worst):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linguistic entities — small model matches or beats frontier:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entity&lt;/th&gt;
&lt;th&gt;This model&lt;/th&gt;
&lt;th&gt;Haiku&lt;/th&gt;
&lt;th&gt;Sonnet&lt;/th&gt;
&lt;th&gt;GPT-4o&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PHONE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.983&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.994&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AGE_OVER_89&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.976&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.967&lt;/td&gt;
&lt;td&gt;0.967&lt;/td&gt;
&lt;td&gt;0.836&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.961&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.996&lt;/td&gt;
&lt;td&gt;0.994&lt;/td&gt;
&lt;td&gt;0.980&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.949&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.967&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FAX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.949&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;0.984&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.945&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.949&lt;/td&gt;
&lt;td&gt;0.970&lt;/td&gt;
&lt;td&gt;0.909&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.818&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.328&lt;/td&gt;
&lt;td&gt;0.289&lt;/td&gt;
&lt;td&gt;0.301&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;LOC&lt;/code&gt; is the standout. The fine-tuned model nearly &lt;strong&gt;triples&lt;/strong&gt; the frontier APIs' F1 on geographic locations. Frontier models systematically under-flag informal location mentions like "she lives in Allston" or "at the Roxbury location" — their pretraining seems to have left them uncertain about whether informal context cues count as PHI. A specialized model trained explicitly to tag these does not hesitate.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AGE_OVER_89&lt;/code&gt; is another quiet win. Frontier models occasionally tag ages 89-and-under as PHI (they aren't, under Safe Harbor) or miss the "over 89" qualifier ("she's 96") that determines whether the age is reportable. The fine-tuned model learned the rule directly from the training distribution.&lt;/p&gt;

&lt;p&gt;For names, dates, phone numbers, fax numbers, and IPs, the gap between this model and frontier is 1–5 percentage points. Within margin-of-noise for production use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured-ID entities — frontier wins, often dominantly:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entity&lt;/th&gt;
&lt;th&gt;This model&lt;/th&gt;
&lt;th&gt;Haiku&lt;/th&gt;
&lt;th&gt;Sonnet&lt;/th&gt;
&lt;th&gt;GPT-4o&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MRN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.276&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.997&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LICENSE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.170&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.933&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HEALTH_PLAN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.264&lt;/td&gt;
&lt;td&gt;0.855&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.983&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.717&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BIOMETRIC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.095&lt;/td&gt;
&lt;td&gt;0.410&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.314&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEVICE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.341&lt;/td&gt;
&lt;td&gt;0.732&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VEHICLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.640&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.970&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SSN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.583&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.983&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.949&lt;/td&gt;
&lt;td&gt;0.915&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ACCOUNT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.759&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.985&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.969&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EMAIL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.815&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.738&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.967&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.967&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.931&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Frontier wins these by enormous margins. The fine-tuned model gets &lt;code&gt;MRN&lt;/code&gt; right 28% of the time. Haiku and Sonnet get it right 100% of the time.&lt;/p&gt;

&lt;p&gt;The reason is straightforward once you stare at the data. Structured-ID entities follow surface conventions that &lt;strong&gt;vary wildly between institutions and generators&lt;/strong&gt;. An MRN might look like &lt;code&gt;OMK-44291&lt;/code&gt;, &lt;code&gt;RMR-882034&lt;/code&gt;, &lt;code&gt;DENT-12345-A&lt;/code&gt;, or just &lt;code&gt;8472301&lt;/code&gt;. The training set generates one distribution of ID formats; the test set was generated by a different model and uses a different distribution. The fine-tuned model can only recognize what it saw during training, and when the test-set MRN doesn't match the training conventions, the model either misses it entirely or produces a span boundary that's off by one token (which under seqeval's strict matching is a miss).&lt;/p&gt;

&lt;p&gt;Frontier models win these categories because they've seen a much wider distribution of ID formats during pretraining, and because their attention mechanism is strong enough to anchor an ID span to its context cue — "MRN" or "member ID" or "license #" — regardless of the specific token pattern that follows it.&lt;/p&gt;

&lt;p&gt;This is a real limitation. It's also a reasonable one to live with, because there's a much cheaper way to catch structured-ID PHI than running a frontier API on every message.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that wasn't (and the bug that was)
&lt;/h2&gt;

&lt;p&gt;A first version of the model trained on the raw generated annotations and scored 0.57 macro F1 on test — worse than what's shipping now. The expected explanation was distribution shift between train and test. The actual explanation was simpler and more embarrassing.&lt;/p&gt;

&lt;p&gt;The training data had &lt;strong&gt;systematic label noise&lt;/strong&gt;: the data-generation LLM was returning entity texts that included the cue word that introduced the entity. The annotation for "MRN 8472301" came back as &lt;code&gt;{"text": "MRN 8472301", "label": "MRN"}&lt;/code&gt; instead of &lt;code&gt;{"text": "8472301"}&lt;/code&gt;. The literal word "SSN" was annotated as an SSN entity in six different training examples. About 8.6% of all training spans (1,676 of them) had this kind of cue-word contamination. The Claude-generated test set, with a stricter prompt, had two such cases out of 1,632 spans.&lt;/p&gt;

&lt;p&gt;So the model wasn't being graded against the same distribution it learned. It learned "MRN" was part of an MRN span; the test set told it it wasn't.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;clean_data.py&lt;/code&gt; script in the repo strips known cue-word prefixes ("MRN ", "SSN ", "phone ", "account #") from entity texts, re-locates the cleaned text in the inquiry, and drops entities that no longer have a valid span. Importantly, it preserves natural prefix characters like the opening &lt;code&gt;(&lt;/code&gt; in &lt;code&gt;(617) 555-1234&lt;/code&gt; — a first version of the cleanup stripped parens too aggressively and tanked PHONE F1 from 0.94 to 0.51 in one run. The fix was to apply punctuation stripping only after a cue word had been detected and removed, not as a generic "trim leading punctuation" pass.&lt;/p&gt;

&lt;p&gt;The cleanup recovered about 4 percentage points of test macro F1 and (more interestingly) flipped two entity types from "loses badly" to "competitive": EMAIL went from 0.55 to 0.82, ACCOUNT from 0.21 to 0.76.&lt;/p&gt;

&lt;p&gt;The deeper lesson is one that's well-known but easy to forget: &lt;strong&gt;synthetic data is fast and cheap and the cost shows up as systematic noise that nobody else will catch for you&lt;/strong&gt;. The annotations themselves need a QA pass before training. Real-world data has its own noise problems, but it tends not to label cue words as entities, because human annotators don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preventing benchmark leakage
&lt;/h2&gt;

&lt;p&gt;Same trick as model 1: training set was generated by &lt;code&gt;gpt-4o-mini-2024-07-18&lt;/code&gt;; the held-out test set was generated by Claude with a deliberately different prompt style. This cross-generator split mitigates the failure mode where a fine-tuned model just learns one generator's style and the benchmark inflates.&lt;/p&gt;

&lt;p&gt;A side effect on this model specifically: the Claude-generated test set uses tighter, more uniform structured-ID formats than the training set. That's part of why the test F1 on structured-ID entities is harsher than the val F1 (val: 0.86 macro; test: 0.63 macro). It's also fair, because real-world MRN formats are at least as varied as the gap between the two generators — and probably more so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recommended production architecture: hybrid
&lt;/h2&gt;

&lt;p&gt;Given the per-entity breakdown, the right architecture is not "use this model alone" or "use a frontier API alone." It's a three-stage pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run this model first&lt;/strong&gt; on every inbound message. ~30 ms on a CPU, $0, never sends text off-host. Captures &lt;code&gt;NAME&lt;/code&gt;, &lt;code&gt;LOC&lt;/code&gt;, &lt;code&gt;DATE&lt;/code&gt;, &lt;code&gt;PHONE&lt;/code&gt;, &lt;code&gt;FAX&lt;/code&gt;, &lt;code&gt;IP&lt;/code&gt;, &lt;code&gt;AGE_OVER_89&lt;/code&gt; reliably — that's most of the volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add regex matchers&lt;/strong&gt; for highly structured patterns the model misses: SSN (&lt;code&gt;\d{3}-\d{2}-\d{4}&lt;/code&gt;), credit-card numbers, basic MRN/account patterns specific to your practice's conventions. Regex is fast, free, and brittle — but correct when it matches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fall back to a frontier API only when&lt;/strong&gt; the message contains likely structured-ID content the local pipeline didn't resolve, or when downstream confidence is needed. This pays the latency and dollar cost only on a small fraction of traffic.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a practice receiving 10,000 messages per day where roughly 10% have unresolved structured-ID content after stages 1 and 2, the hybrid sends 1,000 calls per day to Haiku ($0.30/day) instead of 10,000 ($3.00/day). Most messages never leave the host. Latency is bounded by the local model. The frontier model becomes a "structured-ID specialist" rather than a "PHI redaction generalist."&lt;/p&gt;

&lt;p&gt;This is the actual cost-effective answer in 2026. The small model is not a frontier replacement; it's a frontier accelerator.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost ledger
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&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;9,500 synthetic training examples via OpenAI (&lt;code&gt;gpt-4o-mini&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;~$1.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RunPod RTX A4000 pod (two training runs, ~25 min total)&lt;/td&gt;
&lt;td&gt;~$1.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Benchmark API calls (Haiku + Sonnet + GPT-4o on 548 examples × 2 runs)&lt;/td&gt;
&lt;td&gt;~$5.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hugging Face hosting&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$8.10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A bit more expensive than model 1 because the benchmark ran twice — once before the data cleanup, once after. The cleanup story was a four-percentage-point F1 improvement, which seems small but matters for the "where does this model lose" interpretation of the per-entity numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AutoModelForTokenClassification&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;

&lt;span class="n"&gt;model_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raihan-js/clarioscope-phi-deberta-v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;tokenizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoModelForTokenClassification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hi Dr. Okafor, this is Iniko Adeleke, DOB 11/03/1985. phone 312.555.7820, email jordan@workmail.io. I live in Brookline.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_offsets_mapping&lt;/span&gt;&lt;span class="o"&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;return_tensors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;truncation&lt;/span&gt;&lt;span class="o"&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;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;offsets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offset_mapping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;no_grad&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;pred_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;id2label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id2label&lt;/span&gt;
&lt;span class="n"&gt;spans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pred_ids&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;id2label&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pred_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;ent_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;offsets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;offsets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pred_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;id2label&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pred_ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ent_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;offsets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ent_type&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;spans&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# {'text': 'Dr. Okafor', 'label': 'NAME'}
# {'text': 'Iniko Adeleke', 'label': 'NAME'}
# {'text': '11/03/1985', 'label': 'DATE'}
# {'text': '312.555.7820', 'label': 'PHONE'}
# {'text': 'jordan@workmail.io', 'label': 'EMAIL'}
# {'text': 'Brookline', 'label': 'LOC'}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;The model card has the full list. The ones worth surfacing here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;All training and evaluation data is synthetic.&lt;/strong&gt; No real production validation yet. A real-world calibration pass is required before deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured-ID entities are weak.&lt;/strong&gt; Per the benchmark, this model is materially worse than frontier APIs on &lt;code&gt;MRN&lt;/code&gt;, &lt;code&gt;LICENSE&lt;/code&gt;, &lt;code&gt;HEALTH_PLAN&lt;/code&gt;, &lt;code&gt;BIOMETRIC&lt;/code&gt;, and several others. Pair with regex and/or a frontier fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a HIPAA compliance verdict.&lt;/strong&gt; This model tags entity types as defined in the Safe Harbor rule. HIPAA compliance is a regulatory determination that a model can't make on its own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;English only, healthcare practice domain only.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;This is model 2 of three. The third is &lt;code&gt;clarioscope-insurance-v1&lt;/code&gt; — structured JSON extraction of insurance- and billing-relevant fields from inbound text. Probably a small encoder-decoder with constrained decoding. When all three are published, they'll go up as a Hugging Face collection with a single longer post tying the suite together.&lt;/p&gt;

&lt;p&gt;The honest takeaway from this one: small specialized models don't always beat frontier on aggregate, but the per-entity breakdown is where the actual production decision lives. Frontier-or-nothing is the wrong frame. Frontier-as-fallback-to-a-cheap-local-model is the right one.&lt;/p&gt;

&lt;p&gt;Follow along on &lt;a href="https://huggingface.co/raihan-js" rel="noopener noreferrer"&gt;Hugging Face&lt;/a&gt; or &lt;a href="https://github.com/raihan-js" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>healthcare</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>Matching frontier LLMs at 22 lower latency: a 184M-parameter intent classifier for healthcare text</title>
      <dc:creator>Raihan</dc:creator>
      <pubDate>Mon, 11 May 2026 17:43:38 +0000</pubDate>
      <link>https://dev.to/raihan-js/matching-frontier-llms-at-22x-lower-latency-a-184m-parameter-intent-classifier-for-healthcare-text-5ec2</link>
      <guid>https://dev.to/raihan-js/matching-frontier-llms-at-22x-lower-latency-a-184m-parameter-intent-classifier-for-healthcare-text-5ec2</guid>
      <description>&lt;p&gt;Healthcare practices drown in inbound patient text. Email, contact forms, live chat, SMS, voicemail transcripts — every channel sends messages that need to be routed: to scheduling, to billing, to clinical, to the front desk. It's a high-volume, deterministic, latency-sensitive task.&lt;/p&gt;

&lt;p&gt;The obvious answer in 2026 is to throw a frontier LLM at it. Claude Haiku 4.5 will give you 95% accuracy on this kind of classification. GPT-4o will too. But every call costs real money, adds about a second of network round-trip, and sends patient text to a third party that doesn't have a BAA with you.&lt;/p&gt;

&lt;p&gt;I built a small alternative — a 184M-parameter DeBERTa-v3-base fine-tune — and benchmarked it against Claude Haiku 4.5, Claude Sonnet 4.6, and GPT-4o on a 1,154-example test set. &lt;strong&gt;The fine-tuned model lands within 4 percentage points of accuracy&lt;/strong&gt; of the best frontier model, &lt;strong&gt;runs 22× faster&lt;/strong&gt; on a CPU, and costs &lt;strong&gt;effectively $0 per inference&lt;/strong&gt; after training. Total cost to build it: under $3.&lt;/p&gt;

&lt;p&gt;Model on Hugging Face: &lt;a href="https://huggingface.co/raihan-js/clarioscope-intent-deberta-v1" rel="noopener noreferrer"&gt;&lt;code&gt;raihan-js/clarioscope-intent-deberta-v1&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fraihan-js%2Fclarioscope-intent-deberta-v1%2Fresolve%2Fmain%2Faccuracy_vs_latency.png%3Fv%3D2" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fhuggingface.co%2Fraihan-js%2Fclarioscope-intent-deberta-v1%2Fresolve%2Fmain%2Faccuracy_vs_latency.png%3Fv%3D2" alt="Accuracy vs latency" width="1691" height="967"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is model 1 of three I'm building for the ClarioScope SLM Suite — a healthcare intake intelligence pipeline. The other two are a PHI detector and an insurance extractor; they're in development. This post is the methodology and the benchmark for the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task
&lt;/h2&gt;

&lt;p&gt;Seven intent labels, designed for production routing at a healthcare practice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Label&lt;/th&gt;
&lt;th&gt;What it captures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;new_patient_inquiry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A prospective patient asking about becoming a patient&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;existing_patient_question&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An existing patient with a non-urgent question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;appointment_request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Scheduling, rescheduling, or cancellation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;billing_inquiry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Questions about bills or pricing of services already received&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;clinical_concern&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An active medical concern requiring clinical attention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;complaint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dissatisfaction with service, staff, communication, or outcome&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;price_shopper&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pricing-only inquiry, no commitment signals&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These categories are opinionated and they have real ambiguity at the edges. A new patient asking for their first appointment is both new-patient and appointment-request. A frustrated patient describing a medical concern is both clinical and complaint. The data-generation prompt encodes explicit disambiguation rules (complaint dominates when both signals are present; pre-commitment pricing questions are &lt;code&gt;price_shopper&lt;/code&gt; even if they mention insurance), but the boundary cases are where every model — fine-tuned or frontier — gives up F1 points.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use the API
&lt;/h2&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; Frontier API calls from my Bangladesh ISP run 1,000–1,600 ms. For routing, that's the difference between an inbox that updates instantly and one that lags noticeably. The fine-tuned model on a CPU runs in 48 ms. On a GPU it would be another 5–10× faster. Either way, the wall-clock floor for a hosted API call is in the hundreds of milliseconds even before the model processes anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; Claude Sonnet 4.6 costs $0.76 per 1,000 inferences on this task. Haiku is $0.25 per 1K. GPT-4o is $0.53 per 1K. For a single practice receiving 10,000 inbound messages per day across all channels (not unrealistic for a multi-location dental or dermatology group), that's $912 to $2,774 per practice per year — a hard line item on the SaaS economics. The fine-tuned model has a one-time training cost and approximately zero marginal per-inference cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy.&lt;/strong&gt; Frontier APIs are great, and they're also a third-party data path. For protected health information you'd want a BAA, and not every API provider offers one at every tier. A self-hosted classifier never sends patient text anywhere.&lt;/p&gt;

&lt;p&gt;The accuracy gap versus frontier is real but small enough that for production routing, the speed/cost/privacy wins dominate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model
&lt;/h2&gt;

&lt;p&gt;Standard &lt;a href="https://huggingface.co/microsoft/deberta-v3-base" rel="noopener noreferrer"&gt;DeBERTa-v3-base&lt;/a&gt; with a sequence classification head: a single linear layer over the pooled &lt;code&gt;[CLS]&lt;/code&gt; representation producing 7 logits. All 184M parameters fine-tuned. No LoRA — at this dataset size, full fine-tuning beats parameter-efficient methods without much overhead. Training was 5 epochs of 8,099 examples on a single RTX 4090 (rented on RunPod), batch size 32, max sequence length 256 tokens, learning rate 2e-5 with cosine schedule and 10% warmup, fp16 mixed precision. Total training wall time: about five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The training data — synthetic and transparent about it
&lt;/h2&gt;

&lt;p&gt;This is the most important section of the post for anyone considering similar work. &lt;strong&gt;All training and test data is synthetic.&lt;/strong&gt; There is no real patient data anywhere in the pipeline. This is a deliberate choice — using synthetic data for v1 sidesteps HIPAA constraints entirely and lets the model ship fast. A v2 trained on real PHI would need HIPAA-eligible training infrastructure (AWS SageMaker or Azure ML with a BAA), and that's a separate, more careful project.&lt;/p&gt;

&lt;p&gt;But "synthetic" is doing a lot of work in that sentence. The naïve approach — ask an LLM for 1,000 example patient inquiries per intent — produces what I'll call &lt;strong&gt;ChatGPT-polite text&lt;/strong&gt;: every message opens with "Hi!", ends with "Thanks!", uses correct grammar and punctuation, and reads nothing like a real SMS message that an actual frustrated parent sends at 2 AM.&lt;/p&gt;

&lt;p&gt;A model trained on ChatGPT-polite text will overfit to the politeness markers and degrade badly on real production text. So the generation prompt forces a &lt;strong&gt;mandatory realism mix per batch&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~40% polished&lt;/strong&gt; (full sentences, correct grammar, proper punctuation, formal or neutral)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~40% casual&lt;/strong&gt; (lowercase starts, contractions, fragments, missing terminal punctuation, conversational)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~20% messy&lt;/strong&gt; (typos, autocorrect mistakes, abbreviations like &lt;code&gt;u&lt;/code&gt;/&lt;code&gt;appt&lt;/code&gt;/&lt;code&gt;tmrw&lt;/code&gt;, ALL CAPS for urgency, run-on phrasing)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus channel-conditional scaling: SMS is the messiest, voicemail transcripts second messiest, email and web forms more polished. The prompt also includes about 20 lines of &lt;strong&gt;style anchors&lt;/strong&gt; — concrete patterns the LLM should reproduce. Stuff like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Abbreviations: &lt;code&gt;u&lt;/code&gt;/&lt;code&gt;ur&lt;/code&gt;, &lt;code&gt;appt&lt;/code&gt;, &lt;code&gt;tmrw&lt;/code&gt;, &lt;code&gt;yr&lt;/code&gt;, &lt;code&gt;pls&lt;/code&gt;, &lt;code&gt;thx&lt;/code&gt;, &lt;code&gt;rx&lt;/code&gt;, &lt;code&gt;ins&lt;/code&gt; (insurance)&lt;/p&gt;

&lt;p&gt;Fragment phrases: "billing question call me back", "need to reschedule thursday", "kid has fever 102", "still no answer about my x-ray"&lt;/p&gt;

&lt;p&gt;Run-on voicemail: "uh hi yeah this is calling about that thing you mentioned last week i think it was a follow up or something can you call me back"&lt;/p&gt;

&lt;p&gt;Conversational starts (no greeting): "Quick question —", "So I got this bill...", "Need to cancel —"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two dry runs before the full 9,000-example generation: the first one without the realism mix produced very polite, very clean output (82% of messages opened with "Hi!", 0% had ALL CAPS, almost nothing was a fragment); the second one with the mix landed at 18% greetingless openers, 22% abbreviations, 21% no terminal punctuation, 6% ALL CAPS urgency. The shape of the distribution actually moved when the prompt told it to move.&lt;/p&gt;

&lt;p&gt;Costs: the 9,000 training examples cost about $1.20 of OpenAI credit (via &lt;code&gt;gpt-4o-mini-2024-07-18&lt;/code&gt;, JSON-object response format, temperature 1.0, 8-worker parallel generation).&lt;/p&gt;

&lt;h2&gt;
  
  
  Preventing benchmark leakage
&lt;/h2&gt;

&lt;p&gt;The naive failure mode here is generating both train and test with the same model. The fine-tuned model would learn the generator's style, and the benchmark would inflate.&lt;/p&gt;

&lt;p&gt;So train and test come from different generators:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Train (9,000 examples)&lt;/strong&gt; — generated by &lt;code&gt;gpt-4o-mini-2024-07-18&lt;/code&gt; with the prompt above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test (1,154 examples)&lt;/strong&gt; — generated by Claude with a &lt;strong&gt;deliberately different prompt style&lt;/strong&gt; and a &lt;strong&gt;different abbreviation set&lt;/strong&gt; (&lt;code&gt;w/&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;hrs&lt;/code&gt;, &lt;code&gt;BTW&lt;/code&gt;, &lt;code&gt;IDK&lt;/code&gt;, &lt;code&gt;plz&lt;/code&gt; versus the train prompt's &lt;code&gt;u&lt;/code&gt;, &lt;code&gt;tmrw&lt;/code&gt;, &lt;code&gt;appt&lt;/code&gt;). The test set leans into more medically specific content (real conditions, real procedure names) and longer rambling messages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A side effect of this split: when I benchmark against Claude Haiku 4.5 and Claude Sonnet 4.6 below, those models are from the same family as the test-set generator. If anything, they should get a small style-familiarity advantage. The benchmark numbers below are with that caveat in mind. (Spoiler: they don't visibly benefit.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark
&lt;/h2&gt;

&lt;p&gt;Evaluated on 1,154 held-out test examples:&lt;/p&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;Accuracy&lt;/th&gt;
&lt;th&gt;Macro F1&lt;/th&gt;
&lt;th&gt;Latency / example&lt;/th&gt;
&lt;th&gt;Cost / 1K inferences&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;raihan-js/clarioscope-intent-deberta-v1&lt;/code&gt; (CPU)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;91.16%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;91.07%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48.5 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-haiku-4-5-20251001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;95.32%&lt;/td&gt;
&lt;td&gt;95.28%&lt;/td&gt;
&lt;td&gt;1064 ms&lt;/td&gt;
&lt;td&gt;$0.252&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-sonnet-4-6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;93.59%&lt;/td&gt;
&lt;td&gt;93.53%&lt;/td&gt;
&lt;td&gt;1566 ms&lt;/td&gt;
&lt;td&gt;$0.759&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-4o-2024-11-20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;95.23%&lt;/td&gt;
&lt;td&gt;95.17%&lt;/td&gt;
&lt;td&gt;1036 ms&lt;/td&gt;
&lt;td&gt;$0.527&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Latency is wall-clock single-example latency through each provider's chat completions API, measured from a Bangladesh ISP. The fine-tuned model number is on a CPU (no GPU acceleration). Cost is the actual API spend per 1,000 calls based on token counts from the run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqp4f5dg6rjgb3hv02jo2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqp4f5dg6rjgb3hv02jo2.png" alt="Accuracy and cost comparison" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things in this table are interesting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Sonnet 4.6 is worse than Haiku 4.5.&lt;/strong&gt; A bigger, slower, more expensive frontier model produces lower accuracy on this task. This isn't an artifact of one run — I've seen it consistently. My take: for narrow, well-structured classification with short prompts, more reasoning capacity sometimes second-guesses the correct intuition. The first thought is often right, and a smaller model that doesn't have the option to deliberate just commits to it. The right tool for this kind of job is small and specific.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The latency advantage is on CPU.&lt;/strong&gt; The 48ms number is on CPU. A modest GPU would drop it to ~5–10 ms. The frontier API numbers are network-bound — the model itself processes the request in tens of milliseconds, but the wall-clock floor for a hosted API call from a non-US-East ISP is in the hundreds of milliseconds before the model has even started. Adding a GPU at the API side does nothing for that floor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost gap doesn't shrink at scale.&lt;/strong&gt; API cost scales linearly with call volume. The fine-tuned model has a one-time training cost (about $2.40 of OpenAI plus RunPod compute together) and approximately zero marginal cost. For 10K daily inferences over a year, the dollar swing is between zero and roughly $2,800.&lt;/p&gt;

&lt;h2&gt;
  
  
  Per-class F1 and where the errors live
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg3kfcrkif2emfrtqppag.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg3kfcrkif2emfrtqppag.png" alt="Confusion matrix on val set" width="800" height="642"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The model's per-class F1 on the val set, ranked best to worst:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Intent&lt;/th&gt;
&lt;th&gt;F1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;price_shopper&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.957&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;complaint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.929&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;billing_inquiry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.908&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;appointment_request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.881&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;clinical_concern&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.874&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;existing_patient_question&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.834&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;new_patient_inquiry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.819&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The hardest pairs to disambiguate are exactly the pairs you'd expect to be hard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;new_patient_inquiry&lt;/code&gt; ↔ &lt;code&gt;appointment_request&lt;/code&gt;&lt;/strong&gt; — a new patient asking to schedule their first visit fits both labels. The data-gen prompt resolves toward &lt;code&gt;new_patient_inquiry&lt;/code&gt; for messages that lead with the becoming-a-patient signal, but the model lands on &lt;code&gt;appointment_request&lt;/code&gt; more often than the label intends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;existing_patient_question&lt;/code&gt; ↔ &lt;code&gt;clinical_concern&lt;/code&gt;&lt;/strong&gt; — medical questions from established patients read as low-grade concerns to the model, because at the lexical level they are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;clinical_concern&lt;/code&gt; ↔ &lt;code&gt;complaint&lt;/code&gt;&lt;/strong&gt; — frustrated medical concerns combine both signals; the prompt's tie-breaker says complaint dominates, but the model occasionally goes the other way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These same pairs gave Claude Haiku 4.5 trouble too when I ran the benchmark by hand on a sample. They're real ambiguity in the task, not classifier weakness. Useful production move: have the model emit confidence (max softmax) alongside the label, and route low-confidence predictions to a human reviewer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost ledger
&lt;/h2&gt;

&lt;p&gt;Full breakdown of what it cost to ship this model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&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;9,000 synthetic training examples via OpenAI (&lt;code&gt;gpt-4o-mini&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;$1.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RunPod RTX 4090 pod (about 50 minutes including iteration)&lt;/td&gt;
&lt;td&gt;$1.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Benchmark API calls (Haiku + Sonnet + GPT-4o, 1,154 examples each)&lt;/td&gt;
&lt;td&gt;$1.78&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hugging Face hosting&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$4.18&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's it. End-to-end, from empty repo to published model + reproducible benchmark, for less than the price of lunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AutoModelForSequenceClassification&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;

&lt;span class="n"&gt;model_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raihan-js/clarioscope-intent-deberta-v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;tokenizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoModelForSequenceClassification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;texts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hi, I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;m new to the area and looking for a dermatologist. Are you accepting new patients?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;got a bill for $382 for my visit on 4/12 but my copay should only be $35 — what&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s the rest?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my kid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s fever is 103.2 and not coming down with tylenol. need advice now&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;inputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;padding&lt;/span&gt;&lt;span class="o"&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;truncation&lt;/span&gt;&lt;span class="o"&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;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_tensors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;no_grad&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;
&lt;span class="n"&gt;labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id2label&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ['new_patient_inquiry', 'billing_inquiry', 'clinical_concern']
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;I've put a full Limitations section in the &lt;a href="https://huggingface.co/raihan-js/clarioscope-intent-deberta-v1" rel="noopener noreferrer"&gt;model card&lt;/a&gt;, but the highlights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;All training and test data is synthetic.&lt;/strong&gt; No real production validation yet. A real-world calibration pass is a prerequisite for production deployment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;English only.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Healthcare practice domain only.&lt;/strong&gt; Routes messages within a practice — does not generalize to other industries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seven categories, not exhaustive.&lt;/strong&gt; Messages that don't fit get the closest available label rather than an "unknown" bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No PHI redaction is performed by this model.&lt;/strong&gt; PHI detection is a separate model in the suite (in development), and HIPAA compliance is a regulatory determination that no model can make.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;This is model 1 of three. The other two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;clarioscope-phi-deberta-v1&lt;/code&gt;&lt;/strong&gt; — a token-classification model (BIO tagging) for detecting PHI spans in patient text. Same DeBERTa base, different head, different training data (synthetic PHI-annotated text). Goal: redact before routing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;clarioscope-insurance-v1&lt;/code&gt;&lt;/strong&gt; — structured JSON extraction of insurance- and billing-relevant fields from inbound text. Probably a small encoder-decoder or constrained-decoding setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When all three are published, they'll go up as a Hugging Face collection and the master writeup will be a single longer post tying the suite together. Follow along on &lt;a href="https://huggingface.co/raihan-js" rel="noopener noreferrer"&gt;Hugging Face&lt;/a&gt; or &lt;a href="https://github.com/raihan-js" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you've shipped a small specialized model that beats — sorry, &lt;strong&gt;matches&lt;/strong&gt; — frontier APIs on a narrow task, I'd love to hear about it. The pattern works.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>huggingface</category>
    </item>
  </channel>
</rss>
