TL;DR — This post walks you through building a from‑scratch LoRA fine‑tuning library in pure NumPy, covering hand‑derived forward/backward passes, rank/alpha scaling, merged‑weight zero‑overhead inference, and DoRA. The result is a runnable, extensible codebase that showcases low‑level ML systems skill perfect for a CV.
Implementing LoRA (Low‑Rank Adaptation) from the ground up is a rare CV/ML side project that moves beyond “plug‑and‑play” fine‑tuning. It forces you to confront the exact linear‑algebra that underpins modern adapter modules, and the resulting library can be dropped into any NumPy‑based pipeline, benchmarked, and extended with production‑grade features. Hiring managers see a candidate who understands both the theory of parameter‑efficient transfer and the practicalities of merged‑weight inference, gradient flow, and modular design.
Why This Project Stands Out on a CV
- Low‑level ML engineering: You implement the exact matrix factorisation that LoRA papers describe, demonstrating fluency with weight‑gradient coupling and parameter‑count reduction.
- Numerical proficiency: Pure‑NumPy code forces you to handle dtype choices, broadcasting rules, and numerical stability—skills that transfer to any tensor framework.
- Systems thinking: Merged‑weight inference shows you can design zero‑overhead serving paths, a hallmark of production‑ready model serving.
- Research‑to‑code pipeline: Building DoRA (Depth‑wise Rank Adaptation) from scratch proves you can read a paper, translate equations to code, and validate against reference implementations.
Roles that benefit: ML Engineer, Deep Learning Systems Engineer, Research Engineer, and any position where you’ll customize or optimise fine‑tuning pipelines.
Architecture Overview
The library is organised into four core modules that map directly to the LoRA life‑cycle:
BaseModel– wraps an existing weight matrixW₀(shape(out, in)) and provides a cleanforward(x)API.LoRAAdapter– holds two low‑rank factorsA((in, r)) andB((r, out)) plus the scaling factorα/r.Merger– computes the merged weightW̃ = W₀ + (α·B·A)once and caches it for inference, eliminating the adapter overhead at serving time.DoRAHead– optional depth‑wise rank adaptation that normalises the adapter output by the ℓ₂ norm ofB·A, then re‑scales by a learnable coefficient.
BaseModel ──► forward(x) ──► LoRAAdapter ──► (A·B) ──► Merger ──► W̃
│ │
└─────────────── DoRAHead ──────────┘
The Merger step is the key to zero‑overhead inference: after training, the adapter matrices are never materialised during a forward pass; only the merged matrix W̃ is used.
Building It Step by Step
Below are seven concrete steps, each with a runnable NumPy snippet. Copy‑paste them into a file lora_numpy.py and execute python lora_numpy.py to see the numbers.
Step 1 – Initialise the base weight matrix
import numpy as np
in_dim, out_dim = 64, 128 # example dimensions
r = 4 # LoRA rank
alpha = 16 # scaling factor
# Base weight (random, but in practice you’d load from a checkpoint)
W0 = np.random.randn(out_dim, in_dim).astype(np.float32)
print("W0 shape:", W0.shape) # (128, 64)
Step 2 – Initialise LoRA factors A and B
# A: (in, r), B: (r, out)
A = np.random.randn(in_dim, r).astype(np.float32) * np.sqrt(1.0 / in_dim)
B = np.random.randn(r, out_dim).astype(np.float32) * np.sqrt(1.0 / r)
Step 3 – Hand‑derived forward pass
The LoRA output adds a low‑rank correction to the base linear transformation:
def lora_forward(x, W0, A, B, alpha):
"""x: (batch, in_dim)
returns (batch, out_dim)"""
# Base transformation
base = x @ W0.T # (batch, out)
# Low‑rank correction
lora = (x @ A) @ B * (alpha / r) # scale by α/r
return base + lora
Test:
x = np.random.randn(8, in_dim).astype(np.float32) # batch of 8
out = lora_forward(x, W0, A, B, alpha)
print("Output shape:", out.shape) # (8, 128)
Step 4 – Hand‑derived backward pass (gradients)
During training we need dW0, dA, dB. Using the chain rule:
def lora_backward(dout, x, A, B, alpha):
"""
dout: gradient of loss w.r.t. output, shape (batch, out_dim)
returns:
dW0: gradient w.r.t. base weight, shape (out_dim, in_dim)
dA: gradient w.r.t. A, shape (in_dim, r)
dB: gradient w.r.t. B, shape (r, out_dim)
"""
# Gradient flowing into base weight
dW0 = dout.T @ x # (out, batch) @ (batch, in) -> (out, in)
# Gradient for the low‑rank correction
# lora = (x @ A) @ B * s where s = alpha / r
s = alpha / r
# d(lora) w.r.t. B = (x.T @ (dout * s)) @ A.T
dB = (x.T @ (dout * s)) @ A.T # (in, batch) @ (batch, r) -> (in, r) → transpose later
# d(lora) w.r.t. A = (x.T @ ((dout * s) @ B.T)).T
dA = (x.T @ ((dout * s) @ B.T)).T # (batch, in) → (in, batch) → transpose
# Note: dW0 already includes the base path; no extra scaling needed.
return dW0, dA, dB
Test:
dout = np.random.randn(*out.shape).astype(np.float32)
dW0, dA, dB = lora_backward(dout, x, A, B, alpha)
print("dW0 shape:", dW0.shape) # (128, 64)
print("dA shape:", dA.shape) # (64, 4)
print("dB shape:", dB.shape) # (4, 128)
Step 5 – Rank/alpha scaling
The effective rank‑adjusted update is ΔW = (α / r) · B @ A. Verify that the scaling matches the forward implementation:
def merged_weight(W0, A, B, alpha):
"""Return the merged weight W̃ = W0 + (α/r) · B @ A"""
correction = (alpha / r) * (B @ A) # (out, in)
return W0 + correction
W_merged = merged_weight(W0, A, B, alpha)
print("Merged weight shape:", W_merged.shape) # (128, 64)
Step 6 – Zero‑overhead inference with merged weight
After training, you can drop A and B entirely and use W_merged:
def inference_merged(x, W_merged):
"""Pure linear layer, no adapter matrices."""
return x @ W_merged.T
out_merged = inference_merged(x, W_merged)
# Compare with the forward that includes the adapter
out_full = lora_forward(x, W0, A, B, alpha)
print("Max diff merged vs full:", np.max(np.abs(out_merged - out_full))) # should be ~0
Step 7 – DoRA (Depth‑wise Rank Adaptation) integration
DoRA normalises the adapter output by its ℓ₂ norm and multiplies by a learnable scalar γ. Here we illustrate a static γ = 1.0 for demonstration:
def lora_dora_forward(x, W0, A, B, alpha, gamma=1.0):
base = x @ W0.T
lora = (x @ A) @ B * (alpha / r) # raw LoRA correction
norm = np.linalg.norm(B @ A, axis=0, keepdims=True) + 1e-8
dora = gamma * lora / norm # depth‑wise normalisation
return base + dora
out_dora = lora_dora_forward(x, W0, A, B, alpha)
print("DoRA output shape:", out_dora.shape)
All seven steps are now encapsulated in lora_numpy.py. Running the script prints shapes, verifies the merged‑weight equivalence, and shows a working DoRA head.
Running and Testing It
Install dependencies – you only need NumPy:
pip install numpySave the code – create
lora_numpy.pyand paste the seven steps (including the helper functions) into it.Execute – run
python lora_numpy.pyThe terminal should output shapes and a near‑zero maximum difference between the merged‑weight inference and the full forward pass, confirming that the merged weight truly replaces the adapter at inference time.
Unit‑test suite – add a small
pytestfiletest_lora.py:import numpy as np from lora_numpy import lora_forward, merged_weight, inference_merged def test_merged_equivalence(): # Re‑create the matrices from the script W0 = np.random.randn(128, 64).astype(np.float32) A = np.random.randn(64, 4).astype(np.float32) * np.sqrt(1.0/64) B = np.random.randn(4,128).astype(np.float32) * np.sqrt(1.0/4) alpha, r = 16, 4 x = np.random.randn(8,64).astype(np.float32) W_merged = merged_weight(W0, A, B, alpha) out_full = lora_forward(x, W0, A, B, alpha) out_merged = inference_merged(x, W_merged) assert np.allclose(out_full, out_merged, atol=1e-6)Run
pytest test_lora.py -vto ensure the core invariant holds.Optional – Compare with PyTorch – if you have PyTorch installed, you can load a pretrained
torch.nn.Linear, extract its weight, and verify that your NumPy merged weight matches the PyTorch LoRA implementation (up to floating‑point precision). This exercise proves portability.
Extending It: Your Roadmap to Senior‑Level
- Persistence (pickle / npz) – save
A,B,alpha, andrto disk and reload them without re‑initialising. Why it matters: enables checkpointing and resumes training runs. - Mixed‑precision (float16 / bfloat16) – cast matrices to
np.float16and usenp.dotwith thecasting='safe'flag. Why it matters: halves memory footprint on GPUs/TPUs and often speeds up training. - Integration with PyTorch Datasets – wrap the NumPy arrays in
torch.from_numpyand useDataLoaderfor batched training. Why it matters: bridges the gap between pure‑NumPy prototyping and real‑world PyTorch pipelines. - Distributed training with
torch.distributed– shard the base weightW0across ranks and synchronise LoRA gradient updates viaall-reduce. Why it matters: scales the library to multi‑GPU or multi‑node clusters. - Benchmarking against
torch.nn.Linear– measure FLOPs, latency, and memory bandwidth for both the adapter and merged‑weight paths. Why it matters: provides concrete numbers to discuss efficiency in interviews or performance‑review meetings. - CLI entry point – expose
train,merge, andinfersubcommands usingargparseorclick. Why it matters: turns the library from a notebook toy into a reusable tool that can be invoked from CI/CD pipelines.
Each upgrade adds a production‑grade dimension while preserving the core insight: you control every line of the adaptation logic.
Key Takeaways
- Implementing LoRA from scratch forces you to master the exact gradient flow and parameter‑efficiency mathematics that underpin modern fine‑tuning.
- The merged‑weight trick gives you zero‑overhead inference, a pattern that appears in every production model‑serving system.
- DoRA’s norm‑normalisation demonstrates how to augment a low‑rank adapter with adaptive scaling, a technique shown to improve fine‑tuning stability in recent CV papers.
- Pure‑NumPy code is an excellent sandbox for experimenting with dtypes, broadcasting, and numerical stability before moving to framework‑level code.
- A side project that ships a runnable library, unit tests, and a clear extension roadmap signals to hiring managers that you can ship production‑quality ML code, not just notebook experiments.
Further Reading
- LoRA: Low‑Rank Adaptation of Large Language Models – the original paper by Hu et al. that introduced the rank‑α formulation we implemented.
- DoRA: Depth‑wise Rank Adaptation for Efficient Fine‑Tuning – extends LoRA with norm‑based scaling; useful if you want to experiment beyond the static γ = 1.0 demo.
- NumPy Documentation – Linear Algebra – reference for
matmul,linalg.norm, and broadcasting rules used in the code. - PyTorch Linear Layer Docs – for comparing your merged weight against the standard PyTorch linear implementation.
- GitHub – microsoft/LoRA – official reference implementation in PyTorch; great for cross‑checking gradients and alpha scaling.