Aug 18, 20269 min read/2026/08/18/i-fine-tuned-with-lora-and-broke-the-model/

I Fine-Tuned With LoRA, Scored 100%, and Broke the Model

Here is a complete LoRA fine-tune, start to finish, on a Mac. No cloud, no rented A100, no config hell. It took three minutes and seventeen seconds, peaked at 2.1 GB of memory, and produced an 11 MB adapter.

It also took the task from 0% to 100%.

The setup, so none of this is vague:

Base model mlx-community/Qwen2.5-1.5B-Instruct-4bit — Qwen2.5 1.5B Instruct, 4-bit quantised
Framework MLX + mlx-lm 0.31.3 (Apple's, runs on the Mac GPU)
Hardware Mac Studio, M1 Max, 64 GB — training peaked at 2.1 GB, so a MacBook Air would do it
Adapter 8 layers, rank default, lr 1e-4, 600 iterations, batch 4

A 1.5B model is small enough to be honest about: it is not a frontier model and I am not claiming these numbers transfer to one. It is, however, exactly the size of thing people actually fine-tune for a narrow job.

And then I ran two checks that almost nobody publishes, and discovered I had wrecked the model. Both halves are below, because the second half is the useful one.

The task

I needed something a base model genuinely cannot do, and that I could grade exactly — no LLM-as-judge, no "looks better to me."

So: convert a ward note into a rigid house code.

"Bed 4, gave 5 mg metoprolol orally at 14:30"   ->   B04|MET|5MG|PO|1430

Bed number zero-padded to two digits, drug as a three-letter house code, dose and unit uppercased, route abbreviated, time as HHMM. Arbitrary and internally consistent — exactly the kind of convention a base model cannot guess and can only be told or taught.

The whole dataset is generated from one function, and that function is the specification:

def encode(bed, drug, dose, unit, route, hh, mm):
    return f"B{bed:02d}|{DRUGS[drug]}|{dose}{unit.upper()}|{ROUTES[route]}|{hh:02d}{mm:02d}"

500 training rows, 60 validation, 140 test, all disjoint — with an assertion that fails the build if any test note appears in training, because I have written before about what happens when you trust an eval you haven't validated.

Grading is exact string match.

Three conditions, because "does LoRA work" is the boring question

The useful question is whether a training run beats simply telling the model the rules in the prompt. So I ran three:

What it is Result
zero-shot instruction only 0/140 — 0.0%
few-shot instruction + 8 worked examples in the prompt 124/140 — 88.6%
LoRA instruction only, adapter loaded 140/140 — 100.0%

Zero-shot is a clean floor. Asked to produce the code, the base model just reformats the sentence:

want: B40|INS|2MG|SC|1515
got:  '15:15, bed 40, 2 mg insulin subcutaneously'

It has no idea. Fine — nobody can guess an arbitrary convention.

But look at few-shot. Eight examples in the prompt, zero training, and it's at 88.6%. That is the real competitor, and if I'd stopped there I'd have had most of the value for the cost of pasting some examples.

The 11.4% that mattered

The few-shot misses are the interesting part, because they aren't random:

want B28|WAR|500MCG|IM|0545   got B28|WAR|500MG|IM|0545     <- dropped the "c"
want B31|CEF|100MCG|PO|0230   got B31|CEF|100MG|PO|0230     <- again
want B01|CEF|1ML|IM|0015      got B1|CEF|1ML|IM|0015        <- lost the zero-pad
want B02|INS|20MG|SC|1805     got B2|INS|20MG|SC|1805       <- again

Two systematic errors: micrograms silently becoming milligrams, and bed numbers losing their zero padding.

Both produce a perfectly well-formed code. Nothing crashes, nothing is malformed, no validator fires. And in a drug-dosing context, MCG → MG is a thousand-fold error, emitted in valid syntax.

That's the failure mode I keep running into: not wrong-looking, just wrong.

LoRA fixed both. All 140, including every microgram and every zero-pad. So the honest framing of what the training bought is not "better" — it's consistent. The prompt taught the concept. The training nailed the edge cases, and here the edge cases were the dangerous ones.

The training run itself

python -m mlx_lm lora \
  --model mlx-community/Qwen2.5-1.5B-Instruct-4bit \
  --train --data ./data \
  --iters 600 --batch-size 4 --num-layers 8 \
  --learning-rate 1e-4 --adapter-path ./adapters

That's it. On an M1 Max:

  • 3 min 17 s wall clock
  • 2.1 GB peak memory — this would fit on a MacBook Air
  • ~1,160 tokens/sec training throughput
  • final train loss 0.207, validation loss 0.220
  • 11 MB adapter

The cost of this experiment was electricity. That's the part of LoRA that genuinely is as good as advertised.

Then I ran the two checks

A 100% score should make you suspicious, not happy. So before writing any of this up, I asked two questions.

1. Did it learn the rule, or memorise the table?

Twelve drugs appear in training. I fed it four that never do — atorvastatin, diazepam, naloxone, gentamicin — all of which follow the same first-three-letters convention.

want B09|ATO|5MG|PO|0830    got B09|AT|5MG|PO|0830
want B17|DIA|10MG|IV|2115   got B17|D|10MG|IV|2115
want B03|NAL|2MG|IM|0445    got B03|NAL|2MG|IM|0445    <- the only hit
want B26|GEN|80MG|IV|1200   got B26|GENT|80MG|IV|1200

1 out of 4. It didn't learn "take the first three letters." It memorised twelve drugs, and on a thirteenth it produces AT, D, GENT — confidently, in valid format, silently wrong.

Note what that means against the headline number. On the test set it scores 100%, because the test set only contains drugs it memorised. The benchmark and the deployment are different distributions, and the benchmark had nothing to say about the gap.

2. Is it still a model?

This is the check I almost skipped, and it's the one that changed the article.

I asked the tuned model three ordinary questions. Same questions to the untouched base model as a control.

"Write a one-line Python function that returns the square of n."

BASE:  def square(n):
           return n ** 2

LoRA:  At 17:00 administered 100 mcg of furosemide orally to bed 10

"In two sentences, what does a database index do?"

BASE:  A database index speeds up data retrieval by allowing the database system
       to quickly locate the relevant data without scanning the entire table.

LoRA:  At a single coded line, a database index allows the database to quickly
       find a single line.

The base model answers both correctly. The tuned model answers a coding question with a ward note, and produces contaminated word salad about databases.

It still knows the capital of France. Almost everything else generative has collapsed into the shape of my training data.

Three minutes and eleven megabytes destroyed a general-purpose model, and the task benchmark went up while it happened.

What I actually did wrong

I want to be precise, because "LoRA breaks models" would be the wrong lesson.

I over-trained. 600 iterations at batch 4 over 500 examples is roughly five epochs on a narrow, highly repetitive corpus. The loss had plateaued around 0.21 by iteration 400 and I kept going.

My dataset had no diversity. Every single example was the same task in the same format. Nothing in the training signal said "and also remain a model." The standard mitigation is to mix in a slice of general instruction data so the gradient has a reason to preserve everything else — I didn't, and this is what that looks like.

I tuned eight layers with a fairly aggressive learning rate for what is, in the end, a formatting task.

All of that is fixable, and a more careful run would have been kinder to the base model. But I'm reporting the run I did, not the run I'd do next time, because this is the default path. Nothing in the tutorial I followed warned me. The training output was clean and green the entire way. The benchmark said 100%.

The lesson, which is not "don't use LoRA"

Use it. It is genuinely three minutes and eleven megabytes, and it fixed a class of silent unit error that a prompt could not.

But three things travel with it:

Try the prompt first, seriously. Few-shot got 88.6% for free. If you skip that comparison you'll never know whether your training run bought 100% or bought 11%.

A task benchmark cannot see collateral damage. Mine measured only the thing I trained for, so it went up while the model fell apart. If you fine-tune and only measure the task, you have no instrument pointed at the damage — which is a specific instance of a general rule I keep re-learning: your eval measures the distribution you thought to sample.

Ask whether it learned the rule or the table. Hold out categories, not just rows. My test set was disjoint in examples and identical in vocabulary, which made it blind to the thing that will actually break in production.

There's a practical upshot in all this, and it connects to yesterday's post on adapters as a supply chain: keeping the adapter separate rather than merged is not just an ops convenience. A model this damaged is completely fine as long as the adapter is loaded only for the ward-coding task and not for anything else. Separability contains the blast radius. Merge it into your general model and you've shipped this.

Run it yourself

The whole experiment is public: github.com/egarim/lora-reality-check

git clone https://github.com/egarim/lora-reality-check
cd lora-reality-check
uv venv --python 3.11 && uv pip install -r requirements.txt
./run_all.sh

Apple Silicon only (MLX is Mac-only), no API key, no cloud account, nothing rented. About 30 minutes end to end, of which the training is three. make_data.py is seeded, so a fresh clone regenerates the identical dataset and you should get the same numbers I did.

The file worth stealing is probe.py. The training script is the same one in every LoRA tutorial; the two checks are the part nobody ships.

So

Zero to a hundred percent in three minutes and seventeen seconds, for eleven megabytes and the price of electricity. LoRA is real and it is cheap.

And when I pointed two instruments at it that weren't the scoreboard, one said it had memorised a lookup table rather than learning the rule, and the other said it could no longer write a two-line Python function.

Build the ruler first — and then build a second ruler pointed somewhere the first one isn't looking.