§E.1–E.4Parameter-Efficient Fine-Tuning with LoRA

Part III 📎 Appendix pp. 147–154 · ~9 min read

  • LoRA
  • parameter-efficient fine-tuning
  • LoRA rank
  • LoRA alpha

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 (low-rank adaptation) — one of the most widely used parameter-efficient fine-tuning 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:

Wupdated=W+ΔW  LoRA  Wupdated=W+ABW_{\text{updated}} = W + \Delta W \qquad \xrightarrow{\;\text{LoRA}\;} \qquad W_{\text{updated}} = W + AB

symbolmeaningshape
WWa pretrained weight matrix — frozen(d_in × d_out)
ΔW\Delta Wthe full-finetuning update (as large as WW itself)(d_in × d_out)
AALoRA’s “down” matrix, normally initialized(d_in × r)
BBLoRA’s “up” matrix, initialized to zeros(r × d_out)
rrthe rank — the bottleneck width; the efficiency/capacity dialsmall (e.g. 16)
α\alphathe alpha scaling factor — how strongly the bypass influences the layer’s outpute.g. 16
Regular finetuningLoRAPretrainedweights Wd × dWeight updateΔWd × d — as big as W+Inputs x (d) → both paths → OutputsPretrainedweights W 🔒frozen — never modifiedABr — the innerdimension is ahyperparameter+Inputs x → W (frozen) and x → A → B (trained) → summed Outputs
The book’s p. 148 figure: full fine-tuning learns a ΔW as large as W; LoRA pinches the update through a rank-r bottleneck — the narrow waist between A and B is where the parameter savings live.

The separability trick. By the distributive law of matrix multiplication, the update never has to be merged into the weights:

x(W+AB)=xW+xABx(W + AB) = xW + xAB

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

</> LoRALayer — the A·B bypass
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.
</> LinearWithLoRA and the recursive replacement
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:

x self.linear(x) — frozen W 🔒 pretrained knowledge, untouched x @ A down to r dims @ B back up to out × α + output xW + α·xAB only A and B receive gradients — the entire frozen tower learns nothing, and needs no optimizer state
1

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.

1 / 4

One forward pass through LinearWithLoRA. The frozen path preserves pretrained behavior; the bypass learns the task.

Freeze, wrap, count

</> From 124M trainable parameters to 2.7M
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 r(din+dout)r \cdot (d_{in} + d_{out}) parameters — summing over every Linear in this model gives exactly 166,658 · r:

rank rTrainable 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+
LoRA parameters vs rank for the ch6 spam model (formula verified: r=16 reproduces the printed 2,666,528 exactly). Click rows for context.

The payoff

Fine-tuning runs ch6’s train_classifier_simple unchanged (AdamW, lr=5e-5, 5 epochs):

SplitLoRA (2.7M trainable)ch6 approach
Training accuracy (%)
100
97.21+
Validation accuracy (%)
96.64
97.32+
Test accuracy (%)
98
95.67+
LoRA results vs ch6's full-head fine-tuning of the same model on the same task.

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, from torch.tensor to a fine-tuned, parameter-efficient LLM.

📝 Check yourself: LoRA

0 / 5
  1. 1.What is LoRA's core mathematical move?

  2. 2.Why does the distributive law x(W + AB) = xW + xAB matter so much in practice?

  3. 3.Why is B initialized to ZEROS while A gets a normal random initialization?

  4. 4.Predict: with rank=16, alpha=16, replace_linear_with_lora adds 2,666,528 trainable parameters. Roughly what happens at rank=8?

  5. 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?