LoRA (Low-Rank Adaptation) is the PEFT method most product teams meet first. Instead of updating a full weight matrix W, you learn a thin update BA and add it at runtime. QLoRA pairs that idea with a quantized frozen backbone so large models fit on smaller GPUs.
A dense update ΔW for a d x d matrix has d^2 parameters. LoRA assumes task updates are approximately low rank:
W' = W + ΔW
ΔW ≈ B @ A where A is r x d, B is d x r, r << d
You train A and B (plus maybe biases/heads); W stays frozen. After training you can keep them separate or merge W + BA into a single matrix for serving.
QLoRA: load the backbone in 4-bit (or similar) quantized form to save memory, keep it frozen, and still train LoRA adapters in higher precision. You get LoRA-quality adaptation with far less GPU RAM.
Common targets in transformers:
q_proj, k_proj, v_proj, o_projgate_proj, up_proj, down_projMore targets → more capacity and memory. Start with attention; expand if underfit.
r and scalingr (rank) — Capacity knob. Typical starting points: 8, 16, 32.alpha / scaling — Many implementations scale the update by alpha / r so changing r does not wildly change effective step size.You still evaluate in a setup that matches production numerics as closely as you can.
BA into W for a normal checkpoint (simpler serving, loses easy unload).Pure Python LoRA matmul to see shapes—no GPU.
import random
def matmul(M, v):
# M: list[list] shape (out, in); v: list length in
return [sum(row[j] * v[j] for j in range(len(v))) for row in M]
def add(u, v):
return [a + b for a, b in zip(u, v)]
def scale(v, s):
return [s * x for x in v]
d, r = 8, 2
random.seed(0)
W = [[random.uniform(-0.2, 0.2) for _ in range(d)] for _ in range(d)]
A = [[random.uniform(-0.1, 0.1) for _ in range(d)] for _ in range(r)] # r x d
B = [[random.uniform(-0.1, 0.1) for _ in range(r)] for _ in range(d)] # d x r
alpha = 16
scale_factor = alpha / r
x = [random.uniform(-1, 1) for _ in range(d)]
y_base = matmul(W, x)
# x @ A^T then through B (here: A is r x d so A @ x -> r, then B @ that -> d)
ax = matmul(A, x)
y_lora = matmul(B, ax)
y = add(y_base, scale(y_lora, scale_factor))
print("out_dim", len(y), "trainable", d * r + r * d)
def merge_W(W, A, B, scale_factor):
# ΔW_ij = scale * sum_k B_ik * A_kj
d = len(W)
r = len(A)
merged = [row[:] for row in W]
for i in range(d):
for j in range(d):
delta = sum(B[i][k] * A[k][j] for k in range(r))
merged[i][j] += scale_factor * delta
return merged
W_merged = merge_W(W, A, B, scale_factor)
y_merged = matmul(W_merged, x)
err = sum((a - b) ** 2 for a, b in zip(y, y_merged)) ** 0.5
print("merge_l2_err", round(err, 6))
HuggingFace / PEFT-style pseudocode:
# from peft import LoraConfig, get_peft_model
# config = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"])
# model = get_peft_model(base_model, config)
# # QLoRA: load base with load_in_4bit=True (bitsandbytes), then attach LoRA
# model.print_trainable_parameters()
# trainer.train()
r or add MLP targets.requires_grad correctly — Accidentally training the whole backbone undoes the point.LoRA learns low-rank updates BA on frozen weights; QLoRA does the same while storing the backbone in 4-bit to cut GPU memory.
BA into W for a standalone weight set.