llm_from_scratch / lessons/14 · LoRAlesson 15 / 16

Part V — Fine-tuning

LoRA — parameter-efficient fine-tuning

Lesson 13 turned the base completer into an instruction-following assistant by fine-tuning it — but that meant nudging all ~124 million weights and then saving an entire second copy of the model just for that one behavior. Most of that update is redundant. This lesson replaces the whole-model update with a tiny low-rank patch that trains a fraction of a percent of the parameters, starts exactly at the base model, and can be swapped per task.

What the last step left broken
Instruction fine-tuning (13) is full fine-tuning: every matrix W in every block gets a gradient and gets updated, and the result is a fresh 124M-parameter checkpoint you must store and serve. Want a second skill — a summarizer, a coder, a support bot? That is another full backward pass over 124M parameters and another whole 500 MB checkpoint on disk. The cost scales linearly with the number of tasks, even though each task only bends the model a little. We are paying to re-store the entire model to remember a small adjustment.
Linear position
Forced by: full fine-tuning (13) updates and stores all ~124M parameters per task — one complete model copy each — and most of that update is redundant.
New idea: freeze the pretrained weights W and learn only a low-rank change ΔW = A·B, where A is (d × r), B is (r × d), and the rank r ≪ d. Train just 2·d·r numbers instead of ; initialize B = 0 so ΔW = 0 and training begins exactly at the base model; scale the patch by α/r.
Forces next: we have now built every piece of a working, trainable, steerable GPT — but entirely at a 124M toy scale. Lesson 15 asks what actually changes when you push this same machine to frontier scale, and hands off to the tracks that go there.
The plan
Five moves. (1) Count the true cost of full fine-tuning: updated and stored per matrix, one copy per task. (2) State the insight that makes it avoidable — the adaptation you need is low-rank. (3) Build the mechanics: freeze W, add ΔW = A·B, the frozen-plus-patch forward pass, B = 0 at init, the α/r scale. (4) Do the arithmetic of the payoff and see how adapters swap. (5) Decide where to attach LoRA and treat rank as the one capacity knob. Then drive the rank explorer and watch the parameter count collapse.

1 · The true cost of full fine-tuning

A single weight matrix in a transformer block — say a query projection W_q — is (d × d). For GPT-2 small that is 768 × 768 ≈ 590{,}000 numbers in one matrix, and a block has several such matrices (the four attention projections plus the two feed-forward matrices), stacked L = 12 times. Full fine-tuning computes a gradient for every one of those ≈124M numbers and writes back a new value for each.

Two costs follow, and the second is the one that bites. The compute cost is a full backward pass — real, but you pay it once. The storage and deployment cost is forever: the output of fine-tuning is a complete, standalone 124M-parameter checkpoint. Ten fine-tuned tasks means ten full checkpoints, ten times the disk, and — if you want to serve them — either ten models loaded in memory or an expensive swap of the entire weight set every time a request needs a different skill.

The waste, stated precisely
Fine-tuning does not rebuild the model from scratch; it nudges a model that already works. The nudge — the difference ΔW = W_finetuned − W_base between the fine-tuned and base weights — is what actually encodes the new task. Yet full fine-tuning throws that structure away and stores the whole sum W_base + ΔW as a fresh dense matrix. We are re-saving the shared foundation, over and over, to record a comparatively small change.

2 · The insight: the change you need is low-rank

Here is the observation that unlocks everything. When you adapt a large pretrained model to a specific downstream task, the update ΔW that fine-tuning discovers does not need the full expressive range of a (d × d) matrix. Empirically it has a very low intrinsic rank: it can be approximated well by a matrix that is the product of two thin ones. The pretrained W already carries the heavy, general knowledge; task adaptation only has to steer within a small subspace.

Recall what rank means. Any (d × d) matrix of rank r can be written as a product of a tall matrix and a wide one:

ΔW  ≈  A · B,    where   A is (d × r)   and   B is (r × d),    r ≪ d

The tall A and wide B meet at a narrow waist of width r — the rank — that pinches all the information passing through. A full-rank update would need r = d and buy nothing. But if adaptation truly lives in a low-dimensional subspace, a small r (LoRA's paper uses values like 1, 2, 4, 8) recovers most of the benefit while the two factors together hold only 2·d·r numbers — vastly fewer than the in the dense update. This is the entire idea of Low-Rank Adaptation.

Why a product of two matrices is "low-rank"
Multiply a (d × r) by an (r × d) and the result is (d × d), but its rank can be at most r — every column of the product is a combination of just r underlying directions (the columns of A). The bottleneck r is a hard ceiling on how much structure ΔW can encode. LoRA bets that a small ceiling is enough for adaptation, and the bet usually pays.

3 · The mechanics: freeze, patch, and start from zero

LoRA changes three things about the layer and nothing else. First, freeze W: the pretrained matrix keeps its values and receives no gradient. Second, add the low-rank patch beside it. The layer that used to compute Wx now computes the frozen result plus a small correction routed through the bottleneck:

y  =  W x  +  (α / r) · A (B x)

Read the correction right-to-left, and notice we never build the big (d × d) matrix A·B explicitly. The input x (dimension d) first hits B and is squeezed down to the tiny rank-r vector Bx; then A lifts it back up to dimension d; finally the α/r factor scales it and it is added to the frozen Wx. Only A and B — together 2·d·r parameters — carry gradients and get trained.

Initialize B = 0 so training begins at the base model

The third change is the quiet, clever one. Initialize A with small random values but set B = 0. Then at step zero:

ΔW = A · B = A · 0 = 0   ⟹   y = W x + 0 = W x

The very first forward pass is identical to the untouched base model — the patch adds nothing. This matters: fine-tuning should start from the model you already trust and move away from it deliberately, not lurch to a random new function on step one. As training proceeds, gradients flow into B (and A), the product A·B grows away from zero, and the model smoothly departs from the base only as far as the task demands. Setting A = 0 instead of B would also give ΔW = 0 at init, but then B would receive no gradient on the first step; zeroing B and keeping A nonzero keeps both factors learning immediately.

The α / r scale

The factor α/r decouples "how hard the patch pushes" from "how wide the bottleneck is." α is a fixed constant (often set once, e.g. to the initial r); dividing by r means that when you later raise the rank, the patch's overall magnitude does not blow up in proportion — you can retune r without re-tuning the learning rate. In practice α/r behaves like a second, LoRA-specific learning-rate multiplier on the update.

class LoRALinear:                 # wraps a frozen linear layer W: (d, d)
    def __init__(self, W, r, alpha):
        self.W = freeze(W)        # pretrained; no gradient
        self.A = zeros(d, r)      # will hold small random init...
        self.B = zeros(r, d)      # ...but B stays 0 at start  => ΔW = 0
        init_small_random(self.A)
        self.scale = alpha / r

    def forward(self, x):
        return x @ self.W.T + self.scale * ((x @ self.B.T) @ self.A.T)
        #      └ frozen base ┘         └ low-rank patch through the r-waist ┘

Everything downstream is unchanged: the loss is still cross-entropy over the response tokens (13), the optimizer is still AdamW (10). The only difference is which parameters carry gradients — and there are now dramatically fewer of them.

4 · The payoff: the arithmetic and the swap

Put GPT-2's numbers in. A dense (d × d) matrix with d = 768 holds 768² ≈ 590{,}000 parameters. Adapt it with rank r = 8 and the LoRA factors hold 2 · 768 · 8 ≈ 12{,}300 — about 2% of the dense matrix, a roughly 48× reduction, per matrix. Applied across the model, LoRA typically trains well under 1% of the total parameters while matching full fine-tuning's task quality closely.

full update
params per matrix = 768² ≈ 590{,}000 — trained and stored, per task.
LoRA update
2·d·r params per matrix = 2·768·8 ≈ 12{,}300 — the only thing trained or stored.
ratio
2·d·r / d² = 2r/d = 16/768 ≈ 2% of the parameters; compression d/(2r) = 768/16 = 48×.

The storage win compounds into a deployment win. Because W is frozen and shared, every task's adapter is just its own small A, B pair — a few megabytes instead of a few hundred. You load the one base model once, then hot-swap adapters per request: attach the summarizer's (A, B) for one call, the support bot's for the next, all over the same frozen backbone. Ten skills cost one model plus ten tiny patches, not ten models. (If you ever want a single fused checkpoint, you can add (α/r)·A·B back into W once and ship a plain model — but the whole point is usually to keep the base pristine and the patches separate.)

Stacking with quantization: QLoRA
The base W is only ever read in the forward pass, never updated — so it can be stored in low precision. QLoRA quantizes the frozen backbone to 4-bit and trains the LoRA factors in higher precision on top, shrinking the memory needed to fine-tune a large model onto a single consumer GPU. The systems detail lives in the scale track; see the cross-reference below.

5 · Where to attach it, and rank as the capacity knob

You do not have to LoRA-ize every matrix. The original work found that adapting the attention projections — the W_q, W_k, W_v (and sometimes the output projection W_o) inside multi-head attention (06) — gives most of the benefit, since attention is where the model decides what to relate to what for the new task. The large feed-forward matrices can be left frozen, or included when you want more adapter capacity. This selectivity is part of why the trainable-parameter count stays so small.

The one knob that matters is the rank r. It is a direct dial on the adapter's capacity: too small and the low-rank patch cannot express the adaptation the task needs (it underfits, and quality lags full fine-tuning); larger and it captures more, at the cost of more trainable parameters and a weaker regularizing effect. Because init keeps you at the base model and α/r keeps the scale sane, sweeping r is cheap — which is exactly what the widget lets you do.

6 · Watch the parameter count collapse

The widget draws the frozen weight matrix W (greyed — it never changes) beside the two LoRA factors A and B that meet at the rank-r waist. Move the r slider to widen or pinch that waist, and the d slider to resize the whole layer. The KPIs compute the trainable parameter count 2·d·r against the full , the percentage, and the compression factor — live. It shows B = 0 at initialization, so ΔW = A·B = 0: the patch starts invisible and the model starts exactly at the base.

LoRA rank explorer
Proves the core trade: freezing W and learning ΔW = A·B at rank r ≪ d buys adaptation for 2·d·r parameters instead of — a tiny fraction. With B = 0 at init, ΔW = 0, so training starts at the base model.
trainable params (2·d·r)
full update (d²)
% of full
compression
ΔW at init
0

Failure modes & checklist

Failure modes

  • Rank too small. The bottleneck r can't express the needed adaptation; the model underfits and lags full fine-tuning. Fix: raise r or LoRA-ize more matrices.
  • Forgetting the α/r scale. The patch pushes too hard or too softly, and changing r silently changes the effective learning rate.
  • Not freezing W. If the base gets gradients too, you are back to full fine-tuning plus a redundant patch — you lose the entire storage/swap benefit.
  • Zeroing A instead of B (or both nonzero). Both-zero kills all gradient; a random start on both makes ΔW ≠ 0 at init, so you no longer begin at the base model.

Checklist

  • W frozen (no gradient); only A (d×r) and B (r×d) train.
  • Forward = Wx + (α/r)·A(Bx), routed through the rank-r waist.
  • B = 0 at init ⇒ ΔW = 0 ⇒ start exactly at the base model.
  • Rank r ≪ d; trainable params 2·d·r ≪ d².
  • Attach to attention projections first; store one small adapter per task.

Checkpoint

Try it
Set d = 768 and r = 8: the KPIs should read about 12k trainable parameters — roughly 2% of the ~590k in the full matrix, ~48× compression. Now drag r up to 64 and watch the trainable count and percentage climb linearly while compression falls; then drag d up to 1024 with r fixed and watch the percentage drop (because 2r/d shrinks as d grows — LoRA pays off more on wider layers). Note that no slider ever changes "ΔW at init," which stays 0. Which of the three properties from lesson 00 — accurate, trainable, steerable — does LoRA make cheaper to reach?

Where this points next

Step back. Over fourteen lessons you have built the whole machine from a single character: text became BPE ids, ids became embeddings with positions, a sliding window supplied free targets, attention was derived in three forced steps, the transformer block made depth trainable, a stack plus a head produced text, cross-entropy gave it a notion of wrong, AdamW drove it down, sampling and pretrained weights gave it fluency, and fine-tuning — classification, then instruction, now the cheap low-rank variant — bent it to a task. Every piece was a forced move, and every piece ran at a 124M toy scale. So the last question is the obvious one: what actually changes when you take this exact machine to a frontier model? The architecture, it turns out, barely does. Lesson 15, The whole machine — and what scale changes, replays the entire chain end to end and names precisely where scale lives — data, compute, systems, and the modern component swaps — before handing you off to the tracks that build there.

Takeaway
Full fine-tuning (13) updates and stores all ~124M parameters per task; most of that is redundant because the adaptation ΔW is low-rank. LoRA freezes W and learns ΔW = A·B with A (d×r), B (r×d), r ≪ d, so the forward pass is Wx + (α/r)·A(Bx) and only 2·d·r parameters train — for d=768, r=8 that is ~12k vs ~590k per matrix, ~48× fewer. Initializing B = 0 makes ΔW = 0 at start, so fine-tuning begins at the base model; the α/r factor decouples patch strength from rank. Attach it to the attention projections, keep one tiny swappable adapter per task, and stack it with quantization (QLoRA) to fine-tune large models cheaply. Rank r is the single capacity knob.

Interview prompts

Companion reads: cs336 · 16 Supervised fine-tuning covers LoRA and QLoRA at systems scale; gpt_mini · 03 SFT shows fine-tuning as runnable post-training code.