LoRA Parameter-Efficient Fine-Tuning
Adapt a pretrained model to a new task by training only tiny low-rank adapters while every base weight stays frozen β bit-for-bit unchanged.
This builds on the models you adapt and on Flax NNX state filtering. Comfortable with a transformer / MLP and with state and parameter filtering? Good. LoRA is a different efficiency lever than knowledge distillation β this page contrasts them.
- Why low-rank updates can fine-tune a model with a tiny fraction of the parameters
- Attach adapters with
nnx.LoRALinearand scope training tonnx.LoRAParam - Why adapter-only training needs both
nnx.Optimizer(..., wrt=nnx.LoRAParam)andnnx.value_and_grad(..., argnums=nnx.DiffState(0, nnx.LoRAParam)) - Filter frozen base weights with the intersection
nnx.All(nnx.Param, nnx.Not(nnx.LoRAParam)) - Prove the base is frozen (bit-identical before/after) while adapters learn a new task
See the full, verified implementation: examples/adaptation/lora_finetuning.py
The Motivationβ
Fully fine-tuning a large model means updating β and storing a fresh copy of β every weight for every downstream task. For a model with billions of parameters that is expensive to train and wasteful to serve: one 10 GB checkpoint per task.
LoRA (Low-Rank Adaptation) takes a different route. Freeze the pretrained weights entirely and inject a small, trainable, low-rank update alongside each weight matrix. You train only the adapters β often well under 1% of the parameters β and ship a few megabytes per task instead of a full checkpoint.
Contrast this with knowledge distillation, the other efficiency lever: distillation compresses knowledge into a smaller student network (a new, cheaper model), whereas LoRA keeps the original model intact and bolts on cheap, swappable adapters. Distillation changes the model; LoRA changes almost nothing about it.
The Mathβ
A pretrained linear layer computes with a frozen weight matrix . LoRA hypothesizes that the update needed to adapt to a new task has low intrinsic rank, so it constrains the change to a rank- factorization:
Only and are trained; never moves. The scalar scales the update. At initialization (and is small random), so and the adapted model starts out identical to the pretrained one β training can only improve from the pretrained baseline.
Why "under 1%"? A full weight matrix has parameters; the adapter has . For a attention projection with that is adapter params versus M base params β about 0.4%. The savings grow as the matrices get bigger. (In the toy model below the matrices are tiny, so the adapters are a large fraction β the effect is illustrative, not the payoff.)
Flax's nnx.LoRALinear implements exactly this: it holds the frozen base kernel
as an nnx.Param and the factors as nnx.LoRAParam, and its forward pass
is with initialized to zero.
The Modelβ
nnx.LoRALinear is a drop-in for nnx.Linear that carries adapters. Stack two
of them into an MLP:
import jax, jax.numpy as jnp
from flax import nnx
import optax
class LoRAMLP(nnx.Module):
def __init__(self, in_dim, hidden, out_dim, rank, *, rngs: nnx.Rngs):
self.l1 = nnx.LoRALinear(in_dim, hidden, lora_rank=rank, rngs=rngs)
self.l2 = nnx.LoRALinear(hidden, out_dim, lora_rank=rank, rngs=rngs)
def __call__(self, x):
return self.l2(nnx.relu(self.l1(x)))
Each LoRALinear splits into two kinds of variables. nnx.LoRAParam is a
subclass of nnx.Param, which is the crux of everything below: a naive
"train all nnx.Param" filter would sweep in the adapters and the base. To
select only the frozen base, take the intersection of "is a Param" and "is not a
LoRAParam":
# Frozen base weights = Param AND (NOT LoRAParam).
# Use nnx.All (intersection). The comma form nnx.state(m, A, B) returns TWO
# states, which is not what we want here.
BASE_PARAMS = nnx.All(nnx.Param, nnx.Not(nnx.LoRAParam))
def count_params(model, filt):
return int(sum(x.size for x in jax.tree.leaves(nnx.state(model, filt))))
Pretrain, Then Freeze and Adaptβ
The narrative: pretrain the whole model on task A (a random linear map), then freeze it and adapt to task B (the same inputs, but a rotated and shifted target β a domain shift) by training only the adapters.
Phase 1 β full fine-tuning on task Aβ
Standard training with wrt=nnx.Param updates everything (base + adapters):
@nnx.jit
def pretrain_step(model, optimizer, batch):
def loss_fn(model):
preds = model(batch['x'])
loss = jnp.mean((preds - batch['y']) ** 2)
return loss, preds
(loss, preds), grads = nnx.value_and_grad(loss_fn, has_aux=True)(model)
optimizer.update(model, grads)
return loss, preds
model = LoRAMLP(8, 64, 4, rank=4, rngs=nnx.Rngs(0))
pre_opt = nnx.Optimizer(model, optax.adam(1e-2), wrt=nnx.Param) # trains all
Phase 2 β adapter-only fine-tuning on task Bβ
Now freeze the base. Adapter-only training requires two changes that must
agree, because LoRAParam is a subclass of Param:
- Build the optimizer with
wrt=nnx.LoRAParamso it only holds/updates adapter state. - Restrict the gradient to the adapter subtree with
argnums=nnx.DiffState(0, nnx.LoRAParam).
@nnx.jit
def adapt_step(model, optimizer, batch):
def loss_fn(model):
preds = model(batch['x'])
loss = jnp.mean((preds - batch['y']) ** 2)
return loss, preds
(loss, preds), grads = nnx.value_and_grad(
loss_fn,
argnums=nnx.DiffState(0, nnx.LoRAParam), # gradient only w.r.t. adapters
has_aux=True,
)(model)
optimizer.update(model, grads)
return loss, preds
adapt_opt = nnx.Optimizer(model, optax.adam(1e-2), wrt=nnx.LoRAParam) # adapters only
If you set only one of the two (e.g. keep the default wrt=nnx.Param), the base
weights will move β LoRA's whole promise silently breaks. To make the freeze a
checked invariant, snapshot the base before and after:
def snapshot(model, filt):
return jax.tree.map(lambda a: jnp.array(a), nnx.state(model, filt))
base_before = snapshot(model, BASE_PARAMS)
# ... run adapt_step in a loop on task B ...
base_after = snapshot(model, BASE_PARAMS)
assert all(bool(jnp.array_equal(x, y))
for x, y in zip(jax.tree.leaves(base_before),
jax.tree.leaves(base_after))) # bit-identical
Results / What to Expectβ
Running the example on CPU (defaults are offline and synthetic) prints the parameter split, drives task-A MSE to near zero, then adapts to the shifted task B using only the adapters β while the base stays bit-identical:
$ python adaptation/lora_finetuning.py
Base (frozen) params : 836
LoRA (trainable) params: 560 (40.1% of total)
[pretrain task A]
epoch 0: task-A MSE = 0.0051
[adapt task B β LoRA only]
epoch 0: task-B MSE = 0.0641
[assertions]
task-B MSE decreased: 0.0641 < 21.1328 -> True
base weights bit-identical: True
LoRA weights changed: True
Task-B loss falls from ~21 to ~0.06 by moving only the 560 adapter values; the 836 base parameters are frozen to the bit. (Remember: the 40% adapter share is an artifact of the tiny layers; on a real large model the same recipe puts the adapters well under 1%.)
Common Pitfallsβ
-
β
nnx.Optimizer(model, tx, wrt=nnx.Param)for adapter-only training. Sincennx.LoRAParamis annnx.Param, this trains the base too. β Usewrt=nnx.LoRAParamandargnums=nnx.DiffState(0, nnx.LoRAParam)β both. -
β Setting
wrt=nnx.LoRAParamon the optimizer but leaving the gradient as the default (differentiates all params). The optimizer only applies to adapters, but you still pay to compute base gradients β and a shape mismatch can bite. β Match them: scope the gradient withnnx.DiffState(0, nnx.LoRAParam). -
β Filtering the base with the comma form
nnx.state(m, nnx.Param, nnx.Not(nnx.LoRAParam)), expecting the frozen weights. That returns two separate states. β Take the intersection:nnx.All(nnx.Param, nnx.Not(nnx.LoRAParam)). -
β Expecting the adapted model to differ from the pretrained one at step 0. With , and outputs are identical until you train. β That's by design β LoRA starts from the pretrained baseline and can only add.
-
β Assuming LoRA always saves ">99%" regardless of shape. On small matrices can rival . β The savings scale with matrix size; pick .
Next stepsβ
- Knowledge Distillation β the complementary efficiency lever: compress into a smaller student instead of freezing and adapting.
- Variational Autoencoders (VAE) β more practice
with custom
nnx.value_and_gradtraining loops and losses.
Complete Exampleβ
The full, verified script is at
examples/adaptation/lora_finetuning.py
β a CPU-friendly, offline LoRA demo with synthetic-regression defaults, a
pretrainβfreezeβadapt loop, parameter-count reporting, and frozen-base assertions.
Referencesβ
- Hu et al., LoRA: Low-Rank Adaptation of Large Language Models (2021). arXiv:2106.09685
- Aghajanyan, Zettlemoyer & Gupta, Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning (2020). arXiv:2012.13255
- Dettmers et al., QLoRA: Efficient Finetuning of Quantized LLMs (2023). arXiv:2305.14314