Zucchetti LLM Course — Lecture 9
LoRA • QLoRA • From Theory to Production
Teaching models new behaviors — without breaking the bank
Kamyar Zeinalipour
2 hours full theory • 10 min break
S0 — Understanding Fine-Tuning — Where We Are & What It Means
S1 — When to Fine-Tune — Decision Flowchart & Real-World Cases
S2 — LoRA & QLoRA — The Core Mechanics + Worked Example
S3 — The Training Kitchen — Practical Tricks + Evaluation
S4 — From Data to Production — Case Study & Common Mistakes
☕ Break — 10 minutes
S5 — Beyond SFT — DPO & Alignment
💻 S6 — SFT in Practice — Code Walkthrough: Normal SFT vs Thinking SFT
Section 0
Where we are, what it means, and how training works
Eight lectures of working AROUND the model — today we work ON it
L1 — Transformer Architecture — how the model works inside
L2–L3 — Tokenization, Embeddings, Prompting — talk to the model
L4–L6 — RAG, Advanced RAG, Graph RAG — give it external knowledge
L7 — Agentic RAG — give it tools
L8 — Evaluation — measure everything
L9 — Fine-Tuning — change the model itself ⬅ TODAY
Two fundamentally different strategies for improving an LLM
Prompting — changes the INPUT
RAG — adds knowledge AT RUNTIME
Agents — adds tools AT RUNTIME
The model weights never change. Every improvement happens outside.
Fine-tuning — changes the MODEL WEIGHTS
Behavior becomes permanent
No retrieval needed, no prompt tricks
The model itself is different after training.
Continue training a pre-trained model on YOUR data
Pre-training — the expensive part. Done by Meta, Alibaba, Google. Costs millions of dollars. Teaches language, facts, reasoning. You don’t do this.
Fine-tuning (SFT) — the cheap part. Done by YOU. Costs minutes to hours. Teaches your specific task, format, and style.
Update ALL weights
Maximum quality, maximum cost
Update 0.25% of weights
Today’s main focus
How a model learns from data — the engine behind all fine-tuning
Same next-token prediction from Lecture 1. The model sees your training example and tries to predict each token. The loss measures how far off it was. Gradients tell the optimizer which weights to adjust.
Do this for every example in your dataset. One full pass = 1 epoch. Typically 1–3 epochs for fine-tuning. After each batch, the model gets slightly better at your task.
Update ALL parameters — simple, effective, but expensive
Every single weight in the model — all 494 million in Qwen2.5-0.5B, or all 7 billion in Llama-3 — gets updated via gradient descent. Nothing is frozen.
from transformers import AutoModelForCausalLM
from trl import SFTTrainer, SFTConfig
# Load model — ALL parameters are trainable
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
# Train — updates ALL 494M parameters
trainer = SFTTrainer(model=model, train_dataset=data,
args=SFTConfig(output_dir="./output", num_train_epochs=2))
trainer.train() # Every weight changes
✅
Highest possible quality
Maximum flexibility
Well-understood technique
⚠
Needs 40–80+ GB VRAM
Risk of catastrophic forgetting
Creates a full model copy per task
Section 1
Decision framework: prompting vs RAG vs fine-tuning
In L4 we learned when to fine-tune — now we learn how
| Criterion | Prompting | RAG | Fine-Tuning |
|---|---|---|---|
| Setup cost | Minutes | Hours | Hours |
| New knowledge | No | Yes | Limited |
| Behavior change | Some | No | Yes |
| Output style | Fragile | Inherited | Consistent |
| Latency | Fast | Slower | Fast |
Not teaching knowledge — teaching behavior
“Fine-tuning teaches the model new facts”
Want to add company knowledge? Use RAG.
“Fine-tuning teaches the model a new accent”
Output format, tone, domain vocabulary, task behavior
Before fine-tuning: “This seems urgent because the patient mentioned chest pain…”
After fine-tuning: {"priority": "P1", "category": "CARDIAC", "escalate": true}
Same knowledge, completely different behavior and output format.
When to fine-tune, RAG, or prompt — a practical guide
🤔 Do you need the model to know NEW FACTS?
YES
Use RAG
Vector DB + retrieval.
Model cites your documents.
NO
↓
🎯 Do you need CONSISTENT behavior, format, or style?
YES
Fine-Tune
LoRA/QLoRA on 500+ examples.
Bakes behavior into the model.
NO / One-off
Prompt Engineering
System message + few-shot.
Fast, flexible, no training.
Different domains, different scales — same idea: teach the model new behavior
🏥
Task: Triage patient tickets into P1–P5 with structured JSON
Data: 800 labeled tickets
Before: 65% format compliance
After: 98% compliance
Efficient fine-tuning, 2 epochs, 25 min
💻
Task: Generate code reviews in company style
Data: 2,000 past reviews
Before: Generic feedback
After: Team-specific style
Efficient fine-tuning, 3 epochs, 45 min
🌍
Task: Customer service in IT/DE/FR
Data: 1,500 conversations per language
Before: English-centric responses
After: Native-quality replies
Separate adapter per language (~5 MB each)
Some tasks completely change what the model does — here, updating ALL weights is worth the cost:
It works — but at what cost?
💰
7B model = 40–80+ GB VRAM
70B = multiple A100s
~$15,000/GPU
⏰
Hours of GPU time
for meaningful training
vs. minutes with LoRA
🧠
Overwrite useful
pre-trained knowledge
Model “forgets” how to chat
7B params × 2 bytes (fp16) = 14 GB just for weights. Add gradients (14 GB) + Adam optimizer states (56 GB in fp32: momentum + variance) = 84+ GB for full SFT. With mixed precision tricks: ~28–40 GB. LoRA only stores gradients for the tiny adapters.
Section 2
The low-rank trick that changed everything
Most weight updates during fine-tuning live in a low-dimensional subspace
A weight matrix $W$ has dimensions $d \times k$ (e.g., 4096 × 4096 = 16.7M params).
But the useful update $\Delta W$ has much lower rank — rank 8–32 captures most of the signal.
$A$ is initialized with random Gaussian, $B$ is initialized with zeros. So at the start: $\Delta W = B \cdot A = 0$. The model starts exactly as the base model and gradually learns the adaptation.
$d \times r$ (4096 × 8)
= 32,768 params
$r \times k$ (8 × 4096)
= 32,768 params
Hu et al. (2022) — “LoRA: Low-Rank Adaptation of Large Language Models” — ICLR 2022
Why does low-rank work? A visual explanation.
The base model’s weight matrix is a detailed map of the world. Fine-tuning for medical text? You don’t redraw the entire map — you just add a small overlay for the hospital district.
That overlay is low-rank — it only changes a few “directions” in weight space.
| Metric | Full SFT | LoRA (r=8) |
|---|---|---|
| Trainable | 494M (100%) | 1.18M (0.24%) |
| Storage | ~988 MB | ~4.5 MB |
Let’s trace through one attention layer step by step
Model: Qwen2.5-0.5B • Hidden dimension: $d = 896$ • LoRA rank: $r = 16$
| Component | Shape | Parameters | Storage |
|---|---|---|---|
| $W_{q}$ (frozen) | 896 × 896 | 802,816 | ❄ not trained |
| $A$ (down-project) | 896 × 16 | 14,336 | ~57 KB |
| $B$ (up-project) | 16 × 896 | 14,336 | ~57 KB |
| Total adapter | — | 28,672 | ~114 KB |
Which layers get adapters? From L1
🔥 q_proj — query projection LoRA
🔥 k_proj — key projection LoRA
🔥 v_proj — value projection LoRA
🔥 o_proj — output projection LoRA
All 4 attention projections — this is the minimum recommended set.
❄ gate_proj — gating Optional
❄ up_proj — expansion Optional
❄ down_proj — compression Optional
Adding MLP layers increases params but can help for complex tasks.
Start with attention only (q, k, v, o). If quality isn’t sufficient, add MLP layers. The original LoRA paper only used q and v — modern practice targets all attention.
Freeze everything, train tiny adapters
❄ Path 1: Frozen W × x — never changes
🔥 Path 2: α/r × B × A × x — trainable adapter
Output = sum of both paths
| Parameter | Controls | Typical |
|---|---|---|
r (rank) | Adapter size | 8, 16, 32 |
lora_alpha | Scaling factor | $2 \times r$ or $r$ |
target_modules | Which layers | q, k, v, o |
Practical guidelines — don’t overthink it
If $\alpha = 16$ and $r = 8$: scaling = $\frac{16}{8} = 2.0$. If $\alpha = r$: scaling = $1.0$.
Higher scaling = adapter has more influence. Lower scaling = gentler adaptation.
| Scenario | Rank (r) | Alpha | Target Modules | Example |
|---|---|---|---|---|
| Light style change | 8 | 16 | q_proj, v_proj | Always reply in Italian |
| Domain adaptation | 16–32 | 32–64 | All attention + MLP | Medical ticket classifier |
| Complex behavior | 64+ | 128+ | All linear layers | Code generation specialist |
4-bit quantization + LoRA = train a 7B model in 6 GB
Quantize the frozen base model to 4-bit. LoRA adapters stay in float16. During training: dequantize → forward → backward on adapters only.
fp16 (16-bit): $\frac{7 \times 10^9 \times 16}{8 \times 10^9} = $ 14 GB • NF4 (4-bit): $\frac{7 \times 10^9 \times 4}{8 \times 10^9} = $ 3.5 GB
The LoRA adapters (~50 MB) run at full float16 precision on top of the 4-bit base.
Dettmers et al. (2023) — “QLoRA: Efficient Finetuning of Quantized LLMs” — NeurIPS 2023
Choosing the right method for your constraints
| Method | VRAM (7B) | Training Time | Quality | Cost |
|---|---|---|---|---|
| Full SFT | 40–80+ GB | Hours | Highest | $$$ |
| LoRA | 12–16 GB | 30–60 min | High | $$ |
| QLoRA | 6–8 GB | 45–90 min | Good–High | $ |
Hand-written CUDA kernels: 2× faster training, 60% less memory. Fully compatible with HuggingFace. Free and open-source.
From theory to practice — it’s really this simple
from peft import LoraConfig, get_peft_model
# 1. Define the adapter configuration
config = LoraConfig(
r=16, # Rank
lora_alpha=32, # Scaling (alpha/r = 2.0)
target_modules=["q_proj", "k_proj", # Which layers
"v_proj", "o_proj"],
lora_dropout=0.05, # Regularization
task_type="CAUSAL_LM", # Language model
)
# 2. Wrap the frozen model with LoRA adapters
model = get_peft_model(base_model, config)
# 3. Check: how many parameters are trainable?
model.print_trainable_parameters()
# trainable params: 1,572,864 || all params: 495,606,784 || 0.32%
get_peft_model() call freezes all original weights and injects the tiny LoRA matrices. We’ll see the full training loop in Section 6.Same model, same question — dramatically different behavior
“This is a complex medical case that requires careful analysis. The patient’s symptoms could indicate several conditions including myocardial infarction, angina pectoris, or possibly a pulmonary embolism. It would be important to consider the full clinical picture and conduct further diagnostic tests such as blood work and imaging studies…”
⚠ Vague, hedging, no structured output, verbose
“Diagnosis: Acute ST-elevation myocardial infarction (STEMI).
Urgency: P1 — Immediate.
Key findings: Crushing chest pain + left arm radiation + ST-elevation V1–V4.
Action: Activate cath lab, administer aspirin 325mg.”
✅ Structured, decisive, actionable, domain-specific format
Section 3
Practical tricks that make or break your fine-tune
Simulate large batches on small GPUs
Your GPU fits only batch_size=2, but stable training needs an effective batch of 16.
Run 8 forward+backward passes, accumulate the gradients, then do one optimizer step.
Example: 2 × 8 × 1 = 16 effective batch size
TrainingArguments(
per_device_train_batch_size=2, # What fits in GPU memory
gradient_accumulation_steps=8, # Accumulate 8 micro-batches
# → effective_batch_size = 2 × 8 = 16
)
batch_size=1 with high accumulation — it can degrade quality.Stop wasting tokens on padding
[seq1····][PAD][PAD][PAD][PAD]
[seq2··][PAD][PAD][PAD][PAD][PAD]
[seq3·······][PAD][PAD][PAD]
~60% of tokens are padding = wasted compute
[seq1][EOS][seq2][EOS][seq3·]
[seq4··][EOS][seq5][EOS][seq6]
[seq7····][EOS][seq8][PAD]
Sequences concatenated, separated by EOS
SFTTrainer(
...,
packing=True, # Enable sequence packing
max_seq_length=2048, # Pack into 2048-token blocks
)
The format must match between training and inference
<|im_start|>system
You are a medical assistant.<|im_end|>
<|im_start|>user
What is the dosage for amoxicillin?<|im_end|>
<|im_start|>assistant
The standard dosage is 500mg every 8 hours.<|im_end|>
Train with ChatML, infer with Llama format → model outputs gibberish
# Always use the tokenizer's
# built-in template:
tokenizer.apply_chat_template(
messages, tokenize=True
)
tokenizer.apply_chat_template() — it reads the model’s native format automatically.Train on answers only — don’t waste capacity predicting prompts
Without masking, the model tries to predict every token — including the prompt it was given as input. That’s like practicing for a test by memorizing the questions instead of the answers.
[system] You are a medical assistant [user] What is amoxicillin?
Labels: [-100] [-100] [-100] ... [-100] ← ignored
[assistant] Amoxicillin is a penicillin-type antibiotic used to...
Labels: [token_ids...] ← loss computed here only
from trl import DataCollatorForCompletionOnlyLM
collator = DataCollatorForCompletionOnlyLM(
response_template="<|im_start|>assistant\n", # Mask everything before this
tokenizer=tokenizer,
)
One line of code, measurable improvement
During training, add random noise to the embedding vectors. This prevents the model from memorizing exact training examples. Like adding a slight blur to training images in computer vision.
Small datasets (<1000 examples)
Signs of overfitting
Instruction tuning tasks
Start with alpha=5
Sweep: {3, 5, 10}
No other config changes needed
SFTTrainer(..., neftune_noise_alpha=5) # That's literally it!
Jain et al. (2024) — “NEFTune: Noisy Embeddings Improve Instruction Finetuning” — ICLR 2024
Two memory-saving techniques every fine-tuner should know
What: Optimized tiled GPU computation for the attention mechanism (from L1)
Effect: 2–4× faster attention, 20–40% less end-to-end memory
When: Always enable if hardware supports it
model = AutoModelForCausalLM
.from_pretrained(
model_id,
attn_implementation=
"flash_attention_2"
)
What: Discard activations during forward pass, recompute during backward
Effect: ~30% less memory, ~20% slower
When: Last resort for OOM errors
TrainingArguments(
...,
gradient_checkpointing=True,
)
Your reference card — bookmark this slide
| Setting | Recommendation | Why |
|---|---|---|
| Learning rate | 2e-4 | Standard for LoRA/QLoRA |
| Optimizer | adamw_torch or paged_adamw_8bit | Paged = less VRAM |
| Epochs | 1–3 | More = overfitting risk |
| Warmup | 5–10% of steps | Stabilizes early training |
| Packing | ✅ Enable | Up to 2–3× speed boost |
| Loss masking | ✅ Completion-only | Focus on answers |
| NEFTune | ✅ alpha=5 | Free regularization |
| Flash Attention | ✅ If supported | Free speed |
| Grad. Checkpoint | ⚠️ If OOM | Memory vs speed trade-off |
Small dataset + too many epochs = memorization, not learning
The same evaluation framework from L8 — applied to fine-tuning
| Check | What You Measure | How |
|---|---|---|
| 1. Task Quality | Does the model do the task correctly? | Golden dataset + task-specific metrics (accuracy, F1) |
| 2. Format Compliance | Does it output the right format? | Parse outputs, check JSON validity, schema match |
| 3. Regression | Did we break general abilities? | Run base model benchmarks (MMLU, HellaSwag) before/after |
Watch during training:
Training loss (should decrease)
Eval loss (should NOT increase)
If eval loss rises = overfitting
Run after training:
Hold-out test set (20% of data)
Before/after comparison table
LLM-as-Judge (from L8)
Choose your tool — all produce compatible LoRA adapters
| Framework | Speed | Memory | Ease of Use | Best For |
|---|---|---|---|---|
| HuggingFace PEFT + TRL | Baseline | Baseline | Excellent | Flexibility, research |
| Unsloth | 2× faster | 60% less | Easy | Production speed |
| LlamaFactory | Good | Good | YAML config | Beginners, no-code |
| Axolotl | Fast | Good | Config-heavy | Advanced multi-task |
All frameworks produce standard PEFT adapters. Train with Unsloth → deploy with HuggingFace. Train with LlamaFactory → merge with PEFT. The adapter format is universal.
Section 4
Datasets, merging, and deployment
Two main formats — pick one and be consistent
{
"instruction": "Classify this
medical ticket",
"input": "Patient reports severe
chest pain after exercise",
"output": "Priority: P1\n
Category: CARDIAC\n
Action: IMMEDIATE_ESCALATION"
}
{
"messages": [
{"role": "system",
"content": "You classify
medical tickets."},
{"role": "user",
"content": "Chest pain..."},
{"role": "assistant",
"content": "{\"priority\":\"P1\"}"}
]
}
Less than you think — if it’s high quality
| Dataset Size | What You Can Achieve | Zucchetti Example |
|---|---|---|
| 50–100 | Style/format changes | Always output structured JSON |
| 500–2,000 | Domain-specific tasks | Medical ticket classification |
| 2,000–5,000 | Complex behavior | Multi-step clinical extraction |
| 5,000+ | Approaching full SFT quality | General medical assistant |
From trained adapter to production model
Permanently bake LoRA weights into the base model.
merged = model.merge_and_unload()
merged.save_pretrained("./merged")
Simpler deployment, no adapter overhead
One base model, hot-swap LoRA adapters per request.
vLLM and LoRAX support this natively.
Multi-tenant: medical adapter, legal adapter, HR adapter
When merging multiple LoRA adapters into one model: TIES trims redundant params and resolves sign conflicts. DARE randomly drops 90%+ of params and rescales. Both reduce interference between task-specific adapters.
The complete path from training to production
| Export Format | Use Case | Tools |
|---|---|---|
| GGUF | Local deployment (laptops, edge devices) | Ollama, llama.cpp |
| Safetensors | Cloud deployment, sharing on HuggingFace | transformers, vLLM |
| AWQ / GPTQ | GPU-optimized quantized serving | vLLM, TGI |
From zero to production — realistic timeline
| Metric | Prompting Only | After LoRA |
|---|---|---|
| Format compliance (valid JSON) | 65% | 98% |
| Priority accuracy | 72% | 94% |
| Latency (per ticket) | 1.2s (RAG lookup) | 0.3s (no retrieval) |
| Monthly cost (1K tickets/day) | ~€180 | ~€45 |
Learn from others’ failures
| # | Mistake | What Happens | Fix |
|---|---|---|---|
| 1 | Wrong chat template | Model outputs gibberish at inference | Always use tokenizer.apply_chat_template() |
| 2 | Not enough data diversity | Model only handles training patterns | Include edge cases, negatives, refusals |
| 3 | Training too many epochs | Overfitting — memorizes training data | Start with 1–2 epochs, watch eval loss |
| 4 | Skipping evaluation | Ship a broken model to production | Always hold out 20% for testing |
| 5 | Contradictory training data | Model gives inconsistent answers | Audit for conflicts before training |
10 minutes
When we come back: DPO, alignment, and a full code walkthrough.
Section 5
The modern post-training pipeline
How modern open-source models are built
Teach what to do: follow instructions, output JSON, classify tickets, speak in medical terms.
This is what we learned today.
Teach how to choose: prefer helpful over harmful, concise over verbose, accurate over creative.
Refines quality after SFT.
Alignment without a reward model
| Aspect | RLHF (from L1) | DPO (modern) |
|---|---|---|
| Reward model | Required (expensive) | Not needed |
| Training data | Rankings + reward model | Preference pairs (chosen vs rejected) |
| Complexity | PPO algorithm, unstable | Simple loss function |
| Quality | Excellent | Nearly as good |
{
"prompt": "Explain LoRA in one sentence.",
"chosen": "LoRA freezes the base model and trains small adapter matrices to
efficiently adapt behavior.",
"rejected": "LoRA is a type of AI thing that makes models better somehow."
}
Rafailov et al. (2023) — “Direct Preference Optimization: Your Language Model is Secretly a Reward Model” — NeurIPS 2023
Section 6
Code walkthrough — Normal SFT vs Thinking SFT
Everything you need — model + LoRA + data + trainer
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset
# 1. Load model + tokenizer
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
# 2. Apply LoRA
lora_config = LoraConfig(r=16, lora_alpha=32,
target_modules=["q_proj","k_proj","v_proj","o_proj"],
task_type="CAUSAL_LM")
model = get_peft_model(model, lora_config)
# 3. Load dataset (we'll see the format next)
data = load_dataset("json", data_files="train.jsonl", split="train")
# 4. Train!
trainer = SFTTrainer(model=model, tokenizer=tokenizer,
train_dataset=data,
args=SFTConfig(output_dir="./output", num_train_epochs=2,
per_device_train_batch_size=4, learning_rate=2e-4,
logging_steps=10, save_steps=100))
trainer.train() # That's it!
Teach the model to follow instructions → give direct answers
{
"messages": [
{"role": "system",
"content": "You are a medical
triage assistant. Output JSON."},
{"role": "user",
"content": "Patient: chest pain,
shortness of breath"},
{"role": "assistant",
"content": "{\"priority\":\"P1\",
\"category\":\"CARDIAC\",
\"action\":\"ESCALATE\"}"}
]
}
Input: system + user message
Output: direct answer in the trained format
Model output:
{"priority":"P1", "category":"CARDIAC", "action":"ESCALATE"}
No reasoning, no explanation — just the answer.
Teach the model to reason step-by-step before answering
{
"messages": [
{"role": "system", "content": "You are a medical assistant. Think step by step."},
{"role": "user", "content": "Patient: 45M, crushing chest pain, left arm radiation, ST-elevation V1-V4"},
{"role": "assistant", "content": "<think>\nStep 1: Crushing chest pain + left arm radiation = classic cardiac presentation.\nStep 2: ST-elevation in V1-V4 = anterior wall involvement.\nStep 3: This pattern = acute STEMI. P1 priority.\nStep 4: Immediate action: cath lab activation.\n</think>\n\n{\"diagnosis\":\"Acute STEMI\", \"priority\":\"P1\", \"action\":\"CATH_LAB\"}"}
]
}
The model learns to generate reasoning inside <think>...</think> tags, then produce the final answer. At inference, you can parse and hide the thinking.
Made famous by DeepSeek-R1 and OpenAI o1. Dramatically improves accuracy on complex tasks: math, logic, multi-step reasoning.
Same training pipeline, different dataset → different behavior
| Aspect | Normal SFT | Thinking SFT |
|---|---|---|
| Dataset format | instruction → answer | instruction → <think> → answer |
| Output behavior | Direct response | Reasoning chain + response |
| Best for | Classification, JSON, simple Q&A | Math, logic, complex decisions |
| Latency | Fast (short output) | Slower (generates reasoning first) |
| Accuracy on hard tasks | Good | Significantly better |
| Training code | Identical | Identical |
| LoRA config | Identical | Identical |
# At inference — extract only the final answer
response = model.generate(...) # Full output with <think> tags
answer = response.split("</think>")[-1].strip() # Just the answer
1. LoRA trains 0.25% of parameters with 95% of the quality of full fine-tuning
2. QLoRA makes it possible on 6 GB VRAM — a single consumer GPU (RTX 3090/4090)
3. Practical tricks matter: packing (2–3× speed), loss masking (focus on answers), NEFTune (regularization)
4. Data quality > hyperparameters. 500 curated examples beat 50,000 noisy ones
5. The modern pipeline: SFT (teach behavior) → DPO (align preferences) → Deploy
See you next week!
Thank you!
Kamyar Zeinalipour — Zucchetti LLM Course