Part 1 ended with train.jsonl, val.jsonl and a passes_all percentage. The passes_all percentage is our ceiling, the best score we can hope for from the model trained on that corpus.
This post is the rest: LoRA SFT on Qwen3-4B, evaluating against the same five binary questions, iterating manually with hand-corrections and converting to GGUF for easier local inference.
Code is in the same repo: github.com/arunma/learn-you-an-sft.
We need a GPU now. 48 GB VRAM is the comfortable target — A6000, RTX 6000 Ada, L40S, H100. 32 GB works (RTX 5090) if we drop BATCH_SIZE from 8 to 4 in run/train.py and bump GRAD_ACCUMULATION from 2 to 4.
The
BATCH_SIZEadjustment is mandatory, since it directly halves the size of activations and logits.
The
GRAD_ACCUMULATIONchange from 2 to 4 is optional but highly recommended. Without it, the effective batch size is 8 and not 16, which makes convergence less stable.
Step 5 — Train (without OOMing)
| |
LoRA SFT is done on Qwen3-4B-Instruct via HuggingFace TRL. Defaults are tuned for a 48 GB GPU. Constants live at the top of run/train.py.
Two knobs matter most:
| |
The
SYSTEMprompt must be identical at training and inference time. We’ll use it later in LM Studio’s system prompt field. If the system prompt is different, the persona doesn’t fire.
The LORA_TARGET_MODULES list above is a mix of Attention and MLP modules. This is the difference between “character in a costume” and “character”. Attention-only LoRA gets us a model that says the right things but the shape of the response would be Qwen’s default bulleted action plans. MLP layers are where the shape of the response lives: sentence length, format, pacing, whether it asks a question back. To train a character like Monty, we train both.
Module names differ across base models. Llama, Qwen, Mistral and Gemma each name their projections slightly differently. The easiest way to list them in a given base model is to iterate
named_modules()orprint(base)on the console:
1 2 3 4 5 6from transformers import AutoModelForCausalLM base = AutoModelForCausalLM.from_pretrained(BASE_ID) for name, _ in base.named_modules(): if name.endswith("_proj"): print(name)The resolved list then feeds into peft’s
LoraConfigastarget_modules:
1 2 3 4 5 6 7 8 9 10from peft import LoraConfig peft_config = LoraConfig( r=LORA_R, lora_alpha=LORA_ALPHA, lora_dropout=LORA_DROPOUT, target_modules=LORA_TARGET_MODULES, bias="none", task_type="CAUSAL_LM", )If we’d rather skip the name-lookup, peft’s
target_modules="all-linear"shortcut auto-targets every linear layer in the model:
1 2 3 4peft_config = LoraConfig( ..., target_modules="all-linear", )Less explicit but no guessing.
Sidebar: What those names actually look like in Qwen3-4B (click to expand)
print(base) — one decoder layer (the other 35 are identical):
| |
The named_modules() loop for layer 0 (the pattern repeats for all 36 layers):
| |
7 projections × 36 layers = 252 linear modules that we train on.
The important parameters for SFTConfig are:
| |
assistant_only_loss=True means the loss only fires on assistant tokens (the assistant responses in the training samples) and not on the system prompt, user prompt or other placeholder tokens.
Sidebar: What `assistant_only_loss=True` actually sees (click to expand)
A training sample comes in as a chat dict:
| |
The tokenizer’s chat template renders this into a single string with role-delimiter tokens. For Qwen3:
| |
Without assistant_only_loss (default), cross-entropy fires on every position — the model is penalised for not predicting the system prompt and the user message just as much as for the assistant reply. We’ll never want the model to memorize the system prompt nor predict the other tokens.
With assistant_only_loss=True, we set the labels of other tokens to -100 (PyTorch’s ignore index) on every token outside the assistant turn:
| |
The model still sees the system + user tokens (they’re attended to as context), but it doesn’t calculate loss for them.
Sample output:
| |
Watch three things in that output:
trainable% = 0.8021. For LoRA targeting the standard projection layers (attention + MLP) on a 4B model, expecttrainable%somewhere between 0.5–2%. If it sits far below that, ourLORA_TARGET_MODULESlist isn’t matching the model’s module names.- Training loss descending from ~2.9 → ~1.5. Steady decline. No spikes.
- The
eval_lossand the traininglossare moving in the same direction and their gap is small. If they diverge, the model is overfitting. We either drop the number of epochs so that we don’t “overfeed” the data for the model to memorize, or we add more data so the model cannot memorize. We can do either or both.
Step 6 — Eval
Train loss going down tells us the optimizer’s working. It does not tell us whether we’ve built the right thing.
| |
Now we judge the trained model’s responses to held-out val prompts. The script loads base + adapter via peft, generates a response per val prompt and then sends each prompt-response pair to Haiku.
The generation loop is pretty boring:
| |
One prompt at a time, sampled with the same SYSTEM string the model was trained with.
Sample output:
| |
What “good” looks like depends on the character. For reference: Monty hit 76.0% passes_all on the 604-prompt val set above. The data ceiling from Part 1 was 82.6% — the gap between data ceiling and model eval is the bit fine-tuning didn’t capture, usually shape/form like sentence length, format and pacing rather than vocabulary.
The per-axis breakdown is where the fixes live. Each pattern points at a different fix:
| Observation | Fix |
|---|---|
takes_stance 95% + on_persona 65% | Opinionated but doesn’t sound like the character. Add MLP targets if not already there, or tighten the persona prompt. |
is_helpful 85% + factual_floor 70% | Confidently wrong about facts. Upgrade the base model or hand-correct domain-specific failures. |
| Everything in the 60s | Persona prompt is anaemic. Go back to the persona prompt. |
passes_all < 40% | The data ceiling itself is probably too low. Re-run Part 1 with a stronger persona prompt. |
Step 7 — Iterate
Nobody nails the model on the first run. The iteration loop:
- Pull the failures out of
runs/eval_reports/model_eval_*.jsonl. - Categorise them. Usually one or two failure modes account for 60-70% of misses.
- Hand-write the responses the model should have given. Append to the training data jsonl.
- Re-run
prep filter,prep score-and-split,run train.
30-50 handcrafted examples is enough to meaningfully move the needle on a 1K-row corpus. They’re disproportionately effective and each one closes a specific failure mode.
jq makes the slicing trivial. To pull just the factual errors:
| |
| |
Then we sit down for an hour, write 30-50 in-character responses to those failed prompts and append them to data/interim/handcrafted.pairs.jsonl. The next training round trains on those alongside the original corpus. They pay off disproportionately because they directly cancel specific failure modes the judge already identified.
This loop ate two weekends for Monty. Each round taught me something I didn’t know I needed to know.
Step 8 — Merge, quantise, ship
| |
Pulls the base + adapter, merges into a standalone HF checkpoint at models/monty-merged/ (~8 GB). The merge step itself is three lines:
| |
From there it’s the conversion into GGUF using standard llama.cpp:
| |
Sample output:
| |
Drop the GGUF into LM Studio at ~/.lmstudio/models/<you>/<name>/, or find it on Hugging Face after we upload. Paste the SYSTEM prompt from run/train.py into LM Studio’s system prompt field.
The system prompt is half the model. If we forget the system prompt, we get the base instruct model with LoRA applied. With it, we get the character.
What I’d change if I started again
Three things.
Start with the persona prompt longer than feels comfortable. Monty’s first draft was ~150 lines and the dataset showed it. The 350-line version produces visibly better synthetic samples. The marginal cost of writing more is zero. The marginal cost of regenerating a thousand pairs because the prompt was thin is $5 and an hour.
Run a tiny eval before the full training. Score 50 rows of the raw synth against the judge before committing to a 1k-row pipeline. If the synth’s data ceiling is 60%, our model is not going to reach 80%.
Iterate with handcrafted corrections earlier. On round 2, I burned a lot of time on hyperparameter tweaks and LoRA rank changes that did practically nothing. Round 3 with 50 hand-written examples targeting specific failure modes moved the needle more than any single hyperparameter change.
The hard parts of fine-tuning have nothing to do with hyperparameters. The persona prompt does most of the work and the judge keeps us honest. The handcrafted corrections close the rest.
- Code: github.com/arunma/learn-you-an-sft.
- Adapter + GGUFs: huggingface.co/arunma/monty3.
- The story behind the recipe: Costume vs Character →.