LoRA Mathematics › Code Implementation
Language AI HandbookAll chapters LoRA Mathematics › Code ImplementationOne section · Sections in this chapter8 / 12 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
We will implement the LoRA mathematics in PyTorch. This shows the forward pass, gradient computation, and initialization. The implementation verifies our theoretical predictions empirically, connecting the mathematical derivations to runnable code that you can experiment with and extend.
import torch
torch.manual_seed(42)import math
import torch.nn as nn
import torch.nn.functional as F
class LoRALinear(nn.Module):
"""Linear layer with LoRA adaptation"""
def __init__(
self,
in_features: int,
out_features: int,
rank: int = 4,
alpha: float = 1.0,
pretrained_weight: torch.Tensor = None,
):
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.rank = rank
self.scaling = alpha / rank
# Frozen pre-trained weight
if pretrained_weight is None:
pretrained_weight = torch.randn(out_features, in_features) * 0.02
self.register_buffer("W0", pretrained_weight)
# LoRA matrices (trainable)
self.A = nn.Parameter(torch.randn(rank, in_features) / math.sqrt(rank))
self.B = nn.Parameter(torch.zeros(out_features, rank))
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Forward pass with LoRA adaptation."""
# Original forward pass
h0 = F.linear(x, self.W0)
# LoRA path: x -> A -> B (with scaling)
z = F.linear(x, self.A) # Shape: (batch, rank)
delta_h = F.linear(z, self.B) # Shape: (batch, out_features)
return h0 + self.scaling * delta_h
def get_merged_weight(self) -> "torch.Tensor":
"""Return W0 + (alpha/r) * BA for inference."""
return self.W0 + self.scaling * (self.B @ self.A)Let's verify the initialization produces zero updates:
# Create a LoRA layer
layer = LoRALinear(in_features=64, out_features=128, rank=8, alpha=8.0)
# Check that BA = 0 at initialization
BA = layer.B @ layer.A
shape_a = layer.A.shape
shape_b = layer.B.shape
shape_ba = BA.shape
max_ba = BA.abs().max().item()Shape of A: torch.Size([8, 64]) Shape of B: torch.Size([128, 8]) Shape of BA: torch.Size([128, 64]) Max absolute value in BA: 0.000e+00
The product is exactly zero because is initialized to zero, confirming our model starts with pretrained behavior. Matrix has dimensions (rank, in_features) for the down-projection, while has dimensions (out_features, rank) for the up-projection. Their product maintains the full weight matrix dimensions but contains only zeros at initialization, meaning the LoRA path contributes nothing to the output until training begins.
Verifying Gradient Flow
Let's trace gradients through a forward-backward pass to confirm the asymmetric gradient behavior we derived mathematically:
# Create layer and input
layer = LoRALinear(in_features=8, out_features=4, rank=2, alpha=2.0)
x = torch.randn(1, 8, requires_grad=True)
# Forward pass
h = layer(x)
input_shape = x.shape
output_shape = h.shape
# Backward pass with a simple loss
loss = h.sum()
loss.backward()
grad_a = layer.A.grad
grad_b = layer.B.gradInput shape: torch.Size([1, 8]) Output shape: torch.Size([1, 4]) Gradient norm of A: 0.000e+00 Gradient norm of B: 7.864e+00
As predicted by our mathematical analysis, matrix 's gradient is zero at initialization because in the gradient formula , while receives non-zero gradients because depends on (which is non-zero) rather than . This asymmetry demonstrates the coupled dynamics derived mathematically: matrix begins learning only after has moved away from zero, creating a brief initial phase where adapts alone before both matrices co-evolve.
Parameter Efficiency Calculation
Let's compute the parameter savings for a realistic scenario corresponding to a real large language model:
def calculate_lora_efficiency(d: int, k: int, r: int, num_layers: int) -> dict:
"""Calculate parameter counts and savings."""
full_params = d * k * num_layers
lora_params = r * (d + k) * num_layers
ratio = lora_params / full_params
return {
"full_params": full_params,
"lora_params": lora_params,
"ratio_percent": ratio * 100,
"compression": 1 / ratio,
}
# LLaMA-7B attention projections: d=k=4096, 32 layers, 4 matrices per layer
results = calculate_lora_efficiency(d=4096, k=4096, r=16, num_layers=32 * 4)LLaMA-7B Attention LoRA Analysis (r=16): Full fine-tuning params: 2,147,483,648 LoRA params: 16,777,216 Percentage of full: 0.781% Compression ratio: 128x
The 128x compression achieves dramatic parameter reduction while maintaining strong adaptation capability. Full finetuning of these attention layers requires 2.1 billion parameters, while LoRA needs only 16.8 million trainable parameters, making finetuning practical on consumer GPUs with 16-24GB of memory. This dramatic reduction enables efficient multi-task serving where different LoRA adapters can be swapped without duplicating the base model weights.
Visualizing Rank Effects
Let's visualize how different ranks affect the approximation capacity, connecting the theoretical analysis of SVD truncation to the practical question of rank selection:
# Create a target matrix that simulates a fine-tuning update
# (low intrinsic rank with some noise)
d, k = 64, 64
true_rank = 8
U_true = torch.randn(d, true_rank)
V_true = torch.randn(true_rank, k)
target_delta_W = U_true @ V_true + 0.1 * torch.randn(d, k)
# Try different LoRA ranks
ranks = [1, 2, 4, 8, 16, 32, 64]
errors = []
for r in ranks:
# Best rank-r approximation via SVD
U, S, Vh = torch.linalg.svd(target_delta_W, full_matrices=False)
approx = (U[:, :r] * S[:r]) @ Vh[:r, :]
error = torch.norm(target_delta_W - approx).item()
errors.append(error)
# Store true_rank for use in visualization
true_rank_value = true_rank
The error drops sharply until we reach the true intrinsic rank of the target matrix (rank 8 in this example), then decreases much more slowly. This illustrates the "knee" in the error curve that practitioners look for when choosing rank: the point at which increasing provides diminishing returns in approximation quality. For this synthetic matrix, captures nearly all the useful structure, while or adds only marginal improvement at much higher parameter cost.
Gradient Magnitude Analysis
Let's examine how gradients evolve during the first few training steps, confirming the bootstrap dynamics we analyzed theoretically:
# Track gradient magnitudes over steps
layer = LoRALinear(in_features=32, out_features=32, rank=4, alpha=4.0)
optimizer = torch.optim.Adam([layer.A, layer.B], lr=0.01)
grad_A_norms = []
grad_B_norms = []
B_norms = []
for step in range(20):
optimizer.zero_grad()
x = torch.randn(16, 32)
target = torch.randn(16, 32)
output = layer(x)
loss = F.mse_loss(output, target)
loss.backward()
grad_A_norms.append(layer.A.grad.norm().item())
grad_B_norms.append(layer.B.grad.norm().item())
B_norms.append(layer.B.data.norm().item())
optimizer.step()

The plot confirms the mathematical analysis. Matrix 's gradient starts at zero and grows as moves away from zero. The coupled dynamics quickly bring both matrices into active training, with both gradient norms reaching comparable magnitudes after just a handful of steps. The smooth, monotonic growth of shows that the zero initialization does not cause instability or slow convergence.
Weight Merging for Inference
LoRA's trained adapters can be merged into the base weights for inference, eliminating any computational overhead from the low-rank decomposition:
# After training, we can merge LoRA into the base weights
layer = LoRALinear(in_features=64, out_features=64, rank=8, alpha=8.0)
# Simulate some training
for _ in range(10):
x = torch.randn(16, 64)
loss = layer(x).sum()
loss.backward()
with torch.no_grad():
layer.A -= 0.01 * layer.A.grad
layer.B -= 0.01 * layer.B.grad
layer.A.grad.zero_()
layer.B.grad.zero_()
# Verify equivalence
x_test = torch.randn(4, 64)
output_separate = layer(x_test)
merged_weight = layer.get_merged_weight()
output_merged = F.linear(x_test, merged_weight)
max_diff = (output_separate - output_merged).abs().max().item()
are_equivalent = torch.allclose(output_separate, output_merged)Max difference: 2.432e-05 Outputs are equivalent: True
After merging, the LoRA matrices can be discarded and replaced with a single weight matrix that has zero inference overhead compared to the original model. The negligible difference confirms that merging is mathematically exact within floating-point accuracy. This property matters enormously for deployment: during development, separate and matrices provide flexibility for experimentation, while production uses merged weights to eliminate computational overhead from the low-rank decomposition. A model fine-tuned with LoRA at rank 16 runs at exactly the same speed as the original model after merging, with no additional latency.