<?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: vadim albarov</title>
    <description>The latest articles on DEV Community by vadim albarov (@vadim_albarov).</description>
    <link>https://dev.to/vadim_albarov</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%2F4058639%2Fae7afcec-637e-4f73-bea2-21b8cd0d9c4f.jpg</url>
      <title>DEV Community: vadim albarov</title>
      <link>https://dev.to/vadim_albarov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vadim_albarov"/>
    <language>en</language>
    <item>
      <title>A 125M model beat a 14B LLM at de-identifying medical text 40 faster, on CPU</title>
      <dc:creator>vadim albarov</dc:creator>
      <pubDate>Sun, 02 Aug 2026 04:13:27 +0000</pubDate>
      <link>https://dev.to/vadim_albarov/a-125m-model-beat-a-14b-llm-at-de-identifying-medical-text-40x-faster-on-cpu-201a</link>
      <guid>https://dev.to/vadim_albarov/a-125m-model-beat-a-14b-llm-at-de-identifying-medical-text-40x-faster-on-cpu-201a</guid>
      <description>&lt;h2&gt;
  
  
  Your data never leaves the machine - and you can check my math
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Building localscrub, a local-first PHI de-identification cascade, and&lt;br&gt;
benchmarking it honestly against the standard baseline - on one consumer&lt;br&gt;
laptop, with zero real patient data.&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;De-identifying clinical text today forces a bad trade. Cloud de-id APIs are&lt;br&gt;
accurate, but you send the sensitive data out in order to scrub it - the&lt;br&gt;
text crosses your trust boundary before a single character is redacted.&lt;br&gt;
Local rule-based tools keep the data home, but miss exactly the PHI that&lt;br&gt;
matters most: the context-dependent kind. A regex will catch an SSN every&lt;br&gt;
time; it will never catch "the patient's sister works at the bakery on Elm&lt;br&gt;
Street."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/valbarov/localscrub" rel="noopener noreferrer"&gt;localscrub&lt;/a&gt; is my attempt to&lt;br&gt;
refuse the trade. It runs a two-stage cascade entirely on your hardware: a&lt;br&gt;
fast rules-and-NER pass for the well-formatted identifiers - phones, emails,&lt;br&gt;
dates, account numbers - and a local LLM, served by Ollama with no network&lt;br&gt;
egress, for the ambiguous remainder. (How much each stage carries is an&lt;br&gt;
empirical question; the benchmark below answers it rather than assuming.)&lt;br&gt;
It's on PyPI as v0.1 (&lt;code&gt;pip install localscrub&lt;/code&gt;), and every number in this&lt;br&gt;
article reproduces from seeds on a single RTX 5080 laptop.&lt;/p&gt;

&lt;p&gt;This is the story of building it - and more importantly, of &lt;em&gt;measuring&lt;/em&gt; it,&lt;br&gt;
because a privacy tool with unverifiable accuracy claims is just a liability&lt;br&gt;
with a nice README. Along the way: a test set that is a function rather than&lt;br&gt;
a file, a 125-million-parameter model that beat a 14-billion-parameter one,&lt;br&gt;
an eval harness that indicted its own gold standard, and one cursed note&lt;br&gt;
that killed a three-hour benchmark at 99% complete.&lt;/p&gt;
&lt;h2&gt;
  
  
  The ground truth problem: a test set that is a function, not a file
&lt;/h2&gt;

&lt;p&gt;You cannot measure a de-identifier without ground truth, and I refused to&lt;br&gt;
use real PHI to get it. The gold-standard clinical de-id corpus (i2b2/n2c2&lt;br&gt;
2014) sits behind a data use agreement, and scraping a third-party re-upload&lt;br&gt;
would make a privacy project sloppy about data provenance on day one.&lt;/p&gt;

&lt;p&gt;So the corpus is generated. &lt;code&gt;localscrub synth&lt;/code&gt; renders synthetic clinical&lt;br&gt;
notes from templates - seven variants across six note types - with&lt;br&gt;
fabricated identifiers planted at recorded character offsets. Every phone&lt;br&gt;
number is from the reserved 555-01XX block, every domain from RFC 2606,&lt;br&gt;
every IP from RFC 5737, every credit card Luhn-valid on a test prefix. The&lt;br&gt;
output is JSONL with exact gold spans, deterministic from a seed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;localscrub synth &lt;span class="nt"&gt;-n&lt;/span&gt; 500 &lt;span class="nt"&gt;--seed&lt;/span&gt; 42 &lt;span class="nt"&gt;-o&lt;/span&gt; eval.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That determinism buys something subtle: an eval number becomes a property&lt;br&gt;
of the &lt;em&gt;code&lt;/em&gt;, not of a dataset file. Corpora are gitignored and&lt;br&gt;
regenerated at will. The test set is a function, not a file.&lt;/p&gt;

&lt;p&gt;Template-generated text invites an obvious objection: a detector could&lt;br&gt;
memorize the templates. The answer is &lt;code&gt;--diversify&lt;/code&gt;, which lets a local LLM&lt;br&gt;
paraphrase the connective prose &lt;em&gt;without ever seeing an identifier&lt;/em&gt;: every&lt;br&gt;
gold span is masked behind a sentinel token (&lt;code&gt;[[E3]]&lt;/code&gt;), the model rewrites&lt;br&gt;
around the sentinels, values are re-substituted, offsets recomputed. A&lt;br&gt;
rewrite is rejected if any sentinel is dropped or duplicated - or if&lt;br&gt;
re-running stage 1 on the rebuilt text finds identifier-shaped strings&lt;br&gt;
outside the gold spans, i.e. the model &lt;em&gt;invented&lt;/em&gt; PHI. The validation loop&lt;br&gt;
cost about ten lines and closes the biggest ground-truth-corruption risk.&lt;br&gt;
That is how you let an LLM touch your test set without trusting it.&lt;/p&gt;
&lt;h2&gt;
  
  
  An eval whose first job is to embarrass you precisely
&lt;/h2&gt;

&lt;p&gt;The harness (&lt;code&gt;localscrub eval&lt;/code&gt;) reports three numbers per entity type, and&lt;br&gt;
keeping them separate turned out to matter more than any single one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;relaxed&lt;/strong&gt; (type + character overlap): &lt;em&gt;did you find it?&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;strict&lt;/strong&gt; (type + exact boundaries): &lt;em&gt;are your boundaries calibrated?&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redaction recall&lt;/strong&gt; - the fraction of gold spans whose every character
is removed from the output, by any means, under any label: &lt;em&gt;is the output
actually safe?&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Redaction recall is the safety metric, and it is deliberately unforgiving:&lt;br&gt;
a detection that leaves half an address in the text does not count. Partial&lt;br&gt;
redaction of an address is still a leak.&lt;/p&gt;

&lt;p&gt;The three-metric split earned its keep on the very first run. Stage 1's URL&lt;br&gt;
recognizer scored relaxed 1.00 and strict &lt;strong&gt;0.00&lt;/strong&gt; - it was swallowing&lt;br&gt;
sentence-final periods on every single URL. Overlap-only scoring would never&lt;br&gt;
have surfaced it. The same first run put honest zeros on the board: NAME&lt;br&gt;
0.00, GEO 0.00, because stage 1 has no name recognizer by design. Overall&lt;br&gt;
redaction recall: 0.62. That 0.62 turned "stage 2 is on the roadmap" into a&lt;br&gt;
quantified gap - 38% of gold spans unprotected without it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The cascade, and the one design decision that carried the project
&lt;/h2&gt;

&lt;p&gt;Stage 2 asks a local model (qwen3:14b via Ollama) to extract&lt;br&gt;
context-dependent PHI. The contract is the highest-leverage decision in the&lt;br&gt;
codebase: the model returns &lt;strong&gt;verbatim snippets plus a type - never&lt;br&gt;
character offsets&lt;/strong&gt;. LLMs cannot count characters, but they copy substrings&lt;br&gt;
reliably. Every returned snippet is located in the source text by string&lt;br&gt;
search, which redacts repeated mentions for free, and a hallucinated span&lt;br&gt;
dies at a &lt;code&gt;str.find&lt;/code&gt; instead of corrupting a redaction. Ask the model for&lt;br&gt;
what it can do (copy), verify what it can't (locate).&lt;/p&gt;

&lt;p&gt;The merge with stage 1 is additive and fail-closed. Stage-1 detections win&lt;br&gt;
overlaps - rules are better calibrated where rules apply. Ambiguous spans&lt;br&gt;
stage 1 flagged are put to the model for adjudication, but only in one&lt;br&gt;
direction: an escalation the model confirms is resolved; one it stays&lt;br&gt;
silent on remains escalated and gets redacted anyway. A 14B model's "no"&lt;br&gt;
never unredacts anything.&lt;/p&gt;

&lt;p&gt;First contact, 50 notes: redaction recall 0.62 → 0.89, NAME relaxed recall&lt;br&gt;
0.00 → 0.98. And one open wound: the model found "Cedar Vale" but clipped&lt;br&gt;
"4050 Mossbank Blvd", fully covering only 12% of address spans. Relaxed F1&lt;br&gt;
made GEO look twice as healthy as it was; redaction recall told the truth.&lt;/p&gt;
&lt;h2&gt;
  
  
  A 125M model beat the 14B model - 40× faster, on CPU
&lt;/h2&gt;

&lt;p&gt;Before reaching for fine-tuning, I tried the boring thing: an off-the-shelf&lt;br&gt;
de-id-specific token classifier (&lt;code&gt;obi/deid_roberta_i2b2&lt;/code&gt;, 125M parameters,&lt;br&gt;
trained on the i2b2 2014 corpus) wrapped as an optional stage-1 recognizer&lt;br&gt;
(&lt;code&gt;pip install 'localscrub[ner]'&lt;/code&gt;). Only its name and location labels are&lt;br&gt;
mapped; dates, phones, and emails stay with the regexes, whose boundaries&lt;br&gt;
are already exact.&lt;/p&gt;

&lt;p&gt;On the template corpus it was decisive: redaction recall 0.94 at 184 ms&lt;br&gt;
per note on CPU - matching the 14B model at the categories it was trained&lt;br&gt;
for, roughly forty times faster, no GPU.&lt;/p&gt;

&lt;p&gt;Let me concede the framing objection before anyone raises it: a specialist&lt;br&gt;
trained on exactly this task beating a prompted generalist is expected, not&lt;br&gt;
shocking. The finding is the &lt;em&gt;size of the trade&lt;/em&gt; - equal recall at&lt;br&gt;
one-fortieth the latency, no GPU - and it matters because the default&lt;br&gt;
recipe today is "throw an LLM at it," and for structured text the default&lt;br&gt;
is measurably wrong. Nor was the 14B handicapped: it ran qwen3:14b at&lt;br&gt;
temperature 0 with schema-constrained decoding and the same&lt;br&gt;
escalation-hint prompt that inference uses&lt;br&gt;
(&lt;a href="https://github.com/valbarov/localscrub/blob/main/src/localscrub/stage2.py" rel="noopener noreferrer"&gt;&lt;code&gt;extraction_prompt&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
in the repo).&lt;/p&gt;

&lt;p&gt;The two models fail &lt;em&gt;differently&lt;/em&gt;,&lt;br&gt;
and that mattered later: the LLM copies name boundaries nearly perfectly&lt;br&gt;
but clips addresses; the token classifier covers whole addresses but drags&lt;br&gt;
titles and credentials into name spans. Complementary failure modes,&lt;br&gt;
measurable as such.&lt;/p&gt;

&lt;p&gt;Integrating it produced the best debugging afternoon of the project - three&lt;br&gt;
real bugs and a gold-standard flaw, all surfaced by the eval:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Adding NER dropped EMAIL recall from 1.00 to 0.53. A merge bug glued
model spans across paragraph breaks (&lt;code&gt;name@example.org.\n\nNext Name&lt;/code&gt;
became one NAME span).&lt;/li&gt;
&lt;li&gt;Still 0.53 after the fix - the &lt;em&gt;upstream&lt;/em&gt; HuggingFace pipeline
aggregation produced the same cross-paragraph span. No identifier spans
a line break; split on newlines first.&lt;/li&gt;
&lt;li&gt;EMAIL 0.78 - the model labels &lt;code&gt;darius.ashcombe@example.com&lt;/code&gt; as a
PATIENT name, because emails literally contain patient names. A person
name never contains &lt;code&gt;@&lt;/code&gt;; drop such spans at the source.&lt;/li&gt;
&lt;li&gt;GEO recall pinned at 0.52 no matter what. Not a model bug: the &lt;strong&gt;gold
standard was wrong&lt;/strong&gt; - the corpus planted street and city as two spans
where reality has one postal address, so the model's single correct span
could only ever match half the gold. The tell was 0.52 recall alongside
0.72 precision. Sometimes the eval indicts the gold, not the system.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule of integration, now baked into regression floors: adding a detector&lt;br&gt;
must never make another detector worse.&lt;/p&gt;
&lt;h2&gt;
  
  
  The prose gets real: an injection benchmark
&lt;/h2&gt;

&lt;p&gt;Templates were too easy, and by this point provably so. The next test&lt;br&gt;
injects synthetic identifiers into ~5,000 authentic public&lt;br&gt;
medical-transcription samples (mtsamples.com - downloaded with a pinned&lt;br&gt;
checksum, never redistributed). Injections are unlabeled narrative&lt;br&gt;
sentences woven between real sentences at deterministic positions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;localscrub mtsamples &lt;span class="nt"&gt;--fetch&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 100 &lt;span class="nt"&gt;--seed&lt;/span&gt; 42 &lt;span class="nt"&gt;-o&lt;/span&gt; mts.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One methodological point worth stating plainly: on an injection benchmark,&lt;br&gt;
&lt;strong&gt;recall is exact but precision is only a lower bound.&lt;/strong&gt; The real&lt;br&gt;
transcripts contain their own name-like and date-like strings - "Dr. X"&lt;br&gt;
placeholders, real dates - so a detector flagging them is penalized for&lt;br&gt;
being right. Redaction recall over the injected gold is the number to&lt;br&gt;
trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results - including the negative one
&lt;/h2&gt;

&lt;p&gt;Every configuration, both corpora, all at full size, scored by the&lt;br&gt;
identical harness: 500 template notes carrying 5,021 gold spans, and 100&lt;br&gt;
MTSamples notes carrying 697 injected gold spans - both from seed 42.&lt;br&gt;
Redaction recall - the safety metric - plus precision on the&lt;br&gt;
authentic-prose corpus, where over-flagging shows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;config&lt;/th&gt;
&lt;th&gt;template&lt;/th&gt;
&lt;th&gt;MTSamples&lt;/th&gt;
&lt;th&gt;MTS precision†&lt;/th&gt;
&lt;th&gt;latency/note&lt;/th&gt;
&lt;th&gt;hardware&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Presidio (rules-only baseline)&lt;/td&gt;
&lt;td&gt;0.78&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;0.49&lt;/td&gt;
&lt;td&gt;14–46 ms&lt;/td&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stage 1 (rules)&lt;/td&gt;
&lt;td&gt;0.66&lt;/td&gt;
&lt;td&gt;0.62&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;µs&lt;/td&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stage 1 + NER&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.999&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.81&lt;/td&gt;
&lt;td&gt;184–652 ms&lt;/td&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stage 1 + LLM&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;0.967&lt;/td&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;td&gt;7–8 s&lt;/td&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stage 1 + NER + LLM&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.999&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.76&lt;/td&gt;
&lt;td&gt;7–17 s&lt;/td&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;† relaxed precision on the injection benchmark - a lower bound for every&lt;br&gt;
system, per the previous section.&lt;/p&gt;

&lt;p&gt;Because three nines invite scrutiny, here are the raw counts behind the&lt;br&gt;
headline number. 0.999 is &lt;strong&gt;696 of 697&lt;/strong&gt; injected spans fully redacted&lt;br&gt;
(Wilson 95% CI 0.992–0.9997); one more miss would read 0.997, so treat the&lt;br&gt;
third digit as "one miss in this sample," not a stability claim. And the&lt;br&gt;
one miss deserves naming: in &lt;code&gt;2034 Harrowgate Rd, Lantern Hill, VT 93695&lt;/code&gt;,&lt;br&gt;
the NER covered the street line ("2034 Harrowgate Rd") and the&lt;br&gt;
state-plus-ZIP ("VT 93695") but dropped the city - "Lantern Hill" leaked&lt;br&gt;
from between two redactions. The&lt;br&gt;
address-clipping failure mode, surviving at the very tail. (The template&lt;br&gt;
0.94, for comparison, is 4,711 of 5,021 - 310 misses; at that sample size&lt;br&gt;
the second digit is doing honest work.)&lt;/p&gt;

&lt;p&gt;Two findings, one of them a negative result I think the field under-reports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On synthetic templates, the LLM buys nothing.&lt;/strong&gt; Stage 1 + NER,&lt;br&gt;
stage 1 + LLM, and the full cascade all converge at 0.94 redaction recall -&lt;br&gt;
and at 0.97 relaxed F1 - at latencies spanning 184 milliseconds to 17&lt;br&gt;
seconds. The residual 6% is corpus-bound, not detector-bound. If your text&lt;br&gt;
is structured and identifier-dense, a good token classifier is all the&lt;br&gt;
model you need, and it runs on CPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On authentic prose, the LLM earns its keep.&lt;/strong&gt; Bare rules manage 0.62;&lt;br&gt;
adding the LLM lifts that to 0.967; NER+LLM reaches 0.999. The two&lt;br&gt;
detectors compose exactly as the merge was designed to: the LLM still&lt;br&gt;
clips addresses, NER still covers them. But recall is not free - the&lt;br&gt;
precision column tells the other half. The cascade over-flags on narrative&lt;br&gt;
text (0.81 → 0.76 versus NER alone, both lower bounds), and over-redaction&lt;br&gt;
has a real cost in clinical text: every falsely scrubbed token is signal a&lt;br&gt;
downstream reader loses. That trade is why localscrub's model is&lt;br&gt;
review-and-attest rather than fire-and-forget - and note the baseline pays&lt;br&gt;
the same toll, with Presidio at 0.49 precision on this corpus. Recall is&lt;br&gt;
what the LLM buys; know which side of the trade your application needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Versus Presidio, fairly
&lt;/h3&gt;

&lt;p&gt;Microsoft's Presidio (rules + spaCy, the standard open-source baseline)&lt;br&gt;
&lt;strong&gt;beats bare stage 1&lt;/strong&gt; on redaction recall - 0.78 vs 0.66 - because spaCy&lt;br&gt;
gives it person and place names, which stage 1 intentionally defers. It is&lt;br&gt;
also 13–14× faster than our recommended CPU configuration, and its NAME&lt;br&gt;
boundaries are &lt;em&gt;better&lt;/em&gt; than our NER extra's (strict F1 0.87 vs 0.73).&lt;br&gt;
Every scoring ambiguity was resolved in the baseline's favor - its unmapped&lt;br&gt;
types still earn redaction credit.&lt;/p&gt;

&lt;p&gt;With the NER extra, localscrub wins where a leak hurts most. Presidio never&lt;br&gt;
fully covered a single gold address on either corpus - spaCy tags "Dayton"&lt;br&gt;
but drops "412 Birch Lane" - and it has no MRN or health-plan recognizer,&lt;br&gt;
fully redacting 2–6% of MRNs and ≤15% of plan IDs. localscrub holds those&lt;br&gt;
at 1.00 in every configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 17-minute specialist
&lt;/h2&gt;

&lt;p&gt;A fair question at this point: if a pretrained 125M classifier already hits&lt;br&gt;
0.999, why train anything? Because a token classifier cannot take stage 2's&lt;br&gt;
seat. It tags a fixed label set - ask it about an identifier type it wasn't&lt;br&gt;
trained on and it has no opinion - and it cannot adjudicate the ambiguous&lt;br&gt;
spans stage 1 escalates. Stage 2's contract, verbatim snippets plus types&lt;br&gt;
as JSON, is a conversation, and only an instruction-following model can&lt;br&gt;
hold up its end. The 14B holds it up at seven seconds a note. The question&lt;br&gt;
worth 17 minutes of GPU time is whether a small model can be &lt;em&gt;taught&lt;/em&gt; to.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;localscrub sft&lt;/code&gt; emits 2,000&lt;br&gt;
training pairs - the exact inference-time stage-2 prompt, escalation hints&lt;br&gt;
included, paired with gold JSON - and a LoRA recipe (r=16, bf16) tunes&lt;br&gt;
Qwen3-1.7B-Base in 17 minutes on the laptop.&lt;/p&gt;

&lt;p&gt;The before/after is stark, but not where I expected. The base 1.7B model&lt;br&gt;
produced unparseable output on &lt;strong&gt;35 of 35&lt;/strong&gt; notes; the tuned one failed on&lt;br&gt;
0 of 80. The fine-tune's first product is not accuracy - it's&lt;br&gt;
&lt;em&gt;parseability&lt;/em&gt;. Accuracy followed: MTSamples redaction recall 0.61 → 0.92.&lt;br&gt;
(Template recall hit a perfect 1.000, which is optimistic by construction -&lt;br&gt;
train and eval share note skeletons; the docs say so.)&lt;/p&gt;

&lt;p&gt;The 0.92-vs-0.999 gap against the big-model cascade is a training-data&lt;br&gt;
diversity gap, not a capacity verdict - 2,000 examples from 7 templates&lt;br&gt;
generalize only partway to real prose, and the recipe documents the fix&lt;br&gt;
(mix in MTSamples-injected and diversified notes). The deeper point: the&lt;br&gt;
synthetic corpus is the asset. Data, training, and eval all regenerate from&lt;br&gt;
seeds; the specialist retrains from scratch in under half an hour on&lt;br&gt;
consumer hardware, with no real PHI anywhere in the loop - including the&lt;br&gt;
prompts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational lessons (the part I wish someone had written for me)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One bad note must not cost you the run.&lt;/strong&gt; Stage-2 latency is bimodal:&lt;br&gt;
~5 s per note typically, ~50 s when schema-constrained decoding runs away&lt;br&gt;
to the 4,096-token cap - and in each 500-note pass, exactly one template&lt;br&gt;
note (deterministic at temperature 0) stalled the Ollama server for&lt;br&gt;
minutes before returning HTTP 500. The first time, that single note killed&lt;br&gt;
a 2-hour-50-minute benchmark at note ~495 with nothing written, because the&lt;br&gt;
eval had no per-note error handling. The fix - retry the note once, then&lt;br&gt;
score it without stage 2 and report an &lt;code&gt;llm_failures&lt;/code&gt; count - turned the&lt;br&gt;
second occurrence into a 4.8-second retry-and-continue. If you benchmark&lt;br&gt;
local LLMs, build this in before your first long run, not after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cap your decoders.&lt;/strong&gt; Uncapped schema-constrained generation once looped&lt;br&gt;
past a ten-minute timeout. A &lt;code&gt;num_predict&lt;/code&gt; cap plus a salvage parser (parse&lt;br&gt;
the longest well-formed prefix of a truncated extraction list; every item&lt;br&gt;
is independently verified against the source anyway) converts runaway&lt;br&gt;
decoding from a crash into a bounded cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assorted potholes:&lt;/strong&gt; a gitignore &lt;em&gt;trailing comment&lt;/em&gt; silently unignored a&lt;br&gt;
17 MB dataset and it reached the git index once; Blackwell GPUs need cu13x&lt;br&gt;
torch builds and uv needed &lt;code&gt;--reinstall&lt;/code&gt; with an explicit &lt;code&gt;+cu130&lt;/code&gt; pin to&lt;br&gt;
swap them; an untuned base model with no token cap looks exactly like a&lt;br&gt;
frozen process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats, stated rather than buried
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The template-corpus numbers are on easy, identifier-dense synthetic
prose; MTSamples-injection is the authentic-prose test, and the numbers
held there. But injected identifiers are not naturally occurring PHI:
real i2b2-style evaluation remains the gap.&lt;/li&gt;
&lt;li&gt;On data leakage: MTSamples is a well-known public corpus, so the
&lt;em&gt;transcripts&lt;/em&gt; may well sit in a model's pretraining data
(&lt;code&gt;obi/deid_roberta_i2b2&lt;/code&gt; was trained on i2b2 2014, not MTSamples; for
the LLM's pretraining the honest answer is unknown). The scored
identifiers, however, are not MTSamples content: every gold span is
synthetic, seeded, and injected - the identifier strings are generated,
not drawn from the transcripts, so memorizing MTSamples does not hand a
model the answers. What leakage &lt;em&gt;could&lt;/em&gt; do is make the surrounding prose feel
familiar, flattering the numbers relative to truly unseen clinical
text - one more reason the i2b2 evaluation (behind a data use
agreement, unlikely to be memorized) is the next benchmark.&lt;/li&gt;
&lt;li&gt;Injection-benchmark precision is a lower bound for every system measured.&lt;/li&gt;
&lt;li&gt;Stage-2 reproducibility is best-effort: fixed seed, temperature 0, but
tied to model build and hardware.&lt;/li&gt;
&lt;li&gt;No real PHI was used anywhere, at any step - including in every prompt
sent to the local model.&lt;/li&gt;
&lt;li&gt;localscrub does not claim HIPAA Safe Harbor certification. It reduces the
problem to review-and-attest; residual-risk sign-off stays human. The
&lt;a href="https://github.com/valbarov/localscrub/blob/main/docs/threat-model.md" rel="noopener noreferrer"&gt;threat model&lt;/a&gt;
spells out the trust boundary, including what a hijacked stage-2 model
can and cannot do (it can over-redact or falsely confirm; it can never
unredact).&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Two measurements are deliberately absent, and they are the next article.&lt;br&gt;
&lt;strong&gt;Cloud de-id APIs&lt;/strong&gt;: the accuracy/latency/cost legs require sending the&lt;br&gt;
benchmark corpora to each provider - synthetic or not, that crossing of the&lt;br&gt;
trust boundary deserves its own explicit decision and write-up, because it&lt;br&gt;
is the exact trade this project exists to interrogate. And &lt;strong&gt;i2b2/n2c2&lt;br&gt;
2014&lt;/strong&gt;: the literature-comparable corpus of naturally occurring PHI, access&lt;br&gt;
request filed and pending. When both land, the comparison table gets its&lt;br&gt;
last rows.&lt;/p&gt;

&lt;p&gt;Until then: the code is on&lt;br&gt;
&lt;a href="https://github.com/valbarov/localscrub" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, the package is on&lt;br&gt;
&lt;a href="https://pypi.org/project/localscrub/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;, and every number above&lt;br&gt;
regenerates from a seed. Check my math.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>privacy</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
