LoRA & QLoRA with PEFT
Parameter-efficient tuning: LoRA trains tiny adapters instead of all weights, and QLoRA adds a 4-bit frozen base so a 7B model tunes on one consumer GPU. A full, runnable training job.
- a GPU (QLoRA fits a 7B model on one consumer card) +
pip install transformers peft trl bitsandbytes - a GPU +
pip install transformers peft trl datasets
Learning objectives
- Explain how LoRA tunes a model by training small adapter matrices, not all weights.
- Explain how QLoRA adds 4-bit quantization to fit big models on one GPU.
- Run a real QLoRA training job with PEFT + TRL.
- Choose rank, alpha, and target modules sensibly.
30-second recap
- When fine-tuning wins: Prompting and RAG change what the model sees; fine-tuning changes the model itself — worth it for consistent style, format or narrow skills, not for adding facts.
- Transformers: Attention lets every token look at every other token; stacking attention and feed-forward layers is the architecture behind every modern LLM.
code/ft3-lora-qlora/ in the course, with a README. Run the scripts or copy the configs directly.LoRA: train a little, change a lot essential
Here's the wall people hit. You've decided (back in FT1) that fine-tuning is the right move, you've built a clean dataset (FT2), and you go to actually train a 7B model — and your GPU immediately runs out of memory. Not because the model is too big to hold, but because training it means storing a gradient for every weight and two optimizer numbers for every weight on top of the weights themselves. That's the moment most people either rent a cluster they can't afford or give up. LoRA is the technique that lets you walk right past that wall on the hardware you already have — and it's why "lora peft" is one of the first things anyone tuning open models learns.
The analogy that makes it click: imagine a huge published textbook you're not allowed to reprint. You can't rewrite the pages — but you can slip a thin stack of sticky notes between them with your corrections and additions. The book stays exactly as printed (that's the frozen base); your sticky notes are the only thing you write (those are the adapters); and anyone reading gets book-plus-notes as if it were one edited volume. Crucially, your notes are a few pages, not a few thousand — so they're cheap to write, cheap to mail to a colleague, and you can peel one set off and stick a different set on for a different purpose. That last part is the superpower: one base model, many swappable adapters.
Why "low-rank" is the clever part, in plain terms. A weight matrix is a big grid of numbers — say 4096×4096, which is ~16 million values. LoRA never touches that grid. Instead it learns the change you'd want to make to it, and it forces that change to be expressible as two skinny matrices multiplied together: one that's 4096×r and one that's r×4096, where r (the "rank") is small — often 8 or 16. Multiply those two skinny matrices and you get something the same shape as the original grid, but you only ever stored and trained the two skinny factors. At r=8 that's ~65k numbers instead of 16 million — under half a percent (you'll compute this exactly in Exercise 1). The bet LoRA makes is that the useful adaptation for a specific task lives in a low-rank space — that you don't need all 16 million degrees of freedom to teach the model a new tone or format, just a handful of directions. In practice that bet pays off remarkably often.
The common mistake to head off now: LoRA does not shrink or change the base model, and it does not make inference cheaper by itself. The base still has all its billions of weights and still does all its usual math. What LoRA changes is the cost of training — the gradients and optimizer state now cover under 1% of the parameters instead of 100%. Keep "LoRA saves training memory, not model size" straight and the whole chapter stays clear.
Full fine-tuning updates all billions of weights — huge memory, easy to overfit. LoRA (Low-Rank Adaptation) freezes the model and trains tiny adapter matrices injected next to the big weight matrices. You train <1% of the parameters, get most of the benefit, and can swap adapters like plugins.
This picture explains the trick behind LoRA in three boxes. The big idea: don't retrain the giant model — freeze it and bolt on tiny trainable pieces.
- Frozen base weights (left) is the original multi-billion-parameter model, left unchanged. "Frozen" means training never touches it.
- + small LoRA adapters (trained) (middle) are the little matrices added alongside the big ones. They are the only thing that learns — the
<1% of paramslabel means you train less than one percent of the model. - Merged at inference (right) means when you actually use the model, the tiny adapters can be folded back into the base to act as one model — or kept separate and swapped like plugins.
In short: LoRA = freeze the big model, train a small add-on. Because the add-on is tiny, it's cheap to train, quick to store, and easy to swap in and out.
A real QLoRA run essential
LoRA cut the training memory; QLoRA cuts the part LoRA left behind — the base itself. Even frozen, a 7B model in its normal 16-bit form is ~14 GB just to hold, before you've stored a single activation. On a 16 GB consumer card that alone is game over. QLoRA's move is to load that frozen base in 4-bit — roughly a quarter of the memory, ~3.5 GB — using the quantization you met in IC2. Now the base fits with room to spare, the tiny LoRA adapters train on top in full precision, and a model that "needed a cluster" tunes on a single card. That combination — 4-bit frozen base + LoRA adapters — is the entire idea, and the lab below is it, end to end, in about a dozen real lines.
The subtlety that trips everyone, so grab it before the code: "load in 4-bit" and "do the math in 4-bit" are two different things, and QLoRA deliberately splits them. The weights are stored at 4 bits to save memory, but each time they're actually used in a matrix multiply they're temporarily de-quantized to 16-bit (that's what bnb_4bit_compute_dtype=torch.bfloat16 sets). Think of it like a compressed photo: it sits on disk tiny, but your screen still renders it at full resolution — you compress for storage, decompress to use. If you skipped that and did the arithmetic in raw 4-bit, accuracy would fall off a cliff. So the mental model is "store cheap, compute careful," and it's exactly why the knowledge-check at the end asks about this pairing.
Don't try to memorize every argument below — that's the surest way to feel lost. Read it as four moving parts stacked in order: (1) the BitsAndBytesConfig that makes the base 4-bit, (2) the LoraConfig that says which layers get adapters and how big, (3) the dataset you prepared in FT2, and (4) the SFTTrainer that ties them together and runs the loop. Everything else is a dial you can leave at its default on your first run. The one line worth staring at is the last one: you save only the adapter — a few megabytes — never the multi-gigabyte base, because the base didn't change.
SFTTrainer; training writes out only a few-megabyte adapter, never the multi-gigabyte base.
Read left-to-right: the three stacked boxes on the left are the parts you assemble, the arrows funnel them into the trainer, and the green box on the right is all that comes out.
- The three left boxes are the pieces you configure:
BitsAndBytesConfig(the "Q" — squashes the base to 4-bit),LoraConfig(which layers get adapters and how big), and the FT2dataset. - The arrows show all three being handed to
SFTTrainer, the amber box that owns the actual training loop when you call.train(). - The green box is the payoff:
save_modelwrites out only the small adapter (a few MB). The grey note underneath is the key insight — the frozen base is never re-saved because training never changed it.
In short: Three configured inputs → one trainer → one tiny adapter out; the big base rides along frozen and unsaved.
qlora_train.pyimport torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset
base = "mistralai/Mistral-7B-Instruct-v0.3"
bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16)
model = AutoModelForCausalLM.from_pretrained(base, quantization_config=bnb, device_map="auto")
tok = AutoTokenizer.from_pretrained(base); tok.pad_token = tok.eos_token
lora = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], # attention projections
)
ds = load_dataset("json", data_files="train.jsonl", split="train") # has a "text" field (FT2)
trainer = SFTTrainer(
model=model, tokenizer=tok, train_dataset=ds, peft_config=lora,
args=SFTConfig(output_dir="out", num_train_epochs=1, per_device_train_batch_size=2,
gradient_accumulation_steps=4, learning_rate=2e-4, bf16=True,
logging_steps=10, save_strategy="epoch"),
)
trainer.train()
trainer.save_model("out/adapter") # just the small adapter, not the whole model
This is a complete, real QLoRA training job. It loads a 7B model compressed to 4-bit so it fits on one GPU, attaches trainable LoRA adapters, and runs the trainer. Don't worry about every argument — focus on the four moving parts.
BitsAndBytesConfig(load_in_4bit=True, ...)is the Q in QLoRA: it tells the loader to squash the huge base model down to 4-bit numbers so it barely uses memory.AutoModelForCausalLM.from_pretrained(base, quantization_config=bnb, ...)loads it that way.LoraConfig(r=16, lora_alpha=32, ...)defines the adapters:ris their size/capacity,lora_alphascales them, andtarget_modules=["q_proj", "k_proj", "v_proj", "o_proj"]picks which layers (the attention projections) get an adapter.load_dataset(...)reads yourtrain.jsonl— the file already has a"text"field formatted in FT2, so the trainer knows what to learn from.SFTTrainer(...)wires the model, tokenizer, dataset and LoRA config together; theSFTConfig(...)holds training settings (1 epoch, batch size, learning rate).trainer.train()runs the training loop.trainer.save_model("out/adapter")saves only the adapter — a few megabytes, not the whole multi-gigabyte model.
What the output means: During training you'll see loss numbers printed every 10 steps (they should trend down). At the end, out/adapter holds your trained adapter — the small artifact you serve or merge later.
Try this: Change num_train_epochs=1 to 2 for a longer run, or raise r=16 to 32 for more adapter capacity. Change one knob at a time so you can tell what actually helped when you evaluate in FT6.
Choosing the knobs intermediate
This is where good intentions go to die. You've got a run working, so the temptation is to "tune it properly" by bumping rank, adding target modules, and nudging the learning rate — all at once, in one heroic edit. Then the eval moves and you have no idea which change did it, so you can't repeat the win or undo the loss. The knobs below are powerful, but the real skill in this section isn't knowing what each dial means — it's the discipline to move one at a time. Read the table as a set of sensible starting values, not a set of things to immediately change.
The two knobs people misunderstand most are r and lora_alpha, so let's build intuition for them before the table. Rank r is the capacity of the adapter — how much the model is allowed to learn. Think of it like the number of dials on a mixing board: more dials means finer control, but also more that can be misconfigured, and more memory to hold them. Small tasks (a fixed output format, a consistent tone) need very few; genuinely hard behavioral shifts need more. lora_alpha is a scaling factor on how strongly the adapter's learned change is applied — LoRA multiplies its update by alpha/r. The near-universal heuristic is alpha ≈ 2×r, and the reason is quietly elegant: as you raise r, keeping alpha at twice it holds that alpha/r ratio roughly constant, so bumping capacity doesn't accidentally also crank up the update's strength and destabilize training. That's why you'll almost never see alpha tuned independently on a first pass — you set it as a function of r and forget it.
Where to point the adapters is the third real decision. target_modules lists which of the model's weight matrices get an adapter bolted on. The standard, cheapest choice is the attention projections (q_proj, k_proj, v_proj, o_proj) — the parts of the model that decide what to pay attention to, which is usually where task-specific behavior lives. If evals later say the model is underfitting — trying but not quite getting there — you widen the net to include the MLP layers (gate_proj, up_proj, down_proj) for more capacity, at the cost of more memory and more parameters to train. The honest default, and the one the tip below repeats, is to start narrow and only widen when the evidence tells you to. Guessing at "more is better" up front just burns GPU hours and muddies your results.
r and alpha (tied at ≈2×r) set the adapter's capacity and strength; target_modules starts on the attention projections and widens to MLP only when evals show underfitting.
Read the left half first (how big the adapter is), then the right half (where it gets attached); the bar along the bottom is the one remaining dial.
- On the left,
r(capacity) andlora_alpha(scaling) both feed the purplescaling = alpha / rbox. Settingalpha ≈ 2×rkeeps that ratio roughly constant as you raise rank — which is why alpha is never tuned on its own. - On the right,
target_modulesis a choice of layers: the green box (attention projectionsq/k/v/o_proj) is the cheap default; the amber arrow shows you widen down to add the MLP layers only if evals say the model is underfitting. - The bottom bar is
learning_rate, the step size (1e-4 to 3e-4 for LoRA) — the last dial, left near default on a first run.
In short: Set r, let alpha follow at 2×r, adapt the attention projections first, and widen to MLP only when the evidence demands it.
| Knob | What it does | Sensible start |
|---|---|---|
| r (rank) | adapter capacity | 8–32 (higher = more capacity, more memory) |
| lora_alpha | adapter scaling | ≈ 2× r |
| target_modules | which layers get adapters | attention projections; add MLP for more capacity |
| learning_rate | step size | 1e-4 to 3e-4 for LoRA |
Exercise FT3.1 — Tune an adapter
Context: Running QLoRA once end-to-end — even before you judge quality — is how you confirm the pipeline actually trains and moves the model's behaviour.
Your task: Run QLoRA on your FT2 dataset for one epoch, save the adapter, then generate on a few held-out prompts and eyeball whether the behaviour shifted toward your target.
Requirements:
- Train for a single epoch on the FT2 dataset
- Save the adapter artifact
- Generate on several held-out prompts
- Confirm the outputs changed (behaviour shift), not that they're good yet
- Defer quality judgement to FT6
💡 Hint: You're checking that training ran and changed outputs — the QLoRA recipe from the Professional rung is the thing to launch.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: LoRA trains under 1% of a model by adding two small low-rank matrices to a weight instead of updating it: for a (d_in × d_out) weight, a rank-r adapter is a (d_in × r) and an (r × d_out) factor.
Your task: Compute the trainable parameter count of a rank-r LoRA adapter against the full matrix and show the fraction, for d=4096, r=8.
Requirements:
- Adapter params =
d_in×r + r×d_out - Full matrix params =
d_in×d_out - Print both counts and the trained fraction (≈0.4%)
- Make clear the adapter is a tiny swappable plug-in
💡 Hint: The two factors A and B are the whole parameter budget — sum their sizes and divide by the full matrix.
Show solution
The adapter parameter count is the whole cost-saving story:
def lora_params(d_in, d_out, r):
return d_in * r + r * d_out # A (d_in x r) + B (r x d_out)
d = 4096
full = d * d # full weight matrix
adapter = lora_params(d, d, r=8)
print(f"full: {full:,}") # 16,777,216
print(f"adapter: {adapter:,}") # 65,536
print(f"trained: {adapter/full*100:.2f}% of the matrix") # 0.39%
A rank-8 adapter trains under half a percent of one matrix, so LoRA fits in a fraction of the memory and produces a tiny artifact you can swap like a plugin.
Context: QLoRA loads the frozen base in 4-bit and trains only the adapters in higher precision, which is what fits a 7B model on one consumer GPU.
Your task: Estimate QLoRA's memory (4-bit base + tiny adapter grads) and contrast it with full fine-tuning's weights + gradients + optimizer state for a 7B model.
Requirements:
- Full FT ≈ 12 bytes/param (weight 2 + grad 2 + Adam 4+4) → tens of GB, multi-GPU
- QLoRA base at 4-bit ≈ 0.5 bytes/param
- Add only the <1% trained adapters at 16 bytes/param
- Show the QLoRA total fits one GPU (single-digit GB)
- Attribute the saving to quantizing + freezing the base
💡 Hint: Full FT's cost is dominated by gradients and Adam's two moments, not the weights — freezing the base deletes almost all of it.
Show solution
Compare the two memory footprints directly:
params = 7e9
def gb(bytes_total): return bytes_total / 1024**3
# Full FT (mixed precision, rough): weights(2) + grad(2) + Adam m,v(4+4) ~ 12 B/param
full_ft = gb(params * 12)
# QLoRA: base frozen @ 4-bit (0.5 B/param), adapters ~0.4% trained @ 16 B/param
qlora = gb(params * 0.5 + params * 0.004 * 16)
print(f"full FT : {full_ft:.0f} GB -> multi-GPU") # ~78 GB
print(f"QLoRA : {qlora:.1f} GB -> one GPU") # ~3.7 GB (weights) + activations
Freezing the base and quantizing it to 4-bit removes almost all the memory; only the tiny adapters pay full-precision grad/optimizer cost. That is why a 7B tunes on a single consumer card.
Context: LoRA's knobs are rank r (capacity), alpha (scaling, often 2×r), and target modules (which layers get adapters); the update is scaled by alpha/r.
Your task: Write a helper that suggests sensible LoRA defaults from task difficulty and prints the effective scaling.
Requirements:
- Map easy/medium/hard → r = 8/16/32
- Set
alpha = 2×r(the stability heuristic) - Default targets to
q_proj, v_proj; add the rest of attention + MLP for hard tasks - Compute and print
scaling = alpha/r - Explain scaling is the factor LoRA multiplies its update by
💡 Hint: The alpha≈2r rule keeps scaling near a constant as you raise rank, which is why it's the stable default.
Show solution
Encode the common LoRA defaults:
def lora_knobs(task_difficulty):
r = {"easy": 8, "medium": 16, "hard": 32}[task_difficulty]
alpha = 2 * r # a common, stable default
targets = ["q_proj", "v_proj"] # attention proj is the usual minimum
if task_difficulty == "hard":
targets += ["k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
scaling = alpha / r # the factor LoRA multiplies its update by
return {"r": r, "alpha": alpha, "scaling": scaling, "target_modules": targets}
for t in ("easy", "hard"):
print(t, "->", lora_knobs(t))
Rank sets adapter capacity; alpha≈2r keeps the update scaling stable; targeting more modules (all projections) adds capacity for harder tasks at more memory. Start small and only raise rank/targets if the task underfits.
Context: One frozen base can serve many task adapters, which is what makes a multi-task fleet cheap — adapters are MB-scale, full models are GB-scale.
Your task: Estimate total storage for N task adapters vs N full fine-tuned models and show why adapters win on disk.
Requirements:
- Size an adapter from the targeted fraction of params (MB-scale)
- Size a full model from all params at 2 bytes each (GB-scale)
- Compare totals for a fleet of ~20 adapters vs 20 full models
- Print the disk ratio (orders of magnitude)
- Note the base is shared and adapters hot-swap on top of it
💡 Hint: Multiply each per-artifact size by N and divide — the ratio is roughly the full-model size over the adapter size.
Show solution
The storage argument for adapters over full models:
def adapter_mb(params_b, r, frac_targeted=0.004, bytes_per=2):
return params_b * frac_targeted * bytes_per / 1024**2 # only the adapter weights
def full_model_gb(params_b, bytes_per=2):
return params_b * bytes_per / 1024**3
N = 20
a = adapter_mb(7e9, r=16) * N
f = full_model_gb(7e9) * N
print(f"{N} adapters : {a:.0f} MB total") # ~1067 MB
print(f"{N} full models: {f:.0f} GB total") # ~260 GB
print(f"adapters are ~{f*1024/a:.0f}x smaller on disk")
Adapters are megabytes, full models are gigabytes, so a fleet of 20 task-specific adapters costs ~1 GB instead of ~260 GB — and one base can hot-swap between them (FT4).
Context: The whole QLoRA recipe is a real, runnable job: a 4-bit base under a LoRA config, trained by TRL's SFTTrainer, saving only the tiny adapter.
Your task: Show the lesson's real QLoRA training with PEFT + TRL so a teammate can launch it, using only documented APIs and labelling it as needing a GPU + libraries.
Requirements:
- Load the base in 4-bit via
BitsAndBytesConfig(nf4, bf16 compute) - Build a
LoraConfig(r, alpha, target_modules, dropout, CAUSAL_LM) - Train with TRL
SFTTrainerover a JSONL dataset - Save only the adapter with
save_model - Note the libs:
transformers peft trl bitsandbytes datasets
💡 Hint: It's the three earlier rungs made concrete — 4-bit base (rung 2), a LoraConfig (rung 3), and the tiny saved adapter (rung 1).
Show solution
The real training job — needs a GPU + pip install transformers peft trl bitsandbytes datasets:
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset
model_id = "mistralai/Mistral-7B-Instruct-v0.3"
bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype="bfloat16")
model = AutoModelForCausalLM.from_pretrained(model_id, quantization_config=bnb,
device_map="auto")
tok = AutoTokenizer.from_pretrained(model_id)
peft_cfg = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj","v_proj"],
lora_dropout=0.05, task_type="CAUSAL_LM")
ds = load_dataset("json", data_files="train.jsonl", split="train")
trainer = SFTTrainer(model=model, train_dataset=ds, peft_config=peft_cfg,
args=SFTConfig(output_dir="out", num_train_epochs=1,
per_device_train_batch_size=2))
trainer.train()
trainer.save_model("out/adapter") # tiny adapter, not a full model
The 4-bit base (bitsandbytes) plus a small LoRA config (PEFT) fed to TRL's SFTTrainer is the whole QLoRA recipe. The saved artifact is just the adapter.
Context: The tuning-method choice is really a hardware-constraint decision, grounded in the ~12-bytes-per-param memory math, with QLoRA as the single-GPU default.
Your task: Given available VRAM, dataset size, and whether you must serve many task variants, write the decision that lands on full FT, LoRA, or QLoRA and justify it against the memory math.
Requirements:
- Compute full-FT VRAM need as
params × 12 bytes - Choose FULL FT only with a huge clean dataset AND enough VRAM
- Choose QLoRA when VRAM is tight (< ~24 GB)
- Prefer LoRA/QLoRA when many variants must be served
- Default to LoRA otherwise
💡 Hint: Start from the full-FT VRAM number: if the card can't hold it, quantize the base and the decision is essentially made for you.
Show solution
Route to a method from the real constraints:
def choose(vram_gb, params_b, need_many_variants, huge_clean_dataset):
full_need = params_b * 12 / 1024**3 # weights+grads+optimizer, rough
if huge_clean_dataset and vram_gb >= full_need:
return f"FULL FT — you can afford {full_need:.0f} GB and have the data"
if vram_gb < 24:
return "QLoRA — 4-bit base fits a single small GPU"
if need_many_variants:
return "LoRA/QLoRA — swappable adapters for a multi-task fleet"
return "LoRA — cheap, fast, adapter artifact"
print(choose(16, 7e9, True, False)) # QLoRA
print(choose(320, 7e9, False, True)) # FULL FT
QLoRA is the default that makes single-GPU tuning possible; full fine-tuning is only justified when you have both the hardware to hold ~12 bytes/param and a large clean dataset to warrant the larger behavior shift.
✓ Checkpoint — you can move on when you can…
- Explain how LoRA trains adapters instead of all weights.
- Explain how QLoRA fits a big model on one GPU.
- Run a QLoRA job and save an adapter.
- Choose rank, alpha, and target modules sensibly.
Knowledge check check yourself
In the QLoRA config, why is the frozen base loaded in 4-bit (nf4) while bnb_4bit_compute_dtype is set to bfloat16?
Show answer
LoRA sets target_modules=["q_proj","k_proj","v_proj","o_proj"] and trains <1% of parameters. What does this achieve, and what does the r (rank) knob control?