🌙 30-Day AI Ramadan Series  ·  Day 28 of 30
Practical Guide· Mar 18, 2026· 26 min read· LLMs · Fine-Tuning

Fine-Tuning LLMs:
Full Fine-Tuning, LoRA & QLoRA

A pre-trained LLM knows almost everything about language. But it doesn't know your company's tone, your medical domain's terminology, or how to answer your customers' specific questions. Fine-tuning is the process of adapting a general-purpose model to a specific task — teaching it new habits without making it forget everything it already knows. This guide covers all three approaches in production use today: full fine-tuning (change everything), LoRA (change almost nothing but still get most of the benefit), and QLoRA (run it on a single consumer GPU with 4-bit precision) — with the math, plain-English analogies, and complete working code.

← Back to Blog ☰ Categories

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.

🎓 Non-Tech Analogy: The New Hire

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:

📋 Consistent format required
Your output must always follow a specific structure — JSON schemas, medical reports in a precise format, code with specific style conventions. Prompting gets you close; fine-tuning makes it reliable at scale.
🔬 Specialised domain knowledge
The model needs vocabulary or conventions that are rare or absent in general pre-training data — clinical trial protocols, legal contract language, proprietary internal documentation.
💰 Cost and latency at scale
A fine-tuned 7B model doing a narrow task can outperform GPT-4 on that task at 1/50th of the cost. If you're running millions of inferences per day, this matters enormously.
🔒 Data privacy requirements
You cannot send customer data to a cloud API. Fine-tuning an open-weight model that runs on your own infrastructure solves this — the model is yours, the data never leaves your servers.

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.

🖌️ Non-Tech Analogy: Repainting a House

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:

Start: pre-trained weights θ₀ (e.g. LLaMA-3 8B, already trained on 15T tokens)
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:

Model weights (7B × fp16)~14 GB
14 GB
Gradients (same size as weights)~14 GB
14 GB
Optimiser states — AdamW stores m and v per param (fp32)~56 GB
56 GB
Total VRAM required~80–100 GB
~80-100 GB minimum

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.

✅ When full fine-tuning IS the right choice

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

🎵 Non-Tech Analogy: The Band with a New Bassist

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:

Original layer: h = W · x
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
Input x (d_in)
    │
    ├──────────────────────────────┐
    │ │
  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:

ApproachTrainable params per layerTotal trainable (32 layers)% of model
Full fine-tune4096 × 4096 = 16.7M per matrix~7,000M (all params)100%
LoRA r=162 × (16 × 4096) = 131k per matrix~40M (4 matrices/layer)0.57%
LoRA r=42 × (4 × 4096) = 32k per matrix~10M0.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 rUse caseTrainable params (7B model)
r = 4Simple style/format changes, very small datasets (<1k examples)~10M
r = 8Moderate domain adaptation, most instruction-tuning tasks~20M
r = 16Complex domain adaptation, larger datasets (10k+ examples)~40M — most common default
r = 64Near-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.

📦 Non-Tech Analogy: Vacuum-Packing Your Wardrobe

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:

Key insight: neural network weights follow a roughly normal (Gaussian) distribution.

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

Step 1: Load in 4-bit NF4
Load the base model weights in 4-bit NF4 format using bitsandbytes. A 7B model shrinks from 14GB (fp16) to ~4GB (4-bit). The weights are decompressed to bf16 on the fly during forward passes only.
Step 2: Add LoRA adapters
Inject LoRA A/B matrices into the attention layers. These stay in full bf16 precision — they're the parts that actually get trained, so precision matters here.
Step 3: Train only LoRA
Gradients flow only through the LoRA adapters. The frozen 4-bit weights act as a fixed lookup — they participate in forward passes but never accumulate gradients. Optimiser states exist only for the small LoRA parameters.
Step 4: Merge or keep separate
At inference time you can either: (a) keep the 4-bit base + LoRA adapter for minimal memory, or (b) dequantise the base to fp16, merge the LoRA weights (W_new = W + BA), and get a single clean model. Merging is cleaner for deployment; keeping separate is more flexible for switching adapters.

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.

Single-turn format:
{"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 typeData neededExample
Style / tone alignment100–500 examplesMake the model respond more concisely, or in a brand voice
Simple instruction following500–2,000 examplesAlways return JSON, follow a specific template
Domain adaptation (shallow)2,000–10,000 examplesMedical Q&A, legal document analysis
Domain adaptation (deep)10,000–100,000 examplesFull clinical note generation, code in proprietary language
Full capability alignment (RLHF)100k+ preference pairsChatGPT-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
── QLoRA Training Output ────────────────────────────────────────────────
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

❌ Training on the instruction text
The loss should only be computed on the response tokens — not the instruction. If you train the model to "predict" the instruction it already has as input, you're wasting capacity and can cause the model to start repeating prompts. Use SFTTrainer's DataCollatorForCompletionOnlyLM which automatically masks instruction tokens from the loss.
❌ Learning rate too high
Fine-tuning learning rates are typically 10–100× lower than pre-training: 1e-4 to 3e-4 for LoRA (vs 3e-3 for pre-training). Too high and you'll destroy the pre-trained weights rapidly. Use cosine decay with a short warmup (3–5% of steps).
❌ Overfitting on a small dataset
With only 200 examples and 3 epochs, the model sees each example 3 times — this can cause memorisation rather than generalisation. Watch the validation loss carefully. Use early stopping. Consider LoRA dropout (0.05–0.1) for regularisation. Lower rank r also reduces overfitting risk.
❌ Wrong chat template
Every chat model has a specific prompt format (LLaMA-2's [INST], LLaMA-3's <|im_start|>, Mistral's [INST]). If your training data uses LLaMA-2 format but you're fine-tuning LLaMA-3, the model's behaviour will be unpredictable. Always use tokenizer.apply_chat_template() with the correct model's tokenizer.
❌ Forgetting to set pad token
LLaMA models have no pad token by default. If you try to batch sequences of different lengths without setting tokenizer.pad_token = tokenizer.eos_token, training will crash or produce garbage. Always set this before creating the dataset.
❌ Not merging before deployment
Keeping the base model + LoRA adapter separate works but adds latency at inference — the adapter computation runs in addition to the base forward pass. For production, merge with 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.

📋 Real Fine-Tune Config: Medical VLM (Qwen 2.5 7B + 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:

MethodCore ideaBest 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

💬 For You to Consider

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.

🧠 Check Your Understanding
1. What is the fundamental difference between full fine-tuning, LoRA, and QLoRA? At what level does each approach operate?
2. Explain the LoRA math. Why does initialising B=0 matter, and what happens if you initialise both A and B randomly?
3. What is NF4 and why is it better than standard int4 quantisation for neural network weights?
4. You're fine-tuning LLaMA-2 7B for customer support Q&A. You have 800 labelled examples. Walk through the key decisions: method, rank, learning rate, epochs, and what to watch for during training.
5. What is catastrophic forgetting, and why is LoRA better at avoiding it than full fine-tuning? Are there cases where even LoRA can cause forgetting?
⚡ Quick Recap — Fine-Tuning LLMs
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. Common mistakes: computing loss on instruction tokens; learning rate too high; wrong chat template; forgetting to set pad_token; not merging before deployment.
  8. 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.
  9. Other PEFT methods: DoRA (decomposes magnitude + direction), Prefix Tuning (trainable virtual tokens), Adapter layers (bottleneck modules), IA³ (scalar scaling vectors). LoRA dominates in practice.
  10. Key tools: HuggingFace PEFT + bitsandbytes + TRL's SFTTrainer. These three libraries handle 95% of practical fine-tuning needs and work together seamlessly.
🎯 Interview Questions
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
📎 Attachments & Further Reading

📄 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.

#FineTuning#LoRA#QLoRA#PEFT#NF4#Quantization#LLaMA#HuggingFace#LLM#TransferLearning#NLP#DeepLearning#31DaysOfAI#Ramadan2026
← Day 27 Tokenization Deep Dive