Pretrain a small network on upright MNIST digits, then hand it a task the pretraining never covered: the same digits, rotated \(90^\circ\). We compare full finetuning against LoRA at several ranks \(r\), using only a few hundred rotated examples, which is the realistic setting where finetuning data is scarce.
same model, rotated digits, no finetuning: 11.8%
finetuning on just 300 rotated examples
Full finetuning
Unfreeze every parameter and train on the small rotated set.
def full_finetune(Xft, yft, Xtest, ytest, epochs=30, lr=1e-3): m = copy.deepcopy(model) opt = torch.optim.Adam(m.parameters(), lr=lr)for _ inrange(epochs): opt.zero_grad() loss = lossfn(m(Xft), yft) loss.backward() opt.step()with torch.no_grad(): acc = (m(Xtest).argmax(1) == ytest).float().mean().item()return acc, sum(p.numel() for p in m.parameters()), macc_full, params_full, model_full = full_finetune(Xft_t, yft_t, Xrottest_t, yrottest_t)print(f"full finetuning: accuracy {acc_full:.1%}, trainable parameters {params_full:,}")
full finetuning: accuracy 56.8%, trainable parameters 101,770
How much rank did that update actually use?
Full finetuning was free to move all \(10 \times 128\) entries of the output layer’s weight matrix independently, in any of the \(10\) singular directions available to it. Before we restrict it, let’s see how many of those directions it really used: take the singular values of \(\Delta\mathbf{W} = \mathbf{W}^{\mathrm{finetuned}} - \mathbf{W}\) and check how much of its Frobenius energy the top few carry.
dW = (model_full.fc2.weight - model.fc2.weight).detach().numpy()sv = np.linalg.svd(dW, compute_uv=False)energy = np.cumsum(sv **2) / np.sum(sv **2)print("singular values of dW: "+", ".join(f"{v:.3f}"for v in sv))for k in [1, 2, 4, 8]:print(f"top {k:2d} of {len(sv)} directions hold {energy[k -1]:.1%} of dW's energy")
singular values of dW: 0.382, 0.324, 0.276, 0.215, 0.173, 0.145, 0.120, 0.109, 0.092, 0.077
top 1 of 10 directions hold 31.4% of dW's energy
top 2 of 10 directions hold 54.0% of dW's energy
top 4 of 10 directions hold 80.2% of dW's energy
top 8 of 10 directions hold 96.9% of dW's energy
LoRA finetuning
Now freeze every pretrained weight and let the output layer move only inside a rank-\(r\) subspace: replace its update by \(\Delta\mathbf{W} = \mathbf{B}\mathbf{A}\) with \(\mathbf{B} \in \mathbb{R}^{d \times r}\) and \(\mathbf{A} \in \mathbb{R}^{r \times k}\), here \(d = 10\) and \(k = 128\). As in the reading, \(\mathbf{B}\) starts at zero and \(\mathbf{A}\) starts small and random, so training begins exactly at the pretrained model.
class LoRALayer(nn.Module):def__init__(self, base: nn.Linear, r):super().__init__()self.base = basefor p inself.base.parameters(): p.requires_grad =False k, d = base.in_features, base.out_features # W is d x k, as in the readingself.A = nn.Parameter(torch.randn(r, k) *0.01) # small and randomself.B = nn.Parameter(torch.zeros(d, r)) # B = 0, so BA = 0 at initdef forward(self, x):returnself.base(x) + (x @self.A.T) @self.B.Tclass LoRAMLP(nn.Module):def__init__(self, base_model, r):super().__init__()self.fc1 = base_model.fc1for p inself.fc1.parameters(): p.requires_grad =Falseself.fc2 = LoRALayer(base_model.fc2, r)def forward(self, x):returnself.fc2(torch.relu(self.fc1(x)))def lora_finetune(r, Xft, yft, Xtest, ytest, epochs=60, lr=1e-2): m = LoRAMLP(copy.deepcopy(model), r) trainable = [p for p in m.parameters() if p.requires_grad] opt = torch.optim.Adam(trainable, lr=lr)for _ inrange(epochs): opt.zero_grad() loss = lossfn(m(Xft), yft) loss.backward() opt.step()with torch.no_grad(): acc = (m(Xtest).argmax(1) == ytest).float().mean().item()return acc, sum(p.numel() for p in trainable)ranks = [1, 2, 4, 8, 16]results = [lora_finetune(r, Xft_t, yft_t, Xrottest_t, yrottest_t) for r in ranks]for r, (acc, n_params) inzip(ranks, results):print(f"LoRA r={r:2d}: accuracy {acc:.1%}, trainable parameters {n_params:,}")
full finetuning with 3000 examples: 61.0%
LoRA r= 1 with 3000 examples: 25.1%
LoRA r= 2 with 3000 examples: 34.2%
LoRA r= 4 with 3000 examples: 57.3%
LoRA r= 8 with 3000 examples: 63.9%
LoRA r=16 with 3000 examples: 72.1%
Punchline: with only a few hundred finetuning examples, LoRA at rank \(16\) (about \(2\%\) of full finetuning’s parameter count) matches or beats full finetuning outright. The frozen weights do nothing clever here; a rank-\(16\) adapter simply has far less room to overfit a small finetuning set than the whole network does.