[LLM 2/10] SFT + LoRA: Teaching a Model to Be an Assistant by Training 1.69% of Its Parameters
Last chapter we used Continue Pretraining to put knowledge into the model's weights. But a model that knows is not a model that answers — a base model has exactly one job: continuing text. This chapter teaches SFT (Supervised Fine-Tuning) with LoRA: a technique that trains only about 1.7% of the parameters yet changes the behavior of the entire model — finished in ~15 minutes on free Colab. What you get back is an adapter file of roughly 40 MB that becomes the backbone of the rest of the series.
Open in Colab02_sft_lora.ipynb
1. The Problem
Take a true base model like Qwen3-0.6B-Base from the last chapter and type in (in Thai) "Could you recommend some Thai dishes, please?" What comes back is usually not an answer but a continuation — it may compose three more questions, keep going as a travel article, or switch into English partway through, because the only thing it was ever trained on is "on the internet, what usually follows text like this?"
The ability to answer — take an instruction, respond to the point, then stop — does not come with pretraining. It comes from SFT: continued training on (instruction, good answer) pairs, thousands to millions of them. Every instruct model you have ever used has been through this stage.
But the moment you try it yourself, you hit two problems stacked on top of each other:
Layer one — the cost of full fine-tuning. Train every parameter and you get an entire new model (~1.2 GB per task for a 0.6B model). An organization with ten tasks — document summaries, letter drafting, customer replies, complaint triage — has to store ten copies. And moving every single weight with a high learning rate is a recipe for erasing the knowledge you just added in chapter 1 (remember the learning-rate box?).
Layer two — Thai. Even the post-trained Qwen3-0.6B shows the symptom we will see all series long:
ask in Thai, and the answer drifts into English mid-sentence
(this is where the series' th_ratio metric comes from), because the SFT data it saw was overwhelmingly English.
This chapter fixes both layers at once: SFT on Thai instruction data, done through LoRA instead of full fine-tuning.
2. What We're Going to Do
We take Qwen3-0.6B and train it on 4,000 Thai instruction-answer pairs, using the exact same loss as chapter 1 plus two new pieces:
- Completion mask — compute the loss only on the answer tokens, never the question (section 3.1 explains the comically broken failure you get if you skip this)
- LoRA (Low-Rank Adaptation) — freeze all the original weights and instead train two small matrices laid over each layer
You are not training the model's weights — you are training a low-rank "correction" laid on top of the original weights.
This is why the adapter is only ~40 MB, not 1.2 GB, why you can keep twenty adapters and swap them on a single base (twenty tasks = 0.8 GB, not 24 GB), and why the reference model in chapter 4 (DPO) will cost zero additional bytes of VRAM — switch the adapter off and you get the starting model back exactly.
3. The Equations
3.1 The SFT loss and the completion mask
- = one training pair — is the instruction part (chat template included) and is the token sequence of the example the model actually sees during training
- = the token at position , and = every token before it
- = the probability predicted by the model with parameters
- = the completion mask — 1 only on answer-side tokens, and 0 on prompt tokens
Remove (i.e. set everywhere) and this equation instantly becomes the CPT objective from chapter 1. SFT is CPT on text staged as a conversation, plus one mask — nothing more.
But that one mask is half the battle, because is the easiest default to fall into
(many pipelines, SFTTrainer included, will silently train this way if the collator isn't wired correctly).
And in typical Thai instruction data, prompt-side tokens make up around 60% of each example —
meaning most of your gradient is busy teaching the model to write the user's questions, not to answer them.
The side effect this causes will make its appearance in section 9.
3.2 LoRA: training the correction, not the weights
Instead of updating the weight matrix directly, LoRA pins in place and learns a difference that is the product of two small matrices:
- = the layer's original weights, frozen — they receive no gradient at all
- and = the two adapter matrices we actually train
- = the rank of the correction — LoRA's main dial (this chapter uses )
- = a scale multiplier — the product is always multiplied by (here , so )
Two details in this definition matter more than they look:
is initialized to all zeros, so at the first step — training starts from exactly the base model, with no period where random weights disturb it. ( is random Gaussian — set both to zero and both gradients stay zero forever, since each is multiplied by the other's zeros.)
The divisor in makes the update magnitude independent of the rank — double and the sum has twice as many terms, but it gets divided right back. So you can sweep without re-tuning the learning rate every time.
And when training finishes, you have two choices: merge (, giving a single model with no added latency) or keep it separate — the second is the road this series takes, because a detachable adapter is what chapter 4 uses to build a reference model for free.
3.3 The fraction of parameters trained — a number to check, not to recite
For a single matrix, the adapter has parameters, a fraction of
- = the dimensions of the original weight matrix
- = the adapter's rank
Plug in the real values for Qwen3-0.6B (hidden 1024, intermediate 3072, 28 layers, adapters on all 7 matrices: q, k, v, o, gate, up, down) and you get 10,092,544 trainable parameters on a base of 596,049,920 = 1.69%.
Nearly every LoRA article says it "trains under 1% of the parameters." That number is true at 7B scale and up, but not for small models, because adapter parameters grow as — linear in the hidden size — while base parameters grow as — quadratic. The smaller the model, the larger a fraction the adapter becomes.
Notice too that 1.69% is lower than the per-matrix fraction (~2.1–2.3%) — because the denominator includes
roughly 156 million embedding parameters we attach no adapter to. All of this can be checked with plain arithmetic,
and in section 7 the notebook has peft print the real value for you to see with your own eyes — trust the print, not a blog (this one included).
4. Seeing the Equations
One mask redirects the entire gradient
Figure 2.1Pure token accounting for a typical Thai question-answer pair (180 prompt + 120 answer tokens): without the mask, 60% of the loss terms are practice at writing the user's questions
There is nothing deeper in this figure than counting tokens — but the counting is exactly what people skip: a Thai prompt (system message and chat template included) is often longer than its answer, so training unmasked spends most of your compute teaching something you never wanted. The notebook also prints the actual ratio for the sampled dataset — the 60/40 in the figure stands in for a typical example; it is not a sacred constant.
Why rank 16 is "enough" — the low-rank hypothesis
Figure 2.2The singular value spectrum of a synthetic ΔW (1024×1024) with a rank-16 signal buried under full-rank noise — an illustration of the low-rank hypothesis, not a measurement from real fine-tuning
LoRA's hypothesis (Hu et al., 2021) is that fine-tuning moves the weights along only a few important directions relative to the full dimensionality of the matrix. This figure builds a fake with exactly that structure (a rank-16 signal + noise) and lets SVD dig it back out — if real updates look like this, a rank-16 adapter captures nearly all of the content. What the figure does not prove is that real updates always look like this. That is an empirical finding of the research, and it is why LoRA "usually" comes close to full fine-tuning — not "always."
Check the number, don't recite it
Figure 2.3Trainable-parameter fraction per rank, computed exactly from Qwen3-0.6B's real dimensions — the r=16 point sits at 1.69%, above the oft-quoted 'under 1%' line
A straight line on log-log axes confirms what the equation says: the fraction grows exactly linearly with . And on this model, "under 1%" is only true when — which is not a value anyone actually uses for language tasks. The point we use () is 1.69%, or about 40 MB stored as fp32.
5. Setting Up the Environment
Open Colab and pick Runtime → Change runtime type → T4 GPU (the free tier is enough).
The Colab T4 is Turing architecture (SM 7.5), which does not support bfloat16 and does not support FlashAttention-2.
But Qwen3-0.6B's config.json declares torch_dtype: bfloat16.
So torch_dtype="auto" is a trap — your code will crash or run bizarrely slowly without telling you why.
torch_dtype=torch.float16 # not bfloat16
attn_implementation="sdpa" # not flash_attention_2
fp16=True # in SFTConfig (not bf16=True)
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True (counts emulation!)
is_bf16_supported() lies on a T4Recent torch returns True on a T4 because it counts emulation as support — which is far slower than fp16.
Gate on compute capability ≥ 8.0 (Ampere and up) instead. This was a real bug, caught only by running the notebook on Colab.
In chapter 1 we had to model.float() the entire model before training, because we were training every parameter.
Here the base is frozen — no gradients — so it can happily stay in fp16 and save half the VRAM.
What must be fp32 is only the trainable parameters, i.e. the roughly 10 million adapter parameters:
for p in model.parameters():
if p.requires_grad:
p.data = p.data.float() # cast the adapter only — not the whole model
Forget this and you get ValueError: Attempting to unscale FP16 gradients. —
the exact same error as chapter 1, except this time the fix is far cheaper: cast 10 million parameters, not 596 million.
The VRAM budget LoRA transforms
Remember how in chapter 1 Adam's optimizer state took more room than the model itself (about 4.4 GB for and )?
Training only the adapter, Adam keeps state for just 10.1 million parameters — about 0.08 GB —
to the point that adamw_bnb_8bit is no longer needed. The big remaining budget items are the base weights (fp16, ~1.2 GB) and activations.
Try switching between full fine-tuning and various LoRA ranks in the calculator and watch where the budget moves:
- Weights1.13 GiB
- Gradients19.25 MiB
- Optimizer state115.50 MiB
- Activations170.00 MiB
- KV cache—
It fits.This run needs 1.43 GiB and leaves 14.57 GiB of headroom on a free Colab T4.
6. Preparing the Data
We use airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k —
120,000 synthetic Thai instruction-answer pairs from the WangchanX team, a Thai open-source NLP research effort (we sample 4,000).
from datasets import load_dataset
ds = load_dataset("airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k", split="train")
ds = ds.shuffle(seed=42).select(range(4000))
def to_text(ex):
messages = [
{"role": "user", "content": ex["instruction"]}, # always check column names against the dataset card
{"role": "assistant", "content": ex["output"]},
]
return {"text": tok.apply_chat_template(messages, tokenize=False,
enable_thinking=False)}
train_ds = ds.map(to_text, remove_columns=ds.column_names)
Two lines worth reading slowly:
apply_chat_templateassembles the messages into the format Qwen3 was trained on (<|im_start|>user…<|im_end|>…). We call it once, here and nowhere else — the reason lives in trap 2 of section 9.enable_thinking=Falseturns off Qwen3's thinking mode, keeping this chapter's examples simple and the mask straightforward.
Then perform the ritual that should become a habit: always decode the first example and look at it with your own eyes.
print(train_ds[0]["text"][:400])
# you must see <|im_start|>user ... <|im_end|> ... <|im_start|>assistant ... exactly once each per turn
The notebook also prints token-length statistics for the sampled set: the median on the prompt side versus the answer side, and the fraction of examples longer than 768 tokens — that last number will come back to collect its debt in trap 3.
7. The Main Code
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer, SFTConfig, DataCollatorForCompletionOnlyLM
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B", # the post-trained model — this chapter fixes behavior, not knowledge
torch_dtype=torch.float16, # the T4 has no bf16
attn_implementation="sdpa", # the T4 has no FlashAttention-2
).cuda()
lora = LoraConfig(
r=16,
lora_alpha=32, # α/r = 2 — see equation 3.2
lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora)
model.print_trainable_parameters() # trust this line, not the number in any blog
for p in model.parameters(): # the fp16 box from section 5
if p.requires_grad:
p.data = p.data.float()
peft computes its percentage by dividing by the parameter count including the adapter, while our 1.69% divides by the pure base count.
Slightly different definitions, slightly different numbers — and that is precisely the point of section 3.3:
with numbers like this you must know where the numerator and denominator come from, not memorize a free-floating figure and cite it onward.
Next comes the piece that turns equation 3.1 into code — the collator that plays the role of :
collator = DataCollatorForCompletionOnlyLM(
response_template="<|im_start|>assistant\n", # everything before this = prompt
tokenizer=tok,
)
This collator sets the label of every token before <|im_start|>assistant to -100,
the value PyTorch's loss function skips — the same value chapter 4 uses to mask out the prompt side
in its seq_logp function. Same mask, showing up in a different chapter.
cfg = SFTConfig(
output_dir="sft-out",
dataset_text_field="text",
max_seq_length=768, # the notebook prints how many examples get truncated — trap 3
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # effective batch = 16
num_train_epochs=1,
learning_rate=2e-4, # fine for LoRA — catastrophic for full FT (chapter 1)
lr_scheduler_type="cosine",
warmup_ratio=0.03,
packing=False, # the completion mask can't be used with packing directly
fp16=True, # T4: not bf16
logging_steps=10,
)
trainer = SFTTrainer(
model=model,
args=cfg,
train_dataset=train_ds,
data_collator=collator,
processing_class=tok,
)
trainer.train() # ~15 minutes on a T4 (measured: 14.8)
This same learning rate, used to train every parameter, erases the model's capabilities within a few hundred steps. With LoRA it is safe because (1) the 596 million original weights are frozen — the knowledge in the base cannot be overwritten, and (2) starts at zero — the model at step one is exactly the base, then gradually walks away from it. The worst thing LoRA can do is a bad adapter, which you can throw away at any time.
Training done, save only the correction:
model.save_pretrained("qwen3-0.6b-th-sft-lora") # ~40 MB — not 1.2 GB
This is the very adapter chapter 4 loads under the name kobkrit/qwen3-0.6b-th-sft-lora,
serving as both the starting policy and (with the adapter switched off) the reference model — a cross-chapter gift priced at 40 MB.
8. Results
The notebook measures 3 things before and after training and writes them to results.json:
- TH-INSTR — the fraction of answers passing an instruction-following rubric (answers the question, stops on its own, stays in role) from the KobEval-TH benchmark, with a Wilson 95% CI
th_ratio— the fraction of Thai characters in the answers, the series' standing metric. The stock Qwen3-0.6B is notorious for drifting into English on Thai prompts; SFT on 4,000 all-Thai examples should move this number visibly — and if it doesn't, that is your signal to go hunt through the mask and the template before anything else.- The fraction of answers ending with eos within the token budget — the detector for the "won't stop" symptom from traps 1 and 3.
A benchmark of a hundred-odd questions gives a CI roughly ±10 points wide, so a number without a CI is still not an experimental result.
The table in section 9 leaves ? wherever a real number from the notebook belongs — we don't guess results in advance in this blog.
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
9. Comparison
The notebook trains 3 variants on the same data, measured against the starting model:
| Model | TH-INSTR (95% CI) | th_ratio | PPL | Trained params | Train time |
|---|---|---|---|---|---|
| Qwen3-0.6B (base) | 73.3% (55.6–85.8) | 0.93 | 21.3 | — | — |
| LoRA r=16 + completion mask | 83.3% (66.4–92.7) | 0.97 | 17.7 | 10.1M (1.69%) | 13.2 min |
results.json the notebook writes.
SFT improves on every axis: TH-INSTR +10 points (73.3% → 83.3%), th_ratio up
(more answers stay in Thai instead of drifting to English), and perplexity down from
21.3 to 17.7 — training only 1.69% of the parameters for a ~40 MB adapter.
73.3% vs 83.3% looks clear, but the Wilson intervals are 55.6–85.8 and 66.4–92.7 — they
still overlap. At n=30 this is not yet statistically significant. The stronger evidence
is th_ratio and PPL, computed over thousands of tokens. Making TH-INSTR conclusive would
need hundreds of items (part 9).
On full fine-tuning and the no-mask row: the notebook does train a no-mask LoRA to show the behaviour where the model finishes its answer and then invents the user's next question (samples in §8), but it does not score full metrics for it, and it does not run a 100%-parameter full fine-tune — that exceeds the free-Colab budget. We report only what was measured and do not fill in numbers for runs we did not do.
10. Summary
- SFT is CPT on conversations + a completion mask — the same loss as chapter 1; what changes is that the data is staged, and the mask chooses which tokens count
- The mask is half the battle — sends 60% of the gradient into practicing questions, and yields a model that answers, then writes the next question itself
- LoRA trains a "correction," not the weights: with starting at zero, so training departs from exactly the base — and lets you change rank without re-tuning the lr
- 1.69% is not "under 1%" — adapters grow linearly with hidden size while the base grows quadratically, so the smaller the model, the bigger the adapter's share; always check with
print_trainable_parameters() - ~40 MB of adapter per task: one base plus many adapters — and it is the reason chapter 4's reference model comes free
- lr 2e-4 is safe because the base is frozen — the same value destroys the model when training everything
- pad ≠ eos, template once, never truncate mid-answer — three traps whose symptoms surface at inference but whose causes live in the data
SFT teaches "format and style" far more than "knowledge" — 4,000 examples will not add new facts to the model. Whatever it didn't know before, it still won't know after; it will simply be wrong in a prettier format and a more confident tone, which is more dangerous than before — putting knowledge in was chapter 1's job (CPT), not this chapter's.
And as with every chapter: 4,000 examples are a demonstration of the mechanism. Production-grade SFT uses tens of thousands to millions of pairs run through several more layers of quality curation. What you take from this chapter is an understanding of which dial does what and how things break, which transfers to real scale. But don't cite these results as evidence you got a better Thai model.
Next chapter: RLHF and PPO — the model can answer now, but "answers well" cannot be written as a loss function. We will have humans compare answers, train a reward model from those preferences, then use reinforcement learning to push the model toward it — with this chapter's 40 MB adapter as the starting point.
References
- Hu et al. (2021). LoRA: Low-Rank Adaptation of Large Language Models — the source of the W' = W₀ + (α/r)BA equation in section 3
- Aghajanyan et al. (2020). Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning — evidence that fine-tuning has low intrinsic dimension -- why LoRA works at all
- Dettmers et al. (2023). QLoRA: Efficient Finetuning of Quantized LLMs — extends LoRA with 4-bit so larger models fit one GPU
- Biderman et al. (2024). LoRA Learns Less and Forgets Less — LoRA trades less learning for less forgetting -- read alongside section 9
- Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT: the origin of the whole SFT -> RM -> PPO pipeline
- Wang et al. (2022). Self-Instruct: Aligning Language Models with Self-Generated Instructions — generating instruction data from the model itself
- Zhou et al. (2023). LIMA: Less Is More for Alignment — a few thousand high-quality examples suffice -- why we use only 4,000
The writing, code and notebooks in this series are licensed under CC BY-NC-SA 4.0 — reuse and adapt them freely with attribution, for non-commercial purposes, and share your adaptations under the same terms. Third-party models and datasets referenced here keep their own licences.
