LoRA and parameter-efficient tuning
Lesson 3 of 6
3LoRA and parameter-efficient tuning
The big pictureTuning every weight of a seven-billion-parameter model needs tens of gigabytes of memory and a full copy of the model per task. LoRA freezes the original weights and trains two small matrices beside each layer, under 1% of the parameters, and usually gets very close to full tuning's quality.
What the picture shows: The signal passes from the full width d into a narrow neck the width of the rank r, then back out. A is the first set of links and B the second, and because the neck is narrow the parameter count is small: under 0.4% of a 4096 by 4096 matrix at rank 8.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
A large model is like a huge book. Instead of rewriting the whole book to change its style, you stick small transparent notes on its pages that shift the meaning slightly. LoRA is those notes: it trains a very small share of the numbers, saving memory, time and money.
LoRA freezes the original weights and adds to each target matrix a product of two small matrices B·A of rank r. The three settings: rank r, the alpha factor and target_modules. QLoRA adds 4-bit quantisation of the base model to save memory.
Before You Start
🧮Linear algebra
You know matrix multiplication, and that a d×r matrix times an r×d one gives a d×d matrix.
🧱The transformer block
You know the attention projections q, k, v and o, and the feed-forward network inside the block.
The Core Idea
LoRA freezes the original weights and trains two small matrices beside them. In this lesson you see how many parameters it saves, why a small rank is enough, how it starts at zero and merges, and how to set it up with a library.
1Freeze W, learn A and B
Instead of changing all of W, we learn an update ΔW = A × B. A is tall and thin, B is short and wide, and their shared width is the rank r, usually 8 or 16.
Look at the picture: a 4096 by 4096 matrix has 16.8 million parameters, and at rank 8 we train only 0.07 million. The second bar is barely visible.
d = 4096 # one projection matrix in a 7B model is 4096 x 4096
full = d * d
for r in [4, 8, 16, 64]:
lora = d * r + r * d # A is d x r, B is r x d
print(f"rank {r:2}: {lora:>9,} trainable instead of {full:,} ({lora / full:.2%})")
One 4096 by 4096 matrix has 16.7 million parameters. At rank 8 we train only 65 thousand, under 0.4%. Across a whole model that means tens of millions instead of billions, so one mid-range card's memory is enough, and the output is a small file of a few tens of megabytes instead of a full copy of the model.
2Why a small rank is enough
A pretrained model already knows a lot, and tuning does not teach it from scratch. It tilts it in a few directions: this format, this style, these categories. The rank is the number of those directions.
Look at the picture: the error falls quickly up to rank 6, then nearly stops at 0.20. Beyond it there is only noise.
A "real" update built from 6 directions plus light noise. We approximate it at different ranks and measure the error.
import numpy as np
rng = np.random.default_rng(0)
d = 256
true_rank = 6
# a "real" fine-tuning update: mostly a few directions, plus a little noise
update = rng.normal(size=(d, true_rank)) @ rng.normal(size=(true_rank, d)) / d
update += rng.normal(scale=0.002, size=(d, d))
u, s, vt = np.linalg.svd(update)
for r in [1, 2, 4, 6, 8, 16]:
approx = (u[:, :r] * s[:r]) @ vt[:r]
err = np.linalg.norm(update - approx) / np.linalg.norm(update)
print(f"rank {r:2}: relative error {err:.3f} parameters {2 * d * r:>6,} of {d * d:,}")
The error falls quickly up to rank 6, then nearly stops at about 0.20. What remains is the noise, and higher ranks only learn that. In real tuning you do not know the "true" rank, so you try 8, 16 and 32 on the validation set and pick where improvement stops.
3Starting at zero, then merging
The output becomes x W + (α / r) x A B. We start A small and random and B at zeros, so at the first step the model is exactly the original.
Look at the picture: training changes only A and B, then we merge W + (α / r) A B into one matrix for deployment.
import numpy as np
rng = np.random.default_rng(1)
d, r, alpha = 64, 8, 16
W = rng.normal(size=(d, d)) / np.sqrt(d) # frozen pretrained weight
A = rng.normal(size=(d, r)) * 0.01 # trainable, small random
B = np.zeros((r, d)) # trainable, zeros: the update starts at nothing
scale = alpha / r
x = rng.normal(size=(4, d))
def lora_forward(x):
return x @ W + scale * (x @ A) @ B
print("before training, LoRA output equals the base model:", bool(np.allclose(lora_forward(x), x @ W)))
B = rng.normal(size=(r, d)) * 0.05 # pretend training changed B
merged = W + scale * A @ B # fold the adapter into the weight
print("after merging, one matrix gives the same output:", bool(np.allclose(lora_forward(x), x @ merged)))
print("extra cost at inference after merging: 0 extra matrices")
B starts as zeros, so at the first training step the model is exactly the original, and training does not begin by wrecking what it knows. After training, merging A B into W gives one matrix with the same outputs, so LoRA costs nothing at inference.
4Rank, scale and layers
The rank r is the matrices' width, and α sets the update's strength. A common start is r = 16 and α = 32. You put adapters on at least the attention projections q, k, v and o, and adding the feed-forward usually helps at the cost of more parameters.
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-1.5B-Instruct", torch_dtype="bfloat16")
config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
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, config)
model.print_trainable_parameters() # about 1% of the model is trainable
# after training:
# merged = model.merge_and_unload(); merged.save_pretrained("tuned-merged")
target_modules picks the layers, and their names differ between model families, so print model and look. And merge_and_unload merges the adapter into the model to deploy it as an ordinary model.
How It Works, Step by Step: how LoRA trains one layer
The original W is frozen, so it has no gradients and no optimiser state.
Beside it go a small random A and a zero B, so the model starts exactly equal to the original.
The output becomes x W + (α / r) x A B, and training updates only A and B.
After training you merge W + (α / r) A B into one matrix, or keep the adapter separate to swap it.
Going deeper
QLoRA goes a step further: it loads the frozen base model at 4-bit precision instead of 16, cutting its memory to a quarter, and trains LoRA adapters at normal precision on top. That made tuning a seven-billion-parameter model possible on a single 16-gigabyte card, with a small quality loss that you measure.
Worked example
The idea: instead of changing the whole weight matrix W, we learn an update ΔW = A × B, where A is tall and thin and B short and wide. Their shared width is the "rank" r, and it is small: usually 8 or 16.
Try it without code
Myth vs Reality
| Myth | Reality |
|---|---|
| A higher rank means higher quality. | In the example the error stopped near 0.20 after rank 6, and higher ranks only learned noise. Start at 8 or 16. |
| LoRA slows the model down at inference. | After merging, W + (α / r) A B is a matrix with the same shape as W, so the computation is exactly the same. |
| LoRA teaches the model from scratch with fewer parameters. | The model already knows a lot, and tuning tilts it in a few directions: a format, a style, a set of categories. That is why a small rank is enough. |
Try it yourself
A 4096 × 4096 matrix at rank 16. How many parameters does LoRA train in it?
A is 4096 × 16 and B is 16 × 4096: 65,536 + 65,536 = 131,072, which is 0.78% of the matrix.
After merging a LoRA adapter into the weights, how much does inference cost increase?
W + (α / r) A B is a matrix with the same shape as W. The computation is exactly the same as the original model.
Quick review cards
W and learn a low-rank ΔW = A × B. The output is x W + (α / r) x A B.r is the matrices' width, and α sets the update's strength. A common start is r = 16 and α = 32.q, k, v, o); adding the feed-forward usually helps, at the cost of more parameters.ΔW into W after training as one matrix, so there is no extra cost at inference. Or keeping them apart to swap several adapters on one model.Check Your Understanding
Why does B start as zeros rather than random numbers like A?
So that A × B = 0 at the start, and the tuned model begins exactly equal to the original. If both were random, the update would add noise to every layer before any learning and wreck the model's performance in the first steps. And A is random so the gradients are not zero for both.
What does QLoRA add on top of LoRA, and what does it cost?
It loads the frozen base model at 4-bit precision instead of 16, cutting its memory to a quarter, and trains the adapters at normal precision on top. The cost is a small quality loss you measure.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs1
Done with this lesson?
Sign in to save your progress.