15 — The Fine-Tuning Stack: LoRA, QLoRA, DPO, GRPO¶
Papers to keep next to your desk:
LoRA — Hu et al., 2021 —
arxiv:2106.09685QLoRA — Dettmers et al., 2023 —
arxiv:2305.14314DPO — Rafailov et al., 2023 —
arxiv:2305.18290InstructGPT (RLHF) — Ouyang et al., 2022 —
arxiv:2203.02155GRPO / DeepSeekMath — Shao et al., 2024 —
arxiv:2402.03300DeepSeek-R1 — DeepSeek-AI, 2025 —
arxiv:2501.12948Kahneman-Tversky Optimization (KTO) — Ethayarajh et al., 2024 —
arxiv:2402.01306ORPO — Hong et al., 2024 —
arxiv:2403.07691
Mental model. Pretraining teaches the model to predict text. Fine-tuning teaches it behavior. There are two behavioral axes: what to output (supervised fine-tuning, SFT — from labeled pairs) and what to prefer (preference optimization — from human/AI preference or verifiable reward). Modern stacks compose them: SFT gives you a passable instruction-follower; preference optimization sharpens it into something people (or reward functions) actually like. Everything below is machinery for one of these two.
Part 1 — The parameter budget question¶
The first fork in the road is: how many parameters do you actually update? This dominates memory, cost, and portability more than any other choice.
Method |
Trainable params |
Memory (7B, bf16 base) |
Portability |
Quality ceiling |
|---|---|---|---|---|
Full FT |
100% (7B) |
~112 GB (bf16 grads + AdamW states) |
New checkpoint per task |
Highest |
LoRA |
0.1–1% (~10–70M) |
~18 GB |
Tiny adapter (~50 MB) |
~95–99% of full FT for most tasks |
QLoRA |
Same as LoRA |
~7 GB (base in NF4) |
Same tiny adapter |
~98% of LoRA, fits 65B on 48 GB |
DoRA |
Similar to LoRA + magnitude vec |
~19 GB |
Same adapter shape |
Slightly > LoRA at same rank |
Full FT + FSDP2 CPU offload |
100% |
~28 GB active |
Full checkpoint |
Highest |
Prompt/Prefix tuning |
<0.01% |
~14 GB |
Tiny embedding |
Weak; niche only |
Rules of thumb:
If you have <100k SFT examples and a single task, LoRA/QLoRA is almost always right. The quality gap to full FT is usually smaller than the noise in your eval.
If you’re doing multi-task or continual-training on millions of tokens of new distribution, full FT wins.
If you’re fine-tuning >30B on <2 nodes, QLoRA is your only reasonable option.
For preference optimization (DPO/GRPO/RLHF), LoRA is standard; you’re editing behavior, not rewriting the model.
Part 2 — LoRA, mechanically¶
The idea. Freeze the pretrained weight W_0 ∈ R^{d×k}. For each targeted linear layer, learn a low-rank update:
W = W_0 + ΔW , ΔW = B · A , A ∈ R^{r×k}, B ∈ R^{d×r}
with rank r ≪ min(d, k) (typically 8, 16, 32, 64). At init, A ~ Kaiming, B = 0, so ΔW = 0 (identity behavior). Only A and B train. At inference, you can either (a) apply B·A on the fly (small compute penalty) or (b) fuse it back: W ← W_0 + B·A (zero inference overhead).
Knobs and how to set them:
Knob |
Common values |
How to think about it |
|---|---|---|
|
8, 16, 32, 64, 128 |
Higher = more capacity, more memory. Start at 16 for SFT, 32–64 for preference. |
|
2r or 32 |
Scales the update: |
|
0.05–0.1 |
Regularizes the adapter. Usually leave at 0.05. |
|
|
Attention-only saves ~half the params; MLP-included is what QLoRA paper recommended and what PEFT defaults to now. Include MLP if you can afford it — DeepSeek and community empirics show it matters. |
|
|
LoRA on biases is rarely useful. |
Learning rate |
1e-4 to 3e-4 |
~10× higher than full-FT LR because you’re updating so few params. |
A minimal PEFT + Transformers setup:
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
import torch
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B", torch_dtype=torch.bfloat16, device_map="auto")
peft_cfg = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05, bias="none",
target_modules=["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"],
task_type="CAUSAL_LM",
)
model = get_peft_model(model, peft_cfg)
model.print_trainable_parameters() # 0.5–1% of total
What LoRA is not good at. Adding new capabilities the base model lacks (new language, dramatically different domain) — the low-rank subspace is too small. If your eval loss plateaus above the base and you’re sure the data is fine, try full FT before blaming the data.
Part 3 — QLoRA (NF4 + paged optimizers)¶
QLoRA = “run LoRA on top of a 4-bit-quantized frozen base model.” The three technical ingredients:
NF4 (NormalFloat-4). A non-uniform 4-bit format whose 16 quantization levels are placed at the quantiles of a standard normal distribution — because that’s what pretrained weight distributions look like after normalization. Better fidelity than uniform int4 at zero cost. Blockwise (block size 64 or 128) with per-block scales in fp16.
Double quantization. The per-block scales themselves get quantized to 8-bit with a second-level scale. Saves ~0.4 bits/param — small, but free.
Paged optimizers. AdamW states (m, v — 2× fp32/param, 8 bytes) can spike GPU memory when a batch happens to have a long sequence. bnb pages optimizer states between GPU and CPU via unified memory when pressure spikes. Prevents OOM, ~5–10% slower on the pages.
The magic property: because the base model is frozen and quantized, only the LoRA adapters need gradients. So the training memory reads:
memory ≈ base_weights (NF4) + LoRA_weights (bf16) + LoRA_grads (bf16) + LoRA_optimizer_states (fp32) + activations
For a 7B, that’s roughly 3.5 GB + 0.15 GB + 0.15 GB + 0.6 GB + activations — fits in 12 GB. For 70B, ~40 GB + change — fits on a single 48 GB card. This is why QLoRA is the enabling technique for solo fine-tuners.
Setup (bitsandbytes + PEFT):
from transformers import BitsAndBytesConfig, AutoModelForCausalLM
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained("meta-llama/Meta-Llama-3-70B", quantization_config=bnb, device_map="auto")
# then wrap in LoRA as above; use optim="paged_adamw_8bit"
Gotchas:
QLoRA converges slightly slower per step (dequant overhead in every forward). Wall-clock the delta.
Don’t
.merge_and_unload()a QLoRA adapter into a quantized base — quality suffers. Merge into a dequantized base or ship base + adapter separately.Some ops (rotary, RMSNorm) are computed in the
compute_dtype(bf16); the ADD-then-matmul back to NF4 is the actual quant boundary. Understanding this lets you debug precision issues.
Part 4 — DPO: preference optimization without a reward model¶
The classical RLHF pipeline is (i) SFT, (ii) reward model on preference pairs, (iii) PPO against the reward model with KL to the SFT reference. It works, but you’re maintaining four models (policy, ref, reward, value) and PPO is a rollout-heavy nightmare. DPO (Rafailov et al., 2023) collapses (ii)+(iii) into a single supervised objective on preference pairs directly.
The derivation in one line. The optimal policy for the KL-regularized RL objective has a closed-form solution in terms of the reward: π*(y|x) ∝ π_ref(y|x) · exp(r(x,y)/β). Solve for r, substitute into the Bradley-Terry preference likelihood, and the reward model disappears. What’s left is:
L_DPO = -E_{(x, y_w, y_l) ~ D} log σ( β · ( log(π_θ(y_w|x)/π_ref(y_w|x)) - log(π_θ(y_l|x)/π_ref(y_l|x)) ) )
Where y_w is the preferred completion, y_l the rejected one, π_ref is the frozen SFT model, and β (typically 0.1–0.5) controls how far you drift from the reference.
What you need:
An SFT model (
π_ref).Preference pairs — human labels, LLM-as-judge, or synthetic. Quantity: 10k–100k for a small task, more for broad behavior.
Compute: DPO is O(1× SFT forward) with a small constant. You keep two model copies in memory (policy + frozen ref) — or LoRA on top of a shared frozen base, which is standard now.
TRL’s DPOTrainer is the reference. Config knobs that matter:
beta: 0.1 is the paper default; higher = more conservative (stays closer to ref); lower = more aggressive (better preference win rate, more risk of gibberish/hacking).loss_type:"sigmoid"(original),"ipo"(Azar et al., 2023 — fixes overfitting on deterministic pairs),"kto"(single-label, no pairs needed).max_length/max_prompt_length: preference datasets have long completions; blow past your original context and DPO silently mistrains.
What DPO is not. It’s not RL. There is no environment, no exploration, no on-policy sampling. If your preferences are noisy or your reference model is weak, DPO amplifies noise. It also can’t optimize verifiable rewards (math answer correct/incorrect) — for that, you want GRPO.
Part 5 — GRPO: verifiable-reward RL that finally works at scale¶
Group Relative Policy Optimization (DeepSeekMath, then DeepSeek-R1). GRPO is what PPO wanted to be for LLMs: no critic, no value network, just group-level advantage estimation.
The setup. For each prompt x, sample a group of G completions {y_1, ..., y_G} from the current policy. Score each with a rule-based or model-based reward (r_i). Compute group-normalized advantages:
A_i = (r_i - mean(r)) / std(r) // normalize within the group
Then optimize a PPO-style clipped surrogate against A_i, with a KL penalty to the reference:
L = -E [ min( ratio · A , clip(ratio, 1-ε, 1+ε) · A ) ] + β · KL(π_θ || π_ref)
where ratio = π_θ(y|x) / π_θ_old(y|x). The mean-and-std normalization inside each group is the “critic-free advantage estimation” trick — you use the group as its own baseline.
Why it beats PPO for LLMs:
No value model → half the memory, half the moving parts.
No GAE horizon issues; a completion is a completion.
Group normalization eats reward-scale drift for free.
Trivially parallelizes over prompts (each group is independent).
Why it’s expensive anyway. You still need G (usually 4–16) rollouts per prompt per step. If your prompt has a long chain-of-thought, that’s G × 4096-token generations per step. This is where rollout infrastructure (next file) becomes the bottleneck: your training step now contains an inference workload, and the ratio of rollout time to weight-update time is often 10:1 or worse. Fixing that is the current hot niche in ML systems.
When GRPO shines. Tasks with a verifiable reward: math (exact-match answer), code (unit tests pass), tool use (correct API call succeeds), reasoning traces (final answer correct). This is exactly the DeepSeek-R1 recipe, and it’s exactly the shape of many enterprise problems you’d tackle at Zoho — an agentic tool call is either right or wrong; a CRM query either returns the right rows or not.
When to use what:
Situation |
Choose |
|---|---|
No preference data, just supervised outputs |
SFT (LoRA/QLoRA) |
Human preference pairs, ≤ 100k, offline |
DPO |
Preference pairs but ratings noisy |
IPO or KTO |
Verifiable rewards (math, code, tool use) |
GRPO |
Frontier scale + willing to run PPO infra |
RLHF/PPO |
Preference + SFT rolled into one loss |
ORPO |
Part 6 — Data quality > algorithmic sophistication¶
The blunt truth from every practitioner:
“The best DPO run on average data loses to a mediocre SFT run on great data.”
Data-quality checklist for SFT:
Deduplicate at prompt-level and near-duplicate (MinHash/simhash). Repeated examples silently oversample and destroy generalization.
Contamination check against your eval sets — n-gram overlap against MMLU/GSM8K/HumanEval, mandatory.
Length distribution — histogram your (prompt, completion) lengths; a long tail of 8k-token examples with
max_length=2048will silently truncate half your signal.Tokenizer fit — if you’re fine-tuning Llama-3 but your training corpus is heavy Chinese, expect worse token efficiency than a Qwen base would give you. Match the tokenizer to the domain.
Format consistency — chat templates matter. If half your SFT data uses
<|im_start|>and half uses[INST], the model learns “neither.”
For preference data (DPO/GRPO):
Preference agreement rate — sample 200 pairs, have two humans independently label; if agreement < 70%, your preferences are noise and DPO will amplify.
Reward hacking hunt — sample 100 completions from the RL-tuned model; if you see repetition, formatting exploits, or refusal-then-compliance patterns, your reward has a hole.
Length bias — DPO reliably makes outputs longer. If your win-rate goes up but your users complain about verbosity, that’s why. Regularize with length-controlled DPO variants or bake length into the reward.
Part 7 — Evaluation discipline¶
Every fine-tune is a hypothesis; every hypothesis needs a before/after measurement. The minimum bar:
General-capability regression battery. Run lm-evaluation-harness on 5–10 tasks before and after. If MMLU drops 3 points, you paid for your DPO gains in world-knowledge damage.
Task-specific eval. The metric you actually care about — win rate vs GPT-4 on your task, exact match on your test set, human-eval on 100 samples.
Perplexity on held-out chat. Cheap, catches obvious regressions.
Free-form generation samples. 20 fixed prompts, greedy decode, diff against the base and the previous iteration. Human eyes catch the failure modes evals miss (mode collapse, formatting weirdness, refusal drift).
Length + refusal stats. Mean/p95 completion length before and after; refusal rate on a diverse prompt set.
A/B in production shadow traffic if you have it — your Zoho harness makes this feasible in a way it isn’t for most researchers. Take advantage.
Part 8 — Practical stack recommendations¶
For an SFT + LoRA run on a single 24GB GPU (Qwen-7B):
Framework: TRL’s
SFTTraineror Axolotl.Base:
Qwen/Qwen2.5-7B-Instruct(or-Baseif you want to control the chat template).Quantization: bf16 for base if it fits (~14 GB); NF4 QLoRA if 14+activations > 24 GB.
LoRA: r=16, alpha=32, target all linear layers.
Optimizer:
paged_adamw_8bit, lr=2e-4, cosine schedule, warmup 3%.Batch: micro=4, grad-accum to reach effective 64–128.
Precision: bf16 mixed; enable gradient checkpointing if OOM.
Framework accel:
torch.compileon the base model if PEFT+compile plays nice; otherwise skip.
For a DPO run on 2× 80GB GPUs (70B QLoRA):
Framework: TRL’s
DPOTrainer.Base: your SFT’d 70B with LoRA already applied — DPO on top of SFT-LoRA using the same adapters.
beta=0.1,loss_type="sigmoid"first pass,"ipo"if you see overfitting.FSDP2 shard, activation checkpointing on.
Batch: micro=1, grad-accum 32.
Sample completions from base + tuned every 500 steps; look at them.
For a GRPO run on 8× H100:
Framework: verl (from HybridFlow) or TRL’s
GRPOTrainer, or OpenRLHF. See next file.Rollout via vLLM engine inside the training loop.
Reward: rule-based (exact match, regex, tool-call schema check) whenever possible — model-based rewards are hackable.
G=8rollouts, temperature 0.7–1.0 (need exploration).KL β = 0.001–0.01 (very low; the group-norm advantage is already stabilizing).
Watch for reward hacking after step ~500. Always.
Part 9 — What to skip¶
The fine-tuning literature is enormous and 80% of it is noise. Things you can safely skip until proven necessary:
Prompt/prefix tuning. Almost always worse than LoRA and doesn’t merge.
Adapters (Houlsby-style). Superseded by LoRA in every practical setting.
RLAIF-only pipelines. Fine for research; brittle in production.
Any preference method with three-plus loss terms (SLiC, RRHF, PRO, RSO). If DPO/IPO/KTO can’t do the job, you probably have a data problem, not a loss problem.
Part 10 — Zoho angle¶
Your CRM agentic harness is a perfect fine-tuning target. The pattern:
Instrument the harness to log
(prompt, completion, tool_calls, user_signal)for six weeks.user_signal= did the user accept the suggested action / edit it / reject it. This is free preference data. You already have the infrastructure to do this ethically (on-prem, respect the deployment tenant’s boundaries).SFT on the accepted completions — a 7B model fine-tuned on 20k accepted examples will beat prompted-GPT-4 on your specific CRM tasks for latency-sensitive on-prem scenarios.
DPO with (accepted, rejected) pairs — this gives you the second-order polish.
GRPO for the tool-call format — reward = “did the tool call succeed and return non-empty results?” — verifiable, hackable-in-limited-ways, and produces measurable win rate improvements.
Each step is publishable inside Zoho R&D. Each step, done honestly, becomes a portfolio artifact when you want to move.
Exercises¶
Take Qwen2.5-1.5B, apply LoRA r=16 on all linear modules, and fine-tune on 5k Alpaca-style examples. Measure: (i) trainable params vs total, (ii) memory peak, (iii) MMLU delta, (iv) subjective quality on 20 held-out prompts.
Repeat exercise 1 with QLoRA (NF4). Explain the memory delta before running, then verify.
On the same base + LoRA, run DPO with 5k synthetic preferences (generate them by having a bigger model rank two completions of Qwen for each prompt). Measure win rate vs base on a held-out set of 200 prompts (judged by yet another model). Notice how the win rate correlates with completion length.
Design a GRPO experiment for a verifiable task on 1× GPU: GSM8K math. Sketch: rollout via vLLM local server, reward = final-answer exact match. What is the wall-clock ratio between rollout time and gradient update time? What would speed up rollout by 3×?
Read the DeepSeek-R1 paper and identify: (a) exactly which RL algorithm they use, (b) what the reward function is at each stage, (c) how they handle mode collapse / language mixing. Write a one-page critique.
References¶
LoRA:
arxiv:2106.09685QLoRA:
arxiv:2305.14314DPO:
arxiv:2305.18290IPO (Azar et al.):
arxiv:2310.12036KTO:
arxiv:2402.01306ORPO:
arxiv:2403.07691GRPO / DeepSeekMath:
arxiv:2402.03300DeepSeek-R1:
arxiv:2501.12948InstructGPT (RLHF):
arxiv:2203.02155DoRA:
arxiv:2402.09353TRL docs: https://huggingface.co/docs/trl/
PEFT docs: https://huggingface.co/docs/peft/
Axolotl (config-driven training): https://github.com/OpenAccess-AI-Collective/axolotl
bitsandbytes: https://github.com/TimDettmers/bitsandbytes
Next: 16_rollout_infra.md — the training loop that contains an inference engine. This is where your two career worlds fuse.