Fine-tune a small model, on your own laptop.
I had read a dozen threads about fine-tuning and done zero fine-tunes. So I did one, start to finish, on a MacBook, and wrote down every command. I used a 1.5B model and 600 examples. Training took two minutes and left a 21 MB file. You can copy every block below, and I ran every one of them. Follow along and in about half an hour you will have your own fine-tuned model answering on localhost.
Fine-tuning teaches habits, not facts.
The post that pushed me into this was Rahul's full guide to fine-tuning a small LLM. Its main claim is that a 1.5B model trained on a few hundred good examples can beat a frontier model prompted generically, at your one task.
The catch is the word "task." Fine-tuning changes how a model answers: format, tone, the house rules it follows without being reminded. It is a bad way to teach it new facts. If you want facts, use retrieval.
So the rule is prompt first. Fine-tune only when a prompt fails at something specific that you can measure.
Here is the task I picked. Turn a messy message into a calendar event:
can we do the sprint planning with Wei, Chloe & Nia on Mon at 8:45 AM, 60 min?
{"title": "sprint planning", "day": "Monday", "time": "08:45", "duration_min": 60, "people": ["Wei", "Chloe", "Nia"]}
The answer has to follow my house rules:
HH:MM. "half past 4" becomes "16:30".None of these rules is hard. What matters is that they are arbitrary. A prompt can describe them, but a model has to see them a few hundred times before it follows them every time. Fine-tuning fills that gap.
I also picked this task because code can grade the output. So every claim on this page is a measured number.
01the vocabularysix words you will see all the way down+
Six words
Qwen2.5-1.5B-Instruct, already a chat model, just not my chat model.Setup is one folder and one install.
The stack is MLX, Apple's array framework for Apple Silicon. On a Mac the CPU and GPU share one pool of unified memory. Every gigabyte of RAM is a gigabyte of GPU memory, and that is why a laptop can do this at all.
mlx-lm is the LLM toolkit built on top of it. One package loads models, generates text, trains LoRA adapters, fuses them and serves them. I pinned the version I used, so your output should match mine.
mkdir finetune-lab && cd finetune-lab uv venv --python 3.12 source .venv/bin/activate uv pip install "mlx-lm==0.31.3" python -c "import mlx_lm, mlx.core as mx; print(mlx_lm.__version__, mx.default_device())"
0.31.3 Device(gpu, 0)
Then grab the two scripts. You will read both of them properly in the next two steps.
curl -LO https://www.heqinghuang.com/finetune-lab/make_data.py curl -LO https://www.heqinghuang.com/finetune-lab/evaluate.py
01why the model name ends in -4bitthe Q in QLoRA+
A 3 GB model in 0.9 GB
Every script uses mlx-community/Qwen2.5-1.5B-Instruct-4bit. That is Qwen's model with its weights already squeezed from 16 bits down to 4. At 1.54 billion weights, 16 bits is about 3.1 GB. 4 bits, plus a little overhead, is the 0.9 GB you download.
Those 4-bit weights stay frozen. The small LoRA matrices you train sit next to them in full 32-bit precision, so training never updates the lossy 4-bit part.
In the original QLoRA paper, 4-bit plus LoRA matched full 16-bit fine-tuning. That result is why this recipe is the default for small budgets.
02what a 4-bit weight isgroups of 64, a scale and a bias+
Sixteen levels per weight
Four bits can count from 0 to 15. MLX cuts each weight row into groups of 64 and stores one scale and one bias per group. Every weight is rebuilt on the fly from its 4-bit integer. This is straight from the model's own config.json:
"quantization": {"group_size": 64, "bits": 4}
# q is a 4-bit integer 0..15. scale and bias are shared by 64 neighbours. w ≈ scale * q + bias # cost: 4 bits per weight + (16 + 16) bits per 64 weights ≈ 4.5 bits/weight
Six hundred examples, written by a script.
On a real project, this is where most of the time goes: collecting examples, cleaning them, checking every label. Rahul's guide spends six of its fourteen steps here.
I cheated, on purpose. A Python script invents a messy message and its exact answer at the same time, so every label is free and correct. That is great for learning the mechanics. It also plants a trap we will walk straight into in step 6.
python make_data.py
wrote 600 rows -> data/train.jsonl wrote 60 rows -> data/valid.jsonl wrote 100 rows -> data/test.jsonl
Here is the whole script. It is worth a slow read. Every choice in TEMPLATES, say_time and say_duration sets the limits of what the model can learn.
"""Build a tiny synthetic dataset: messy meeting requests -> strict JSON. Writes data/train.jsonl, data/valid.jsonl, data/test.jsonl in the chat "messages" format that mlx_lm.lora reads directly. """ import json import random from pathlib import Path SYSTEM = ( "Extract the meeting from the message. Reply with JSON only: " '{"title": str, "day": str, "time": "HH:MM", "duration_min": int, "people": [str]}' ) TITLES = ["design sync", "1:1", "roadmap review", "standup", "budget check-in", "hiring debrief", "launch retro", "coffee chat", "sprint planning", "customer call", "offsite prep", "interview loop", "demo run-through", "pricing review", "onboarding walkthrough"] PEOPLE = ["Priya", "Tom", "Aiko", "Marcus", "Lena", "Diego", "Sam", "Wei", "Fatima", "Jonas", "Ravi", "Chloe", "Omar", "Hana", "Leo", "Nia"] DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] DAY_SAY = {d: [d, d.lower(), d[:3], d[:3].lower(), "next " + d.lower()] for d in DAYS} def say_time(h, m): """Many ways a human writes a time. The label is always 24h HH:MM.""" h12 = h % 12 or 12 ampm = "am" if h < 12 else "pm" opts = [f"{h12}{ampm}" if m == 0 else f"{h12}:{m:02d}{ampm}", f"{h12}:{m:02d} {ampm.upper()}", f"{h:02d}:{m:02d}"] if m == 30: opts.append(f"half past {h12}") if h == 12 and m == 0: opts.append("noon") return random.choice(opts) def say_duration(mins): if mins is None: return "" opts = {15: ["15 min", "a quick 15"], 30: ["30 mins", "half an hour"], 45: ["45 minutes", "45m"], 60: ["an hour", "1h", "60 min"], 90: ["90 minutes", "an hour and a half"]}[mins] return random.choice(opts) def say_people(ppl): if len(ppl) == 1: return ppl[0] return ", ".join(ppl[:-1]) + random.choice([" and ", " & "]) + ppl[-1] TEMPLATES = [ "can we do the {title} with {people} on {day} at {time}{dur_sfx}?", "{people} + me, {title}, {day} {time}{dur_sfx}", "hey! booking {title} for {day} @ {time}{dur_sfx}. inviting {people}", "Reminder: {title} {day} {time}{dur_sfx} w/ {people}. pls confirm", "move the {title} to {day}, {time}{dur_sfx}. {people} can make it", "{day} {time} works for the {title}{dur_sfx}? adding {people}", "ok so {people} want a {title}. thinking {day} around {time}{dur_sfx}", ] def example(): title = random.choice(TITLES) ppl = random.sample(PEOPLE, random.randint(1, 3)) day = random.choice(DAYS) h, m = random.choice(range(8, 18)), random.choice([0, 0, 15, 30, 45]) mins = random.choice([15, 30, 45, 60, 90, None]) dur = say_duration(mins) text = random.choice(TEMPLATES).format( title=title, people=say_people(ppl), day=random.choice(DAY_SAY[day]), time=say_time(h, m), dur_sfx=f", {dur}" if dur else "") label = {"title": title, "day": day, "time": f"{h:02d}:{m:02d}", "duration_min": mins or 30, # house rule: no duration -> 30 "people": ppl} return {"messages": [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": text}, {"role": "assistant", "content": json.dumps(label)}, ]} def main(): random.seed(0) out = Path("data") out.mkdir(exist_ok=True) for name, n in [("train", 600), ("valid", 60), ("test", 100)]: with open(out / f"{name}.jsonl", "w") as f: for _ in range(n): f.write(json.dumps(example()) + "\n") print(f"wrote {n:>3} rows -> data/{name}.jsonl") if __name__ == "__main__": main()
{
"messages": [
{
"role": "system",
"content": "Extract the meeting from the message. Reply with JSON only: {\"title\": str, \"day\": str, \"time\": \"HH:MM\", \"duration_min\": int, \"people\": [str]}"
},
{
"role": "user",
"content": "Reminder: pricing review next wednesday 4:30 PM, 1h w/ Hana & Priya. pls confirm"
},
{
"role": "assistant",
"content": "{\"title\": \"pricing review\", \"day\": \"Wednesday\", \"time\": \"16:30\", \"duration_min\": 60, \"people\": [\"Hana\", \"Priya\"]}"
}
]
}
mlx_lm.lora reads it directly. The system prompt is the same in every row, so the model is trained on the exact prompt it will see at inference time.01what the model actually readsone string, and which parts get graded+
The chat template
A model never sees JSON with roles. The tokenizer's chat template flattens the three messages into one string, with special tokens marking where each turn starts and ends. This is Qwen's real rendering of the row above:
<|im_start|>system Extract the meeting from the message. Reply with JSON only: {"title": str, "day": str, "time": "HH:MM", "duration_min": int, "people": [str]}<|im_end|> <|im_start|>user Reminder: pricing review next wednesday 4:30 PM, 1h w/ Hana & Priya. pls confirm<|im_end|> <|im_start|>assistant {"title": "pricing review", "day": "Wednesday", "time": "16:30", "duration_min": 60, "people": ["Hana", "Priya"]}<|im_end|>
Grade the answer, not the question
The row is 126 tokens long. With the --mask-prompt flag you will pass in step 4, only the last 44 count toward the loss: the JSON answer and the closing <|im_end|>. Grading that stop token is how the model learns to stop after the JSON instead of rambling on.
Without the mask, most of the gradient would go into predicting the system prompt, which is identical in every row, and the user's message, which is random by design. Neither teaches the task.
One more detail: Qwen's tokenizer splits "16:30" into five tokens, 1 6 : 3 0. The model writes times digit by digit, and each digit is a separate chance to get the 12-to-24-hour conversion wrong.
02three files, three jobstrain, valid, and the one you never touch+
Train, valid, test
Score the prompt before you train.
This is the most skipped step in fine-tuning. If you never measured the prompt-only model, you cannot know whether training did anything.
The base model gets a fair shot: every test row carries the system prompt that spells out the JSON schema. The first run downloads the model, about 0.9 GB, into ~/.cache/huggingface.
python evaluate.py none
msg : can we do the sprint planning with Wei, Chloe & Nia on Mon at 8:45 AM, 60 min? got : {"title": "Sprint Planning Meeting", "day": "Monday", "time": "08:45", "duration_min": 60, "people": ["Wei", "Chloe", "Nia"]} want: {"title": "sprint planning", "day": "Monday", "time": "08:45", "duration_min": 60, "people": ["Wei", "Chloe", "Nia"]} msg : ok so Jonas, Omar and Nia want a coffee chat. thinking Fri around 09:00, a quick 15 got : {"title": "Coffee Chat", "day": "Friday", "time": "09:00", "duration_min": 15, "people": ["Jonas", "Omar", "Nia"]} want: {"title": "coffee chat", "day": "Friday", "time": "09:00", "duration_min": 15, "people": ["Jonas", "Omar", "Nia"]} msg : hey! booking onboarding walkthrough for friday @ 3pm, 60 min. inviting Wei got : {"title": "Onboarding Walkthrough", "day": "Friday", "time": "15:00", "duration_min": 60, "people": ["Wei"]} want: {"title": "onboarding walkthrough", "day": "Friday", "time": "15:00", "duration_min": 60, "people": ["Wei"]} msg : ok so Nia & Leo want a 1:1. thinking next friday around 8:30 AM, half an hour got : {"title": "Nia & Leo 1:1", "day": "Friday", "time": "08:30", "duration_min": 30, "people": ["Nia", "Leo"]} want: {"title": "1:1", "day": "Friday", "time": "08:30", "duration_min": 30, "people": ["Nia", "Leo"]} msg : can we do the demo run-through with Tom and Fatima on Monday at 16:00, an hour and a half? got : {"title": "Demo Run-Through", "day": "Monday", "time": "16:00", "duration_min": 90, "people": ["Tom", "Fatima"]} want: {"title": "demo run-through", "day": "Monday", "time": "16:00", "duration_min": 90, "people": ["Tom", "Fatima"]} valid JSON : 98/100 exact match: 2/100 title 6/100 day 78/100 time 68/100 duration_min 75/100 people 87/100
It is a capable model. The JSON parses 98 times out of 100, and most fields are right. But it does not know my rules. It writes titles in Title Case and adds "Meeting". When I give no duration, it guesses 0 or 60 instead of 30.
Every one of those choices is reasonable, but none of them follows my rules. 2 exact answers out of 100 is the number to beat.
02the grader, line by lineevaluate.py, all of it+
Forty lines that do the grading
"""Score the model on a test file, with or without your LoRA adapter. usage: python evaluate.py none # base model, prompt only python evaluate.py adapters # base model + your adapter python evaluate.py adapters data/wild.jsonl # any test file """ import json import sys from mlx_lm import generate, load MODEL = "mlx-community/Qwen2.5-1.5B-Instruct-4bit" FIELDS = ["title", "day", "time", "duration_min", "people"] SHOW_MISSES = 5 def main(): adapter = sys.argv[1] if len(sys.argv) > 1 else "none" path = sys.argv[2] if len(sys.argv) > 2 else "data/test.jsonl" model, tok = load(MODEL, adapter_path=None if adapter == "none" else adapter) rows = [json.loads(line) for line in open(path)] valid = exact = shown = 0 field_hits = dict.fromkeys(FIELDS, 0) for row in rows: # system + user only: the assistant turn is the answer key prompt = tok.apply_chat_template( row["messages"][:2], add_generation_prompt=True, tokenize=False) out = generate(model, tok, prompt=prompt, max_tokens=120).strip() gold = json.loads(row["messages"][2]["content"]) try: pred = json.loads(out) valid += 1 except json.JSONDecodeError: pred = {} if not isinstance(pred, dict): pred = {} hits = {f: pred.get(f) == gold[f] for f in FIELDS} for f in FIELDS: field_hits[f] += hits[f] if all(hits.values()): exact += 1 elif shown < SHOW_MISSES: shown += 1 print(f"\nmsg : {row['messages'][1]['content']}") print(f"got : {out}") print(f"want: {json.dumps(gold)}") n = len(rows) print(f"\nvalid JSON : {valid}/{n}") print(f"exact match: {exact}/{n}") for f in FIELDS: print(f" {f:<13} {field_hits[f]}/{n}") if __name__ == "__main__": main()
<|im_start|>assistant, so the model's next token begins its answer. That is the same boundary the loss mask uses in training.Train 0.34% of the model for two minutes.
The fine-tune itself is one command.
mlx_lm.lora \ --model mlx-community/Qwen2.5-1.5B-Instruct-4bit \ --train --data data \ --mask-prompt \ --batch-size 4 --iters 300 --learning-rate 1e-4 \ --num-layers 16 \ --steps-per-report 10 --steps-per-eval 50 \ --adapter-path adapters
Loading pretrained model Loading datasets Training Trainable parameters: 0.342% (5.276M/1543.714M) Starting training..., iters: 300 Iter 1: Val loss 0.758, Val took 2.504s Iter 10: Train loss 0.127, … Trained Tokens 1780, Peak mem 3.498 GB Iter 20: Train loss 0.105, … Trained Tokens 3580, Peak mem 3.498 GB Iter 30: Train loss 0.027, … Trained Tokens 5368, Peak mem 3.498 GB … Iter 50: Val loss 0.008, Val took 1.795s Iter 50: Train loss 0.004, … Trained Tokens 8975, Peak mem 3.498 GB Iter 100: Val loss 0.006, Val took 1.829s Iter 100: Train loss 0.001, … Trained Tokens 17928, Peak mem 3.498 GB Iter 110: Train loss 0.000, … Trained Tokens 19716, Peak mem 3.519 GB … Iter 200: Val loss 0.000, Val took 1.822s Iter 200: Train loss 0.000, … Trained Tokens 35922, Peak mem 3.519 GB Iter 300: Val loss 0.000, Val took 1.789s Iter 300: Train loss 0.000, … Trained Tokens 53884, Peak mem 3.520 GB Saved final weights to adapters/adapters.safetensors.
Each flag, in plain words:
train.jsonl and valid.jsonl.01what LoRA doestwo thin matrices beside a frozen one+
A detour around every frozen matrix
Take one weight matrix in the model, say the attention query projection. It maps a 1,536-wide vector to another 1,536-wide vector: 2.36 million frozen numbers. LoRA leaves it alone and adds a side path. The input goes down through A to just 8 numbers, back up through B, gets multiplied by a scale, and is added to the frozen output.
LoRA bets that the change a fine-tune needs is simple, even when the matrix is not. You do not need 2.36 million new numbers to teach "lowercase the title". A rank-8 correction, 24,576 numbers, is enough.
Step zero is the base model, exactly
A starts as small random numbers. B starts as all zeros. Zero times anything is zero, so before the first update the side path adds nothing, and the model is exactly the base model. Training starts from the model you measured in step 3, not from noise. This is mlx-lm's code, trimmed:
# __init__ scale = 1 / math.sqrt(input_dims) self.lora_a = mx.random.uniform(low=-scale, high=scale, shape=(input_dims, r)) self.lora_b = mx.zeros(shape=(r, output_dims)) # __call__ y = self.linear(x) # the frozen 4-bit matmul z = (self.dropout(x) @ self.lora_a) @ self.lora_b # down to 8, back up return y + (self.scale * z).astype(x.dtype) # scale defaults to 20.0
02counting the 5,275,648an interactive parameter calculator+
Where every trainable number lives
The training log said Trainable parameters: 0.342% (5.276M/1543.714M). Here is the whole count. Each of the 16 adapted layers has seven linear matrices, and each gets an A and a B. Move the sliders to see what rank and depth cost.
At the defaults you get 16 × 329,728 = 5,275,648, which matches the log. At 4 bytes each that is 21.1 MB, and the file on disk is 21,126,646 bytes. Nothing on this page is estimated.
The shapes also give away the architecture. k_proj and v_proj output only 256, not 1,536, because Qwen2.5 uses grouped-query attention. And the three MLP matrices hold 76% of the adapter, because the MLP is 8,960 wide.
03the loss functioncross-entropy, masked+
Eleven lines
This is the whole training objective, from mlx-lm. Shift the tokens by one so every position predicts the next token. Take the cross-entropy at every position. Zero out everything outside the answer. Average over the tokens that are left.
def default_loss(model, batch, lengths): inputs = batch[:, :-1] targets = batch[:, 1:] # predict the NEXT token logits = model(inputs) steps = mx.arange(1, targets.shape[1] + 1) mask = mx.logical_and(steps >= lengths[:, 0:1], steps <= lengths[:, 1:]) # lengths[:,0] = where the answer starts ce = nn.losses.cross_entropy(logits, targets) * mask ntoks = mask.sum() ce = ce.astype(mx.float32).sum() / ntoks return ce, ntoks
Cross-entropy is -log(p), where p is the probability the model gave the correct token. A loss of 0.758 means an average correct-token probability of about e-0.758 ≈ 0.47. A loss of 0.008 means 0.992. Keep that conversion handy for the next step.
The log's "Trained Tokens" counter proves the mask works: 53,884 tokens over 1,200 rows is about 45 per row. That matches the 44 answer tokens per row from step 2, not the 126 in the full row.
When the loss hits zero, worry.
These are the real numbers from both of my runs. v2 is the retrain you will do in step 7. The y-axis is logarithmic. Otherwise everything after step 30 would be squashed flat against the bottom.
Validation loss falls from 0.758 to 0.008 by step 50. Training loss prints 0.000 at step 110 and stays there. Zero means the model assigns near-certain probability to every answer token.
On real data, that does not happen. People write ambiguous things, and a model that is certain about all of it has memorized something. Here the reason is simple. My generator has only 7 sentence templates and 15 titles, so the model did not learn how people describe meetings. It learned my script.
And validation could not catch it, because the validation rows came from the same script. A validation set only tells you about the distribution it was drawn from. Mine was drawn from the same seven templates.
01reading any loss curvefour shapes, four diagnoses+
Four shapes
adapters/0000100_adapters.safetensors), or add data.Then write ten messages by hand.
First, the frozen test set.
python evaluate.py adapters
valid JSON : 100/100
exact match: 100/100
title 100/100
day 100/100
time 100/100
duration_min 100/100
people 100/100Exact matches went from 2 to 100. It would be easy to stop here, post the chart, and call it a success.
But that test came from the same script as the training data. So I wrote ten messages the way people actually write them: new names, new meeting types, phrasings my templates never produce. Write your own ten before you look at mine. Then run both.
curl -Lo data/wild.jsonl https://www.heqinghuang.com/finetune-lab/wild.jsonl python evaluate.py none data/wild.jsonl python evaluate.py adapters data/wild.jsonl
# base, prompt only valid JSON : 10/10 exact match: 0/10 # with your adapter valid JSON : 10/10 exact match: 5/10
The adapter scores 100 out of 100 on the easy test, but only 5 out of 10 on messages written by a person. The second number is much closer to the truth. Step 7 will show that even this number is less solid than it looks.
Click through the v1 misses and every one traces back to the generator.
The adapter learned my rules, but only for the kinds of input the data covered.
Fix the data, not the model.
Every miss pointed to a gap in the generator, so the fix goes into the generator. Here is the whole diff: two-hour meetings, "quarter past" and "quarter to", and five new templates.
--- make_data.py +++ make_data_v2.py @@ -33,2 +33,6 @@ opts.append(f"half past {h12}") + if m == 15: + opts.append(f"quarter past {h12}") + if m == 45: + opts.append(f"quarter to {(h + 1) % 12 or 12}") if h == 12 and m == 0: @@ -43,3 +47,4 @@ 45: ["45 minutes", "45m"], 60: ["an hour", "1h", "60 min"], - 90: ["90 minutes", "an hour and a half"]}[mins] + 90: ["90 minutes", "an hour and a half"], + 120: ["2 hours", "two hours", "120 min"]}[mins] return random.choice(opts) @@ -61,2 +66,7 @@ "ok so {people} want a {title}. thinking {day} around {time}{dur_sfx}", + "{Title}. {Day}. {time}. Me, {people}.{dur_sfx_sent}", + "Could you set up a {title} for {day} at {time}? Bring in {people}.{dur_sfx_sent}", + "Rescheduling our {title} to {day} {time}. Attendees: {people}.{dur_sfx_sent}", + "{title} {day} {time} ({dur_paren}) - {people}", + "Can {people} and I do a {title} {day} at {time}{dur_sfx}?", ] @@ -69,7 +79,11 @@ h, m = random.choice(range(8, 18)), random.choice([0, 0, 15, 30, 45]) - mins = random.choice([15, 30, 45, 60, 90, None]) + mins = random.choice([15, 30, 45, 60, 90, 120, None]) dur = say_duration(mins) + say_day = random.choice(DAY_SAY[day]) text = random.choice(TEMPLATES).format( - title=title, people=say_people(ppl), day=random.choice(DAY_SAY[day]), - time=say_time(h, m), dur_sfx=f", {dur}" if dur else "") + title=title, Title=title.capitalize(), people=say_people(ppl), + day=say_day, Day=say_day.capitalize(), time=say_time(h, m), + dur_sfx=f", {dur}" if dur else "", + dur_sfx_sent=f" {dur.capitalize()}." if dur else "", + dur_paren=dur or "30 min") label = {"title": title, "day": day, "time": f"{h:02d}:{m:02d}",
wild.jsonl is not a test anymore, because the training data now contains its phrasings. Any score on it will make the model look better than it is.So before training v2, I wrote a second set of ten, wild2.jsonl. It has new people, new meeting types, and phrasings that neither generator produces: "1.5 hrs", "2:15p", "pencil in". Only wild2 is a fair test of v2.
curl -LO https://www.heqinghuang.com/finetune-lab/make_data_v2.py curl -Lo data/wild2.jsonl https://www.heqinghuang.com/finetune-lab/wild2.jsonl python make_data_v2.py # overwrites data/train, valid, test mlx_lm.lora --model mlx-community/Qwen2.5-1.5B-Instruct-4bit \ --train --data data --mask-prompt \ --batch-size 4 --iters 300 --learning-rate 1e-4 --num-layers 16 \ --steps-per-report 10 --steps-per-eval 50 \ --adapter-path adapters_v2 python evaluate.py adapters data/wild2.jsonl python evaluate.py adapters_v2 data/wild2.jsonl
# v1 adapter valid JSON : 10/10 exact match: 5/10 # v2 adapter valid JSON : 10/10 exact match: 5/10
Five and five. So the new data did nothing?
That is not what I saw the first time. My first v2 scored 7 out of 10, and I had already typed "v2 wins" into this page. Then I read v2's misses on its own test split. Three of the four looked like this:
msg : Can Lena and Wei and I do a roadmap review next thursday at 4:15 PM? got : {..., "duration_min": 30, ...} want: {..., "duration_min": 120, ...}
The message gives no duration, so by my own rule the answer is 30. The model was right, and my label was wrong. My new "Can … and I do …" template left the duration out of the message, but the label still used a random one. Every row from that template taught the model to make up a meeting length. The make_data_v2.py you downloaded has the fix, so the v2 you just trained is the fixed one.
With the bug fixed, wild2 went from 7 down to 5. So did fixing a bug make the model worse?
Ten examples can't tell 5 from 7.
Ten messages could not answer that, so I did two things.
First, I wrote twenty more fresh messages (wild3.jsonl) before training anything else. Together with wild2, that makes 30 messages neither model has seen in any form.
Second, I trained each version three times. The data and flags stayed the same, and only --seed changed. The seed sets two things and nothing else: the random starting values of every A matrix, and the order the rows get shuffled in.
curl -Lo data/wild3.jsonl https://www.heqinghuang.com/finetune-lab/wild3.jsonl
cat data/wild2.jsonl data/wild3.jsonl > data/fresh30.jsonl
for s in 1 2; do
python make_data.py
mlx_lm.lora --model mlx-community/Qwen2.5-1.5B-Instruct-4bit --train --data data \
--mask-prompt --batch-size 4 --iters 300 --learning-rate 1e-4 --num-layers 16 \
--seed $s --adapter-path v1_s$s
python make_data_v2.py
mlx_lm.lora --model mlx-community/Qwen2.5-1.5B-Instruct-4bit --train --data data \
--mask-prompt --batch-size 4 --iters 300 --learning-rate 1e-4 --num-layers 16 \
--seed $s --adapter-path v2_s$s
done
for a in none adapters v1_s1 v1_s2 adapters_v2 v2_s1 v2_s2; do
echo $a; python evaluate.py $a data/fresh30.jsonl | grep exact
done| exact out of 30 fresh | seed 0 | seed 1 | seed 2 | mean |
|---|---|---|---|---|
| base · prompt only | 1 | · | · | 1 |
| v1 adapter | 20 | 12 | 18 | 16.7 |
| v2 adapter | 16 | 22 | 21 | 19.7 |
Read the v1 row first. With the same data and the same code, the score goes from 12 to 20 only because the seed changed. That is 8 points of noise, bigger than the gap between v1 and v2.
On average v2 is ahead, 19.7 to 16.7, and it wins two seeds out of three. But if I had only run seed 0, which is what almost everyone does, I would have concluded the opposite and thrown away the better data.
So the rule I took away: never compare two single runs on a small test. Use more test examples than feels necessary, train with a few seeds, and report the range, not just the best number.
What v2 still gets wrong is the to-do list for v3. Pick the messages in the replay above with the v2 button:
From a 21 MB file to an API.
The quickest check: load the adapter next to the base model and ask it something new.
mlx_lm.generate \ --model mlx-community/Qwen2.5-1.5B-Instruct-4bit \ --adapter-path adapters_v2 \ --system-prompt "$(python -c 'from make_data import SYSTEM; print(SYSTEM)')" \ --prompt "lunch w/ Hugo, Friday 1pm, 45 min"
==========
{"title": "lunch", "day": "Friday", "time": "13:00", "duration_min": 45, "people": ["Hugo"]}
==========
Prompt: 70 tokens, 1074.725 tokens-per-sec
Generation: 39 tokens, 141.889 tokens-per-sec
Peak memory: 1.112 GBTo ship it, fuse it: fold scale · A · B into each frozen matrix, so the result is one ordinary model folder that needs no adapter. One gotcha I hit: the model loader skips files like README.md when it downloads, but fuse wants the complete snapshot and fails without network access. Download everything first.
hf download mlx-community/Qwen2.5-1.5B-Instruct-4bit mlx_lm.fuse --model mlx-community/Qwen2.5-1.5B-Instruct-4bit \ --adapter-path adapters_v2 --save-path fused du -sh fused adapters_v2/adapters.safetensors
839M fused 20M adapters_v2/adapters.safetensors
Then serve it. mlx_lm.server implements the OpenAI chat API, so any OpenAI SDK, or curl, can talk to your model.
mlx_lm.server --model ./fused --port 8080
curl -s localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"temperature": 0, "max_tokens": 80, "messages": [
{"role": "system", "content": "Extract the meeting from the message. Reply with JSON only: {\"title\": str, \"day\": str, \"time\": \"HH:MM\", \"duration_min\": int, \"people\": [str]}"},
{"role": "user", "content": "retro thursday 4:30pm with Jun and Ama"}]}' \
| python -c "import json,sys; print(json.load(sys.stdin)['choices'][0]['message']['content'])"{"title": "retro", "day": "Thursday", "time": "16:30", "duration_min": 30, "people": ["Jun", "Ama"]}You now have a model you trained, answering on localhost, with the house rules built in. The message gave no duration, so it answered 30 without being told.
Putting it on the public web is a different job. Vercel functions have no GPU and cannot hold a 0.9 GB model in memory, so this site cannot run it. The replay above is recorded output, not a live model. The real options, cheapest first:
hf upload your-name/meeting-json-lora adapters_v2. The adapter is 21 MB, and anyone with the base model can load it.--enable-lora on Modal, RunPod or similar, behind auth, a rate limit and a queue. Rahul's guide is firm on this: never expose the raw model server.No Mac? The same recipe on a GPU.
Rahul's guide runs this recipe with Hugging Face's stack on a free Colab T4: transformers + bitsandbytes for the 4-bit base, peft for LoRA, trl's SFTTrainer for the loop. Every concept above carries over. Only the names change.
| mlx-lm | Hugging Face stack |
|---|---|
--model …-4bit | BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4") |
| rank 8, 7 matrices | LoraConfig(r=8, target_modules=["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"]) |
--num-layers 16 | LoraConfig(layers_to_transform=list(range(12, 28))) |
--mask-prompt | SFTConfig(assistant_only_loss=True) |
--batch-size 4 --iters 300 | per_device_train_batch_size=4, max_steps=300 |
--learning-rate 1e-4 | learning_rate=1e-4 |
adapters.safetensors | adapter_model.safetensors |
mlx_lm.fuse | model.merge_and_unload() |
mlx_lm.server | vllm serve … --enable-lora |
One translation is a real trap. PEFT scales the LoRA path by lora_alpha / r. mlx-lm uses a flat scale, 20 by default. A common PEFT setting, r=16 with alpha=32, comes out to a scale of 2. MLX's default is ten times larger. Both work, but they need different learning rates. If you copy hyperparameters between the two stacks, convert the scale first.
Training takes two minutes. The real work is the data.
What surprised me was how little of this was about the model. The training command never changed. It ran in two minutes, on a laptop, and taught the model every rule I had. All the real work was deciding what "right" means, writing examples that cover it, and building a test the model could not have memorized.
Your turn. Some ways to break it on purpose:
mlx-community/Qwen2.5-0.5B-Instruct-4bit in all three places. How does a model a third the size score on the 30 fresh messages?lora_parameters: {rank: 32, scale: 20.0, dropout: 0.0} in a YAML file and pass -c config.yaml. Also try --num-layers 4. Use the calculator first to predict the adapter size.--mask-prompt and compare the loss curve and the wild2 score.make_data.py with 300 real examples of something you do by hand every week. That is the version that matters.The model learns what your data teaches it, and nothing more.