GPT-4 is extraordinarily general.
It knows history, science, poetry, and code.
But it doesn't know your product documentation.
It doesn't speak with your brand voice.
It doesn't know the specialist vocabulary of retinal imaging.
Pre-training gives a model the world.
Fine-tuning gives it a job.
01 — What Is Fine-Tuning and Why Do We Need It?
Pre-training builds a foundation. A model trained on hundreds of billions of tokens from the internet, books, and code develops a deep general understanding of language, facts, and reasoning. But general understanding is not the same as specific usefulness. A doctor who knows everything about medicine still needs orientation training before working at a specific hospital.
Imagine hiring a highly educated graduate — top of their class, reads widely, speaks eloquently. They know an enormous amount. But they've never worked at your company specifically. They don't know your processes, your clients, your preferred way of writing emails, or the industry-specific terms your team uses every day.
Fine-tuning is their onboarding programme. You don't re-educate them from scratch — that would take years and destroy everything they already know. Instead, you expose them to examples of how your company operates, let them practise with feedback, and adjust their behaviour precisely where needed. They keep everything they learned at university; they gain what they need for the job.
Fine-tuning works because of a key insight: the knowledge encoded in a pre-trained model's weights is largely general and reusable. The layers that detect grammar, understand references, reason through logic — all of that transfers. What changes is the model's behaviour: how it formats responses, what terminology it uses, what tasks it prioritises. These behavioural changes require adjusting far fewer parameters than starting from scratch.
When Do You Actually Need Fine-Tuning?
Not always. Many tasks can be solved with good prompting. Fine-tuning is worth the investment when:
02 — Full Fine-Tuning: Change Everything
Full fine-tuning is conceptually the simplest approach: take all the model's weights, run your training data through it, compute gradients with respect to every single parameter, and update everything. No frozen layers, no approximations — every weight is a trainable variable.
Full fine-tuning is like repainting every room in a house simultaneously. Every surface gets a fresh coat. The result can be exactly the colour you want everywhere. But it's expensive, takes a long time, and if you mix the paint badly you've ruined the entire house. You also need access to every room at once — which requires a lot of space (GPU memory).
How It Works
The training procedure is identical to pre-training — the only difference is the data and the starting point:
Training data: your labelled dataset — (instruction, response) pairs
For each batch:
1. Forward pass: compute loss = CrossEntropy(model(input), target_tokens)
2. Backward pass: compute ∂loss/∂θ for ALL parameters
3. Update: θ ← θ − lr × ∂loss/∂θ (AdamW optimiser)
Result: θ_finetuned — every weight has changed from θ₀
The Memory Problem
Full fine-tuning of a 7B parameter model requires storing significantly more than just the model weights. During training you need:
A single A100 80GB GPU barely handles a 7B model. For 13B or 70B models, full fine-tuning requires multiple high-end GPUs with expensive interconnects. This is out of reach for most researchers and companies. It also risks catastrophic forgetting — when a model is trained aggressively on a narrow dataset, it can "forget" its general capabilities. A medical fine-tune trained only on radiology reports might degrade badly at writing normal English.
Full fine-tuning makes sense when: (1) you have massive compute available (multi-GPU cluster); (2) your domain is so specialised that the pre-trained weights need deep modification — not just behavioural adjustment; (3) you want to permanently change the model's "personality" or output style across all tasks simultaneously; (4) the model is small enough (<1B params) that full fine-tuning fits on a single GPU comfortably. For most practical applications — instruction following, domain adaptation, format alignment — LoRA gives 90–95% of full fine-tuning quality at 5% of the cost.
03 — LoRA: Train Almost Nothing, Get Almost Everything
LoRA (Low-Rank Adaptation — Hu et al., 2021) is the most important fine-tuning innovation in recent years. It starts from a deceptively simple observation about how neural networks change during fine-tuning, and builds an elegant solution that makes fine-tuning a 7B model possible on a single modest GPU.
The Core Insight: Weight Updates Are Low-Rank
A rock band has been playing together for ten years. They have a deep repertoire, perfect synchronisation, a signature sound. Now they hire a new bassist with a different style. Do they rebuild the entire band from scratch? Of course not. The bassist learns a small set of adjustments — plays slightly differently on certain songs, emphasises certain notes — and the band's overall sound shifts just enough.
LoRA does the same thing to a language model. Instead of retraining all 7 billion parameters, it adds a tiny set of new parameters that encode only the changes needed for the new task. The original weights stay completely frozen. Only the small "adjustment module" is trained.
The mathematical justification: research found that when you fine-tune a model, the weight update matrix ΔW has very low intrinsic rank. A full fine-tune changes a 4096×4096 weight matrix — that's 16.7 million numbers. But the actual information content of that change can be captured with far fewer numbers, because the update ΔW lies in a low-dimensional subspace.
The LoRA Math
For each weight matrix W in the attention layers (typically W_q, W_k, W_v, W_o), LoRA adds a parallel low-rank branch:
W ∈ ℝ^(d_out × d_in) e.g. 4096 × 4096 = 16.7M parameters
LoRA modification: h = W · x + (B · A) · x · α/r
A ∈ ℝ^(r × d_in) ← down-projection, r << d_in (e.g. r=16, so 16×4096 = 65k)
B ∈ ℝ^(d_out × r) ← up-projection (e.g. 4096×16 = 65k)
α = scaling factor (typically = r, so α/r = 1.0)
Parameters trained: A and B only
W is FROZEN — never updated, never needs a gradient
At initialisation: A ~ N(0, σ²), B = 0 → so BA = 0 → no change at start
This ensures the model starts from exactly the pre-trained state
│
├──────────────────────────────┐
│ │
W·x A·x (rank r, tiny)
[FROZEN] │
│ B·(Ax) (up-project)
│ │
└──────────────── + ───────────┘
│
h = W·x + BA·x ← output
Why This Works: The Numbers
For a 7B model (LLaMA-2 7B), the attention weight matrices have shape 4096 × 4096. Let's compare the trainable parameters:
| Approach | Trainable params per layer | Total trainable (32 layers) | % of model |
|---|---|---|---|
| Full fine-tune | 4096 × 4096 = 16.7M per matrix | ~7,000M (all params) | 100% |
| LoRA r=16 | 2 × (16 × 4096) = 131k per matrix | ~40M (4 matrices/layer) | 0.57% |
| LoRA r=4 | 2 × (4 × 4096) = 32k per matrix | ~10M | 0.14% |
Training 0.57% of parameters means: gradient computation is 175× cheaper. Optimiser states (the biggest memory consumer in full fine-tuning) shrink by 175×. The frozen W weights don't need gradients — PyTorch skips backpropagation through them entirely. A fine-tune that needed 80GB VRAM now needs ~16GB.
Choosing Rank r
Rank r is the most important LoRA hyperparameter. It controls the expressiveness of the adaptation — higher rank can model more complex changes but uses more memory and risks overfitting on small datasets:
| Rank r | Use case | Trainable params (7B model) |
|---|---|---|
| r = 4 | Simple style/format changes, very small datasets (<1k examples) | ~10M |
| r = 8 | Moderate domain adaptation, most instruction-tuning tasks | ~20M |
| r = 16 | Complex domain adaptation, larger datasets (10k+ examples) | ~40M — most common default |
| r = 64 | Near-full-fine-tune quality; large datasets; high compute budget | ~160M |
| r = 128+ | Approaching full fine-tune; rarely needed for behavioural changes | ~320M+ |
Which Layers to Apply LoRA To?
By default, LoRA is applied to the query (W_q) and value (W_v) attention matrices. Research shows adding key (W_k) and output (W_o) projections gives a small additional boost. The MLP (feed-forward) layers contain most of the model's factual knowledge — applying LoRA there helps with domain-specific facts but risks more forgetting of general knowledge. In practice, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"] is a reliable starting point.
04 — QLoRA: Fine-Tune a 70B Model on One GPU
LoRA solved the gradient/optimizer memory problem. But a 7B model's frozen weights still consume 14GB in float16. For 13B models that's 26GB; for 70B it's 140GB. QLoRA (Dettmers et al., 2023) adds one more trick: quantise the frozen base model to 4 bits.
You have a large wardrobe that takes up most of your bedroom. You're not going to wear most of those clothes today — they're just stored there. Vacuum-packing compresses them down to a fraction of the space. You still have all the clothes. When you need one, you unpack it, wear it, then pack it back. The wardrobe is now tiny.
QLoRA vacuum-packs the frozen model weights into 4-bit format. They're all still there, still usable for inference — just stored more compactly. The tiny LoRA adapter (the clothes you actually wear daily) stays in full precision. Result: a 70B model that would need 140GB in fp16 fits in ~35GB — a single A100 80GB GPU, or two consumer 24GB GPUs.
NF4: A Smarter Way to Use 4 Bits
Standard int4 quantisation (just rounding weights to the nearest 1 of 16 levels) introduces significant precision loss. QLoRA uses a custom NF4 (NormalFloat4) data type, designed specifically for neural network weights:
Standard int4: 16 evenly-spaced levels from -8 to +7
Problem: weights cluster near zero, wasting most of the 16 levels on rare large values
NF4: 16 levels placed at the quantiles of N(0,1)
Each level represents an equal probability mass of the weight distribution
Levels are dense near zero (where most weights live) and sparse at extremes
Result: same 4 bits, but optimal representation for the actual distribution of weights
NF4 is information-theoretically optimal for normally-distributed weights.
Double Quantisation
QLoRA adds a second trick called double quantisation: quantise the quantisation constants themselves. When you quantise a weight tensor, you need to store a scaling factor (one per block of ~64 weights) to recover the original scale. These scaling factors are stored in fp32 — and there are many of them. Double quantisation quantises these scaling factors too (from fp32 to fp8), saving an additional ~0.37 bits per parameter. Small, but it adds up across billions of parameters.
The QLoRA Pipeline
Memory Comparison: The Numbers That Matter
| Method | Model size | Training VRAM | GPU needed | Trainable params |
|---|---|---|---|---|
| Full fine-tune 7B | 14 GB (fp16) | ~80 GB | A100 80GB ×1 | 7,000M (100%) |
| LoRA r=16 7B | 14 GB (fp16) | ~18 GB | RTX 3090/4090 | ~40M (0.57%) |
| QLoRA r=16 7B | ~4 GB (4-bit) | ~10 GB | RTX 3080 / Colab T4 | ~40M (0.57%) |
| LoRA r=16 13B | 26 GB (fp16) | ~35 GB | A100 40GB | ~80M (0.6%) |
| QLoRA r=16 13B | ~7 GB (4-bit) | ~15 GB | RTX 3090/4090 | ~80M (0.6%) |
| QLoRA r=64 70B | ~35 GB (4-bit) | ~48 GB | A100 80GB ×1 | ~400M (0.57%) |
05 — Training Data: What You Feed the Model
The training algorithm matters less than the data you train on. Every fine-tuning run is ultimately a question of: what examples do I show the model, and in what format?
Instruction Fine-Tuning Format
The dominant format for chat and instruction models is instruction–response pairs. Every training example consists of an instruction (what to do) and a target response (what the ideal answer looks like). The model is trained to maximise the probability of the response given the instruction — exactly CLM, just on carefully curated pairs.
{"instruction": "Summarise this medical report in plain English.",
"input": "Patient presents with...",
"output": "The patient has a mild case of..."}
Chat / multi-turn format (Alpaca/LLaMA-2):
[INST] Summarise this report: Patient presents with... [/INST]
The patient has a mild case of... </s>
Loss computed only on the response tokens — NOT on the instruction tokens
(instruction tokens are masked during loss calculation)
How Much Data Do You Need?
| Task type | Data needed | Example |
|---|---|---|
| Style / tone alignment | 100–500 examples | Make the model respond more concisely, or in a brand voice |
| Simple instruction following | 500–2,000 examples | Always return JSON, follow a specific template |
| Domain adaptation (shallow) | 2,000–10,000 examples | Medical Q&A, legal document analysis |
| Domain adaptation (deep) | 10,000–100,000 examples | Full clinical note generation, code in proprietary language |
| Full capability alignment (RLHF) | 100k+ preference pairs | ChatGPT-level helpfulness and safety |
Quality beats quantity. 500 perfectly curated examples that consistently demonstrate the target behaviour often outperform 50,000 noisy examples. GPT-4-generated synthetic data has become a standard technique: use the large model to create training examples for the smaller one. This is how Alpaca, Vicuna, and most open instruction-following models were built.
06 — Code: LoRA and QLoRA with HuggingFace + PEFT
Complete working code — from loading a model to training a LoRA adapter to saving and merging. Uses HuggingFace transformers, peft, and trl (which provides the SFTTrainer that handles instruction masking automatically).
# ─────────────────────────────────────────────────────────────────────────
# Install: pip install transformers peft trl bitsandbytes accelerate datasets
# ─────────────────────────────────────────────────────────────────────────
import torch
from transformers import (
AutoModelForCausalLM, AutoTokenizer,
BitsAndBytesConfig, TrainingArguments
)
from peft import LoraConfig, get_peft_model, TaskType, PeftModel
from trl import SFTTrainer, DataCollatorForCompletionOnlyLM
from datasets import Dataset
# ─────────────────────────────────────────────────────────────────────────
# Step 1: Prepare training data
# ─────────────────────────────────────────────────────────────────────────
# Example: medical Q&A dataset (replace with your own data)
training_data = [
{"instruction": "What is diabetic retinopathy?",
"output": "Diabetic retinopathy is a diabetes complication that damages blood vessels in the retina..."},
{"instruction": "Describe the stages of diabetic retinopathy.",
"output": "There are four main stages: mild NPDR, moderate NPDR, severe NPDR, and PDR..."},
# ... hundreds more examples
]
def format_prompt(example):
"""Convert instruction/output dict to training string."""
return {
"text": f"### Instruction:\n{example['instruction']}\n\n### Response:\n{example['output']}"
}
dataset = Dataset.from_list(training_data).map(format_prompt)
# ─────────────────────────────────────────────────────────────────────────
# Step 2a: LoRA config (fp16 base model, ~18 GB VRAM for 7B)
# ─────────────────────────────────────────────────────────────────────────
model_id = "meta-llama/Llama-2-7b-hf"
lora_config = LoraConfig(
r=16, # rank — higher = more expressive, more params
lora_alpha=32, # scaling factor (α/r = 2.0 here)
target_modules=["q_proj", "k_proj",
"v_proj", "o_proj"], # which layers to adapt
lora_dropout=0.05, # regularisation
bias="none", # don't add bias to LoRA layers
task_type=TaskType.CAUSAL_LM,
)
tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.pad_token = tokenizer.eos_token # LLaMA has no pad token by default
# Load base model in fp16 (LoRA — no quantisation)
base_model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.float16,
device_map="auto"
)
lora_model = get_peft_model(base_model, lora_config)
lora_model.print_trainable_parameters()
# → trainable params: 39,976,960 || all params: 6,778,318,848 || trainable%: 0.5899
# ─────────────────────────────────────────────────────────────────────────
# Step 2b: QLoRA config (4-bit NF4 base, ~10 GB VRAM for 7B)
# ─────────────────────────────────────────────────────────────────────────
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NormalFloat4 — optimal for weight distributions
bnb_4bit_compute_dtype=torch.bfloat16, # dequantise to bf16 during forward pass
bnb_4bit_use_double_quant=True, # quantise the quantisation constants too
)
qlora_base = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto"
)
# Prepare model for k-bit training (gradient checkpointing + frozen BN layers)
from peft import prepare_model_for_kbit_training
qlora_base = prepare_model_for_kbit_training(qlora_base)
qlora_model = get_peft_model(qlora_base, lora_config)
# ─────────────────────────────────────────────────────────────────────────
# Step 3: Training with SFTTrainer
# ─────────────────────────────────────────────────────────────────────────
training_args = TrainingArguments(
output_dir="./qlora-medical-llama2",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # effective batch size = 4 × 4 = 16
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
fp16=True, # mixed precision training
logging_steps=10,
save_strategy="epoch",
report_to="none",
)
# SFTTrainer handles: chat template formatting, loss masking on instructions,
# gradient accumulation, and packing short examples together efficiently
trainer = SFTTrainer(
model=qlora_model,
tokenizer=tokenizer,
args=training_args,
train_dataset=dataset,
dataset_text_field="text",
max_seq_length=512,
packing=True, # pack multiple short examples into one sequence for efficiency
)
trainer.train()
trainer.model.save_pretrained("./qlora-adapter")
tokenizer.save_pretrained("./qlora-adapter")
# ─────────────────────────────────────────────────────────────────────────
# Step 4a: Inference with adapter (keep base + adapter separate)
# ─────────────────────────────────────────────────────────────────────────
model_for_inference = PeftModel.from_pretrained(qlora_base, "./qlora-adapter")
model_for_inference.eval()
prompt = "### Instruction:\nExplain macular oedema in simple terms.\n\n### Response:\n"
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
with torch.no_grad():
output = model_for_inference.generate(
**inputs, max_new_tokens=200, temperature=0.7, do_sample=True
)
print(tokenizer.decode(output[0], skip_special_tokens=True))
# ─────────────────────────────────────────────────────────────────────────
# Step 4b: Merge LoRA weights into base model (for clean single-file deployment)
# ─────────────────────────────────────────────────────────────────────────
# Load base in fp16 for merging (can't merge into 4-bit)
base_fp16 = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.float16)
merged_model = PeftModel.from_pretrained(base_fp16, "./qlora-adapter")
merged_model = merged_model.merge_and_unload() # W_new = W_frozen + B·A
merged_model.save_pretrained("./qlora-merged")
# Result: a single 7B model in fp16 with the fine-tuning baked in
trainable params: 39,976,960 || all params: 6,778,318,848 || trainable%: 0.5899
Step 10/750 | loss: 2.341 | lr: 1.87e-04 | grad_norm: 0.842
Step 20/750 | loss: 1.876 | lr: 1.98e-04 | grad_norm: 0.721
Step 50/750 | loss: 1.203 | lr: 2.00e-04 | grad_norm: 0.534
Step 100/750 | loss: 0.891 | lr: 1.94e-04 | grad_norm: 0.412
Step 500/750 | loss: 0.512 | lr: 0.71e-04 | grad_norm: 0.298
Step 750/750 | loss: 0.441 | lr: 0.00e+00 | grad_norm: 0.271
Peak VRAM usage: 9.8 GB ← 7B model, QLoRA r=16, batch=4, seq=512
Training time (3 epochs, 500 examples): ~22 min on RTX 3090 (24GB)
Merging LoRA adapter into base model...
Saved merged model to ./qlora-merged (13.5 GB, fp16)
07 — Head-to-Head: Full vs LoRA vs QLoRA
| Dimension | Full Fine-Tune | LoRA | QLoRA |
|---|---|---|---|
| Trainable params | 100% of model | 0.1 – 1% of model | 0.1 – 1% of model |
| Base model precision | fp16 / bf16 | fp16 / bf16 | 4-bit NF4 |
| VRAM for 7B model | ~80 GB | ~18 GB | ~10 GB |
| VRAM for 70B model | ~800 GB | ~140 GB | ~48 GB |
| Catastrophic forgetting risk | High — aggressive changes can overwrite general knowledge | Low — frozen base preserves general knowledge | Low — same as LoRA |
| Quality vs full fine-tune | Baseline (100%) | 90–95% on most tasks | 85–93% — small penalty from 4-bit quantisation |
| Training speed | Slowest | Faster — fewer param updates | Fastest per GPU — smallest memory footprint |
| Multiple task adapters? | Need separate full model per task | One base, many adapters — swap at inference | Same — lightweight adapters, one base |
| Deployment | Single file — clean | Base + adapter, or merge before deploy | 4-bit base + adapter, or merge into fp16 |
| Best for | Deep domain shift; large compute available; <1B models | Instruction tuning; domain adaptation; standard GPU available | Large models (7B–70B) on consumer hardware; Colab / single A100 |
08 — Common Mistakes and How to Avoid Them
DataCollatorForCompletionOnlyLM which automatically masks instruction tokens from the loss.tokenizer.apply_chat_template() with the correct model's tokenizer.tokenizer.pad_token = tokenizer.eos_token, training will crash or produce garbage. Always set this before creating the dataset.merge_and_unload() to get a single clean model with no additional inference overhead.09 — Real-World Example: Fine-Tuning for Medical Q&A
This is close to what was done in the Medical VLM project described in this portfolio — fine-tuning Qwen 2.5 7B with LoRA rank-32 on 35,000 retinal imaging Q&A pairs. The approach achieved a 240% accuracy gain (20% → 68% on diabetic retinopathy classification) while reducing VRAM from 17GB to 7GB via 4-bit QLoRA.
Base model: Qwen 2.5 7B VLM · Quantisation: 4-bit NF4 (bitsandbytes)
LoRA rank: 32 · LoRA alpha: 64 · Target: q_proj, k_proj, v_proj, o_proj
Dataset: 35,000 retinal imaging (image + report) Q&A pairs
Batch size: 8 · Gradient accumulation: 4 (effective = 32)
Learning rate: 2e-4 with cosine decay · Epochs: 3
Peak VRAM: 7 GB (vs 17 GB for fp16 base) · Training time: ~6 hours on A100 40GB
Result: diabetic retinopathy accuracy 20% → 68% · hallucination rate 18% → 5%
10 — Other PEFT Methods Worth Knowing
LoRA and QLoRA are the dominant approaches, but several other Parameter-Efficient Fine-Tuning (PEFT) methods are worth understanding:
| Method | Core idea | Best for |
|---|---|---|
| LoRA-FA | Freeze matrix A (random init), only train B. Saves more memory at slight quality cost. | Memory-constrained training on large models |
| DoRA | Decomposes weight update into magnitude and direction components. More expressive than LoRA at same rank. | Tasks needing fine-grained control; drop-in LoRA replacement |
| Prefix Tuning | Prepend trainable "virtual tokens" to each layer's key and value — no weight modification at all. | Quick task-specific adaptation without touching any weights |
| Adapter layers | Insert small bottleneck modules (down → nonlinear → up) inside transformer blocks. | Multi-task fine-tuning; each task gets its own adapters |
| IA³ | Learns scaling vectors for key, value, and FFN outputs — just 3 vectors per layer, extremely parameter-efficient. | Few-shot learning; extremely limited compute |
11 — A Thought to Sit With
LoRA's key finding is that the weight changes needed for fine-tuning are low-rank — they live in a small subspace of the full parameter space. This implies that a model's "knowledge" and its "behaviour" are to some degree separable: behaviour can be adjusted with tiny, targeted changes while knowledge remains intact in the frozen weights.
If this is true, what does it say about how knowledge is stored in a neural network? Is the knowledge truly in the original weights, and is the adapter just a "style layer" on top? Or is the line between knowledge and behaviour blurrier than it appears — and we are simply fortunate that the fine-tuning updates happen to be compressible, regardless of what they represent?
12 — Coming Up Next
Tomorrow — Day 29: RAG — Retrieval-Augmented Generation Pipeline. Fine-tuning teaches a model new habits. RAG gives it long-term memory. Instead of baking knowledge into weights, RAG retrieves relevant documents at inference time and feeds them into the context window — so the model can answer questions about things that happened after its training cutoff, or about private documents it has never seen. We build the full pipeline: document ingestion, chunking strategy, embedding, vector database, retrieval, and grounded generation — all from scratch.
13 — Go Deeper
📄 LoRA: Low-Rank Adaptation of Large Language Models — Hu et al. (2021)
The LoRA paper. Section 4 (the low-rank update formulation) is the core contribution. Table 2 comparing full fine-tune vs LoRA quality on GPT-3 is the key empirical result. Very readable.
📄 QLoRA: Efficient Finetuning of Quantized LLMs — Dettmers et al. (2023)
The QLoRA paper. Sections 2–3 cover NF4, double quantisation, and paged optimisers. Figure 1 showing the memory comparison is the clearest single illustration of why QLoRA matters.
🔗 HuggingFace PEFT Documentation
The reference implementation of LoRA, QLoRA, and all other PEFT methods. The LoRA quickstart guide is the fastest path from zero to a working fine-tune.
📄 DoRA: Weight-Decomposed Low-Rank Adaptation
The DoRA paper — improves on LoRA by separately adapting weight magnitude and direction. Used in LLaMA-3 fine-tuning workflows. Worth reading after understanding standard LoRA.
▶ Fine-tuning LLMs with PEFT and LoRA — Weights & Biases
YouTube · 45 min · End-to-end walkthrough of a real LoRA fine-tune with experiment tracking. Shows hyperparameter sweeps, evaluation, and deployment — the closest thing to a production tutorial.
- Why fine-tune: pre-training gives general language understanding; fine-tuning adjusts behaviour for specific tasks, formats, or domains. Worth doing when you need consistent output format, specialised vocabulary, lower cost at scale, or data privacy.
- Full fine-tuning: update all parameters. Maximum quality. Requires ~80GB VRAM for 7B (weights + gradients + AdamW states). Risk of catastrophic forgetting on narrow datasets. Right choice only for deep domain shifts or small models.
- LoRA: freeze all base weights W. Add trainable low-rank matrices A (r×d) and B (d×r). Only 0.1–1% of parameters are trained. Initialise B=0 so the model starts exactly at the pre-trained state. VRAM ~18GB for 7B. Quality within 5–10% of full fine-tune on most tasks.
- QLoRA: LoRA + 4-bit NF4 quantisation of the frozen base. NF4 places quantisation levels at the quantiles of N(0,1) — optimal for normally-distributed weights. Double quantisation compresses scaling factors further. VRAM ~10GB for 7B; ~48GB for 70B.
- Rank r: controls LoRA expressiveness. r=16 is the standard default. Lower (r=4–8) for small datasets or simple tasks. Higher (r=64–128) for complex adaptation with lots of data.
- Training data format: instruction–response pairs. Loss computed only on response tokens (instruction is masked). Quality beats quantity — 500 clean examples can outperform 50k noisy ones.
- Common mistakes: computing loss on instruction tokens; learning rate too high; wrong chat template; forgetting to set pad_token; not merging before deployment.
- Deployment options: keep base + adapter separate (flexible, switch adapters per request), or merge with
merge_and_unload()for a clean single-file model with no inference overhead. - Other PEFT methods: DoRA (decomposes magnitude + direction), Prefix Tuning (trainable virtual tokens), Adapter layers (bottleneck modules), IA³ (scalar scaling vectors). LoRA dominates in practice.
- Key tools: HuggingFace PEFT + bitsandbytes + TRL's SFTTrainer. These three libraries handle 95% of practical fine-tuning needs and work together seamlessly.
- What is LoRA and why does it work? Explain the low-rank assumption.
LoRA (Low-Rank Adaptation) decomposes the weight update ΔW into two small matrices: ΔW = B·A where rank r ≪ d. This is justified by the empirical finding that weight changes during fine-tuning are intrinsically low-rank — they lie in a small subspace of the full parameter space. Rather than updating all d_out × d_in weights, LoRA trains only d_out×r + r×d_in parameters (typically 0.57% of the full model). The frozen base weights preserve general pre-trained knowledge; the adapters encode task-specific adjustments. B is initialised to zero so training starts from the unmodified pre-trained state. - What is catastrophic forgetting and how does LoRA mitigate it?
Catastrophic forgetting is when learning a new task overwrites the representations that enabled performance on old tasks. Full fine-tuning is prone to this because gradient descent on narrow task data pushes all weights toward the new distribution. LoRA mitigates it structurally: the base weights W are completely frozen and never modified. All learned changes are in the small B·A adapters. At inference, the output is W·x (all pre-trained knowledge intact) plus B·A·x (task-specific adjustment). The original knowledge cannot be overwritten because it's never touched. - A company wants to fine-tune LLaMA-3 8B for 10 different customer support use cases. They have 1 A100 80GB GPU. What approach would you recommend?
Use QLoRA with a shared base model and separate LoRA adapters per use case. Workflow: (1) Load the base LLaMA-3 8B in 4-bit NF4 (~5GB). (2) Train a separate LoRA adapter (r=16, ~40M params, ~0.3GB) for each of the 10 use cases — sequential training, reusing the same base model. (3) At inference, load the 4-bit base once and hot-swap adapters depending on which use case is requested. Total storage: one 5GB base + 10 × 0.3GB adapters = ~8GB. This is far more efficient than 10 separate full models (10 × 16GB = 160GB). The A100 80GB comfortably handles the 4-bit base plus one adapter in memory simultaneously. - What is the difference between LoRA rank r and alpha? How do you set them?
Rank r controls the dimensionality of the low-rank adaptation — higher r means more trainable parameters and more expressive adaptation. Alpha (α) is a scaling factor: the LoRA output is multiplied by α/r before being added to the frozen weight output. If α = r (the default in many implementations), the scaling is 1.0 — the adapter output is added at full weight. If α = 2r, the adapter output is doubled. In practice: set r to match your dataset size and task complexity (r=16 is a reliable default). Set α = r (scaling 1.0) or α = 2r (scaling 2.0 to increase adapter influence). Avoid very high α/r ratios (>4) as they can destabilise training. The most common stable configuration is r=16, α=32 — giving scaling 2.0, which slightly emphasises the adapter updates. - When would you choose full fine-tuning over LoRA? Are there real cases where LoRA is not sufficient?
LoRA is insufficient when: (1) Deep architectural change is needed — if the model needs to learn genuinely new capabilities not representable as a low-rank perturbation of existing weights; for example, adding new language support when the base was trained on almost no examples of that language. (2) The task requires modifying many layers simultaneously in highly correlated ways — for example, training a model to produce a completely new output format where every layer needs significant adjustment. (3) The model is small enough that full fine-tuning is feasible — for models under 1B parameters, full fine-tuning fits on a consumer GPU and provides full expressiveness. (4) Research requires measuring the full fine-tuning upper bound — for ablation studies comparing PEFT methods against the theoretically optimal baseline. In practice, for instruction following, domain adaptation, and style alignment on models of 7B+ parameters, LoRA/QLoRA provides sufficient quality in 95%+ of cases and full fine-tuning is rarely justified given the cost differential.
📄 LoRA: Low-Rank Adaptation of Large Language Models — Hu et al. (2021)
The foundational paper. Section 4 (low-rank update) is the key contribution. Table 2 comparing LoRA to full fine-tune on GPT-3 shows the quality–efficiency tradeoff that convinced the field.
📄 QLoRA: Efficient Finetuning of Quantized LLMs — Dettmers et al. (2023)
The QLoRA paper. NF4 derivation in Section 3.1 is the most technically interesting part. The Guanaco models trained on a single A100 in 24 hours showed the community what was now possible.
🔗 HuggingFace PEFT — Official Docs
Reference implementation. The LoRA and QLoRA quickstart guides are the fastest path to a working fine-tune. The conceptual guides section explains each PEFT method clearly.
📄 DoRA: Weight-Decomposed Low-Rank Adaptation
The next step after LoRA — decomposes weight updates into magnitude and direction. Better quality at same rank in many experiments. Increasingly used in production fine-tuning workflows.
▶ Fine-tuning LLMs with PEFT and LoRA — Weights & Biases (2023)
YouTube · 45 min · Complete end-to-end walkthrough with experiment tracking. Shows the practical decisions — hyperparameter sweeps, choosing rank, evaluating outputs — that papers don't cover.