The book’s final act revisits chapter 6’s spam task with a question production teams ask daily: must fine-tuning touch millions of weights at all? LoRA lora Low-rank adaptation: approximating a layer's weight update as a product of two small matrices (ΔW ≈ AB), trained while the original weights stay frozen. defined in ch. E — open in glossary (low-rank adaptation) — one of the most widely used parameter-efficient fine-tuning parameter-efficient fine-tuning Adapting a large model by training only a small fraction of (or small addition to) its parameters. defined in ch. E — open in glossary techniques — answers no: adapt the model through tiny bypass matrices while every pretrained weight stays frozen.
E.1 The idea: approximate the update, don’t replace the weights
Regular fine-tuning learns a full update matrix for each layer; LoRA learns a low-rank factorization of it:
| symbol | meaning | shape |
|---|---|---|
| a pretrained weight matrix — frozen | (d_in × d_out) | |
| the full-finetuning update (as large as itself) | (d_in × d_out) | |
| LoRA’s “down” matrix, normally initialized | (d_in × r) | |
| LoRA’s “up” matrix, initialized to zeros | (r × d_out) | |
| the rank lora rank The inner dimension r of the A and B matrices — controls how many parameters LoRA adds and how much adaptation capacity it has. defined in ch. E — open in glossary — the bottleneck width; the efficiency/capacity dial | small (e.g. 16) | |
| the alpha lora alpha The scaling factor applied to the low-rank path's output, regulating its influence on the adapted layer. defined in ch. E — open in glossary scaling factor — how strongly the bypass influences the layer’s output | e.g. 16 |
The separability trick. By the distributive law of matrix multiplication, the update never has to be merged into the weights:
The LoRA matrices stay separate — the pretrained model is never modified, and the adapters can be applied on the fly (or swapped per task over a single frozen base model). This is what makes LoRA especially attractive in practice.
E.2–E.3 Same task, same head
The dataset is ch6’s SMS Spam Collection, and the model
prep repeats ch6’s head replacement — model.out_head = torch.nn.Linear(768, 2). Everything new is in how the rest of the model
adapts.
E.4 The implementation — three small classes
import math
class LoRALayer(torch.nn.Module):
def __init__(self, in_dim, out_dim, rank, alpha):
super().__init__()
self.A = torch.nn.Parameter(torch.empty(in_dim, rank))
torch.nn.init.kaiming_uniform_(self.A, a=math.sqrt(5)) #A
self.B = torch.nn.Parameter(torch.zeros(rank, out_dim)) #B
self.alpha = alpha
def forward(self, x):
x = self.alpha * (x @ self.A @ self.B) #C
return x - #A A gets standard (kaiming) initialization — similar to how ordinary Linear weights start.
- #B B starts at ZERO, so A·B = 0 initially: the adapted model begins EXACTLY as the pretrained one, and the adaptation grows from nothing during training.
- #C alpha scales the bypass's contribution — the dial for how strongly the adaptation may influence the original layer's output.
class LinearWithLoRA(torch.nn.Module):
def __init__(self, linear, rank, alpha):
super().__init__()
self.linear = linear #A
self.lora = LoRALayer(
linear.in_features, linear.out_features, rank, alpha
)
def forward(self, x):
return self.linear(x) + self.lora(x) #B
def replace_linear_with_lora(model, rank, alpha):
for name, module in model.named_children():
if isinstance(module, torch.nn.Linear):
setattr(model, name, LinearWithLoRA(module, rank, alpha)) #C
else:
replace_linear_with_lora(module, rank, alpha) #D - #A The ORIGINAL pretrained Linear rides along untouched inside the wrapper.
- #B The distributive law, as code: frozen path + trained bypass, summed. xW + xAB.
- #C Any nn.Linear — the qkv projections, out_proj, both FFN linears, the classification head — gets wrapped in place.
- #D Recursion walks the whole module tree, so all 12 transformer blocks are covered without naming them.
Follow one input through the wrapped layer:
One input, two paths
The input x enters the wrapped layer and is routed BOTH ways — through the original weights and through the LoRA bypass.
One forward pass through LinearWithLoRA. The frozen path preserves pretrained behavior; the bypass learns the task.
Freeze, wrap, count
total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable parameters before: {total_params:,}")
# Total trainable parameters before: 124,441,346
for param in model.parameters():
param.requires_grad = False
total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable parameters after: {total_params:,}")
# Total trainable parameters after: 0 #A
replace_linear_with_lora(model, rank=16, alpha=16)
total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total trainable LoRA parameters: {total_params:,}")
# Total trainable LoRA parameters: 2,666,528 #B - #A Everything frozen — a model that can't learn at all…
- #B …until the wrappers add their A/B pairs, which are born trainable: 2.7M parameters, ~2% of the model. The printed structure confirms EVERY Linear is wrapped — qkv, out_proj, both FFN layers in all 12 blocks, and the out_head.
How does that number depend on the rank? Each wrapped Linear contributes parameters — summing over every Linear in this model gives exactly 166,658 · r:
| rank r⇅ | Trainable LoRA parameters⇅ | % of the 124M model⇅ | |
|---|---|---|---|
| 1 | 166658 | 0.13 | + |
| 4 | 666632 | 0.54 | + |
| 8 | 1333264 | 1.07 | + |
| 16 | 2666528 | 2.14 | + |
| 32 | 5333056 | 4.29 | + |
The payoff
Fine-tuning runs ch6’s train_classifier_simple
unchanged (AdamW, lr=5e-5, 5 epochs):
| Split⇅ | LoRA (2.7M trainable)⇅ | ch6 approach⇅ | |
|---|---|---|---|
| Training accuracy (%) | 100 | 97.21 | + |
| Validation accuracy (%) | 96.64 | 97.32 | + |
| Test accuracy (%) | 98 | 95.67 | + |
The book, closed
LoRA distills the book’s entire arc into one trick: the pretrained knowledge (ch2–5) is precious and frozen; the task adaptation (ch6–7) is cheap and factorized. 2.7 million trainable weights — about 2% — matched a full fine-tune. That’s the last page of the journey: every mechanism opened, every claim run as code, fromtorch.tensor to a fine-tuned,
parameter-efficient LLM.📝 Check yourself: LoRA
0 / 51.What is LoRA's core mathematical move?
2.Why does the distributive law x(W + AB) = xW + xAB matter so much in practice?
3.Why is B initialized to ZEROS while A gets a normal random initialization?
4.Predict: with rank=16, alpha=16, replace_linear_with_lora adds 2,666,528 trainable parameters. Roughly what happens at rank=8?
5.The LoRA run reached train 100.00%, val 96.64%, test 98.00% — vs ch6's 97.21/97.32/95.67. What's the fairest takeaway?