Index Ai Academy
Phase 6 · Modern LLM engineering · Adapting models

LoRA

Fine-tune a big model by training small low-rank updates.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Fine-tuning every weight of an 8-billion-parameter model takes well over 100 GB of GPU memory. Each task you train for also leaves you a new 16 GB copy of the model. LoRA trains less than 1% of the numbers instead. It fits on one GPU, and each task's result is a small file, measured in megabytes, that sits on top of one shared model. LoRA is why small teams can afford to fine-tune open models. It's the usual way people customize them today.

What you'll be able to do

  • Explain how LoRA replaces a full weight update with two small low-rank matrices
  • Count the trainable parameters and memory LoRA saves on a real model size
  • Implement a LoRA layer, fine-tune with it, and merge it back into the weights
  • Choose rank, scaling, target layers and learning rate, and know when QLoRA helps

What it covers

  1. Sticky notes instead of a new book
  2. The formula
  3. Worked example: a 3 × 4 table with a rank-1 adapter
  4. What it saves on a real model
  5. A LoRA layer in code
  6. Merge it, or keep it separate
  7. LoRA against full fine-tuning
  8. Choosing the settings

Sticky notes instead of a new book

Imagine a 1,000-page cookbook. You want to change a few recipes for your restaurant. You could reprint the whole book with your changes. Or you could leave the book as it is and add a few sticky notes. The notes are cheap, and you can swap them for a different set later.

Full fine-tuning is the reprint. It changes every number in the model and saves a complete new copy. LoRA is the sticky notes. The name stands for "low-rank adaptation". Researchers at Microsoft (Hu and colleagues) published it in 2021.

Here's the plan. A model is mostly made of weight tables, one per layer, as you saw in Phase 4. LoRA freezes those tables: training never touches them. Next to each one, it adds a small trainable add-on called an adapter. Only the adapters learn. The adapter's job is to hold the change that fine-tuning would have made to the table.

A big change built from two thin tables

The trick is how the adapter stores that change. A full change to a table with 4,096 rows and 4,096 columns would need 16.8 million numbers. LoRA builds it from two thin tables instead.

  • One table is tall and thin: 4,096 rows but only a few columns.
  • The other is short and wide: a few rows but 4,096 columns.

Multiply them together and you get a full-size 4,096 × 4,096 table. But you only had to store and train the two thin ones.

The size of that thin side is called the rank. It's the few columns of the tall table, and the same number of rows in the wide one. You met rank in the SVD and PCA lesson. It's how many truly separate directions a table holds. A table built from two thin tables can hold no more directions than that thin side allows. A rank-16 adapter for that table needs 16 × (4,096 + 4,096) = 131,072 numbers. That's under 1% of 16.8 million.

Quick check
A rank-8 adapter on a 4,096 × 4,096 table trains how many numbers?
print(8 * (4096 + 4096))
  1. 32768
  2. 65536
  3. 4104
  4. 16777216
Show the answer

65536

Two thin tables, 8 wide, give 8 × 8,192 = 65,536, which prints as 65536. That's about 0.4% of the 16.8 million in the full table.

Why should a thin change be enough? The LoRA authors observed that the changes fine-tuning makes to large models tend to be low-rank. So a thin version captures most of what matters. This was something they measured, not a proof.

In plain words: LoRA keeps the model's weights fixed. For each weight table, it learns the change as two thin tables that multiply into a full-size one.

The formula

Here is the same idea written as math. We'll take the symbols one at a time.

WW is one of the model's weight tables, the frozen one. W′W' (say "W prime") is the table after fine-tuning. BB is the tall, thin table. AA is the short, wide table. The letter rr is the rank, the thin side of both. It's small, usually somewhere from 4 to 64. Finally, α\alpha (alpha) is a number you pick that sets how strong the change is.

W′=W+αrBAW' = W + \frac{\alpha}{r} B A

In plain words: the new table is the old table plus a change. The change is BB times AA, multiplied by the number α/r\alpha / r. Only BB and AA are trained.

Two details matter.

  • BB starts as all zeros. Zero times anything is zero, so at the start the change is nothing. Training begins exactly at the original model and moves away from it gradually.
  • The scale α/r\alpha / r is a plain number, like 2. People often set α\alpha to rr or to twice rr, so the scale is 1 or 2.
Quick check
Why does BB start as all zeros?
  1. To make training slower
  2. So the change starts at nothing, and the model first behaves exactly as it did
  3. Because A is always zero too
  4. To save memory
Show the answer

So the change starts at nothing, and the model first behaves exactly as it did

Training begins from the working model and moves away only as far as the data asks.

To count what LoRA trains, add up the two thin tables. Say the layer takes in dind_{in} numbers (read "d in"). Say it gives out doutd_{out} numbers. Then AA holds r×dinr \times d_{in} numbers, and BB holds dout×rd_{out} \times r.

In plain words: an adapter has r×(dout+din)r \times (d_{out} + d_{in}) trainable numbers. The full table has dout×dind_{out} \times d_{in}. For big tables and a small rank, that's a tiny fraction.

Go deeper The full formula, with sizes

The paper writes the formula with the size of each table attached:

W′=W+αrBA,B∈Rdout×r,  A∈Rr×dinW' = W + \frac{\alpha}{r} B A, \qquad B \in \mathbb{R}^{d_{out} \times r},\; A \in \mathbb{R}^{r \times d_{in}}

Read it as: "BB is a table of ordinary numbers with doutd_{out} rows and rr columns. AA has rr rows and dind_{in} columns." The symbol R\mathbb{R} means "real numbers", the everyday kind with decimals. The sign ∈\in means "is a member of".

Why divide by rr? A bigger rank adds more terms to each entry of BAB A, so the change tends to grow. Dividing by rr keeps its size steadier when you try a different rank. Then you don't have to retune the learning rate every time you change rr.

Worked example

Worked example: a 3 × 4 table with a rank-1 adapter

Take a tiny layer with 4 inputs and 3 outputs. Its frozen table WW is 3 × 4, all ones. Use rank 1, so BB is 3 × 1 and AA is 1 × 4. Pick α=2\alpha = 2, so the scale is α/r=2/1=2\alpha / r = 2 / 1 = 2.

Say training has set BB to the column 1, 2, 3 and AA to the row 1, 0, 2, 1. Then BAB A is a 3 × 4 table. Each row is AA times one entry of BB:

  • row 1: 1 × [1, 0, 2, 1] = [1, 0, 2, 1]
  • row 2: 2 × [1, 0, 2, 1] = [2, 0, 4, 2]
  • row 3: 3 × [1, 0, 2, 1] = [3, 0, 6, 3]

Multiply by the scale 2 to get the change. Its first row is [2, 0, 4, 2]. Add the ones from WW, and the new first row is [3, 1, 5, 3].

The full change has 3 × 4 = 12 numbers. The adapter stored only 3 + 4 = 7. Predict the three printouts, then run it.

import numpy as np

W = np.ones((3, 4))                     # frozen weights: 3 outputs, 4 inputs
B = np.array([[1.0], [2.0], [3.0]])     # tall and thin: 3 rows, 1 column
A = np.array([[1.0, 0.0, 2.0, 1.0]])    # short and wide: 1 row, 4 columns
alpha, r = 2, 1

change = (alpha / r) * B @ A            # full-size change, built from 7 numbers
print(change)
print(W + change)
print("full change:", W.size, "numbers   LoRA:", B.size + A.size, "numbers")
It runs right here in your browser, with nothing to install.

The first table is the change, αrBA\frac{\alpha}{r} B A. Every row is a multiple of [1, 0, 2, 1], which is what rank 1 means: one direction, scaled up or down. The second table is W′W', the old weights plus the change. The last line shows the saving: 7 numbers instead of 12. Here the saving is small. For a 4,096 × 4,096 table at rank 16 it's 131,072 numbers instead of 16,777,216.

Quick check
Predict the output. A layer has 5 inputs and 8 outputs, with a rank-2 adapter.
out_dim, in_dim, r = 8, 5, 2
print(r * (out_dim + in_dim), out_dim * in_dim)
  1. 26 40
  2. 80 40
  3. 40 26
  4. 13 40
Show the answer

26 40

A is 2 × 5 = 10 numbers and B is 8 × 2 = 16, so 26 in all. The full table is 8 × 5 = 40.

What it saves on a real model

Now try a real size: Meta's Llama 3 8B, a model with about 8 billion weights. Here's what you need to know about its shape.

  • Each token travels through the model as a list of 4,096 numbers. That's its width.
  • It has 32 layers, called transformer blocks in Phase 5.
  • Each block has four attention tables: q, k, v and o. The k and v tables are narrower, with 1,024 outputs. That's a memory-saving trick called grouped-query attention.
  • Each block also has three MLP tables: gate, up and down. Their middle width is 14,336.

The code counts LoRA's trainable numbers for each table, at ranks 8, 16 and 64. It compares two choices: adapters on the attention tables only, or on every table. Then it estimates memory.

d, kv, mlp, layers, total = 4096, 1024, 14336, 32, 8.03e9
matrices = {"q": (d, d), "k": (kv, d), "v": (kv, d), "o": (d, d),           # (out, in) per layer
            "gate": (mlp, d), "up": (mlp, d), "down": (d, mlp)}

def lora_params(out_dim, in_dim, r):
    return r * (out_dim + in_dim)                   # B is out × r, A is r × in

for r in (8, 16, 64):
    attention = layers * sum(lora_params(o, i, r) for name, (o, i) in matrices.items() if name in ("q", "k", "v", "o"))
    everything = layers * sum(lora_params(o, i, r) for o, i in matrices.values())
    print(f"rank {r:>2}: attention only {attention / 1e6:5.1f}M ({attention / total:.2%})   all linear layers {everything / 1e6:6.1f}M ({everything / total:.2%})")

print(f"\none full 4096 x 4096 update: {d * d:,} numbers; rank 16: {lora_params(d, d, 16):,}")
gb = 1e9
print(f"memory to fine-tune everything with Adam (about 16 bytes per parameter): {total * 16 / gb:.0f} GB")
adapters = layers * sum(lora_params(o, i, 16) for o, i in matrices.values())
print(f"memory for LoRA (frozen 16-bit weights + rank-16 adapters with Adam): {(total * 2 + adapters * 16) / gb:.0f} GB")

Even with adapters on every table at rank 16, LoRA trains 41.9 million numbers. That's 0.52% of the model. Memory drops a lot too: about 128 GB for full fine-tuning against about 17 GB for LoRA. That's what decides whether the job fits on one GPU.

Why so much for full fine-tuning? Training doesn't only store the weights. For every weight, it also keeps a gradient and two running averages that the Adam optimizer uses. Some of these are kept at extra precision. Together that's around 16 bytes per weight. LoRA stores the frozen weights once, at 2 bytes each. It needs the full 16 bytes only for the small adapters.

The 17 GB leaves out some working memory that training also needs. In practice an 8B model fits on a single GPU with 24 to 48 GB. The saved adapter is a few hundred megabytes at most, instead of a 16 GB copy of the model.

Go deeper Where "16 bytes per weight" comes from

A common setup trains in mixed precision. For each weight it keeps:

  • the weight in 16-bit form: 2 bytes;
  • its gradient in 16-bit form: 2 bytes;
  • a 32-bit master copy of the weight: 4 bytes;
  • Adam's two running averages, 32-bit each: 8 bytes.

That adds up to 16 bytes. Exact setups vary, so treat 16 as a rule of thumb. For 8.03 billion weights, 8.03 × 16 ≈ 128 GB.

Try it now

A rank-16 LoRA on a 4,096 × 4,096 table trains under 1% of the numbers. Raise the rank and see the cost grow only in a straight line.

A LoRA layer in code

Here's an adapter wrapped around one ordinary layer. The class LoRALinear does four things.

  1. It keeps the original layer as base and freezes it. requires_grad_(False) tells PyTorch not to train those numbers.
  2. It creates A with small random numbers.
  3. It creates B full of zeros, so the adapter starts by adding nothing.
  4. In forward, it returns the base layer's answer plus the scaled adapter's answer.

The cell then runs three tests. It checks that the new layer starts out identical to the frozen one. Then it pretends training changed B and merges the adapter into one plain layer. Finally it counts the trainable numbers.

import torch
import torch.nn as nn

class LoRALinear(nn.Module):
    """A frozen Linear layer plus a trainable low-rank update: W x + (alpha / r) B A x."""
    def __init__(self, base, r=4, alpha=8):
        super().__init__()
        self.base = base
        for p in base.parameters():
            p.requires_grad_(False)
        self.A = nn.Parameter(torch.randn(r, base.weight.shape[1]) / r)
        self.B = nn.Parameter(torch.zeros(base.weight.shape[0], r))     # zero: starts as the original layer
        self.scale = alpha / r

    def forward(self, x):
        return self.base(x) + (x @ self.A.T @ self.B.T) * self.scale

torch.manual_seed(0)
layer = LoRALinear(nn.Linear(32, 32))
x = torch.randn(5, 32)
print("starts identical to the frozen layer:", torch.allclose(layer(x), layer.base(x)))

with torch.no_grad():
    layer.B.normal_()                               # pretend training changed B
    merged = nn.Linear(32, 32)
    merged.weight.copy_(layer.base.weight + layer.scale * layer.B @ layer.A)
    merged.bias.copy_(layer.base.bias)
print("merged single layer gives the same output:", torch.allclose(merged(x), layer(x), atol=1e-5))
print("trainable:", sum(p.numel() for p in layer.parameters() if p.requires_grad), "of", sum(p.numel() for p in layer.parameters()))

Both tests print True. The last line shows that only 256 of the layer's 1,312 numbers are trainable: A and B hold 128 each, and the frozen base holds the rest. (torch here is the course's in-browser PyTorch subset.)

Go deeper Why merging gives exactly the same answers

In the formula, the layer turns an input xx into WxW x. With the adapter it gives Wx+αrBAxW x + \frac{\alpha}{r} B A x. Multiplying works the same whether you add the tables first or add the answers afterwards:

Wx+αrBAx=(W+αrBA)xW x + \frac{\alpha}{r} B A x = \left(W + \frac{\alpha}{r} B A\right) x

In plain words: running the old table and the adapter separately, then adding, is the same as adding the adapter into the table once and running that.

With small numbers: if W=3W = 3, the change is 11 and x=2x = 2, then 3×2+1×2=83 \times 2 + 1 \times 2 = 8 and (3+1)×2=8(3 + 1) \times 2 = 8.

The code writes x @ self.A.T @ self.B.T because it stores each input as a row. That's the same calculation as BAxB A x, written for rows.

Quick check
Why is LoRA's B matrix initialized to zero?
  1. So B·A is zero and training starts exactly from the original model's behavior
  2. So the adapter has no parameters
  3. To make the update full rank
  4. Because zero matrices train faster
Show the answer

So B·A is zero and training starts exactly from the original model's behavior

With B = 0 the adapter adds nothing at first. Training then moves it away from the pretrained model gradually.

Merge it, or keep it separate

After training you have two choices.

  • Merge the adapter into the weights. You compute W+αrBAW + \frac{\alpha}{r} B A once and save it as an ordinary model. It runs at exactly the old speed, with no extra cost per token.
  • Keep adapters separate. You load one base model and many small adapters, one per customer or task. Each request uses the adapter it needs.

Back to the cookbook: merging is copying your sticky notes into the pages. Keeping them separate means one shared book, with a different set of notes for each reader.

Quick check
One client wants their own model on its own server, as fast as possible. Another company serves 30 clients from one machine. Which choice fits each?
  1. Neither: LoRA adapters can't be served
  2. Merge the adapter for the single client; keep adapters separate for the 30 clients
  3. Keep adapters separate for the single client; merge all 30 adapters into one model
  4. Merge in both cases
Show the answer

Merge the adapter for the single client; keep adapters separate for the 30 clients

A merged model has no extra cost per token. Separate adapters let 30 clients share one base model, with the right adapter picked per request.

LoRA against full fine-tuning

Now a real contest. The model is tiny-gpt-chat, the course's small chat model. Right now it answers with a full product description. The task is to teach it a new style: answer with the first sentence only. The training data is the same 300 chats as in the SFT lesson.

One copy gets full fine-tuning, so every weight trains. A second copy gets rank-4 LoRA adapters on every weight table inside its transformer blocks. Everything else in that copy stays frozen. Both are then tested on 20 products that aren't in the catalog.

The cell has four parts.

  1. LoRALinear and add_lora. The second one freezes every weight and wraps each table in an adapter.
  2. The data. It builds one chat per product, with the prompt labeled -100 so only the reply counts.
  3. score. It asks about the 20 unseen products and counts the replies that name the product, are one sentence long, and stop properly.
  4. finetune. This is the usual training loop: 201 steps of 16 chats each.

It takes a while to run. The Run button counts the seconds.

import re
import requests
import torch
import torch.nn as nn
from tinygpt import load_pretrained

class LoRALinear(nn.Module):
    def __init__(self, base, r=4, alpha=8):
        super().__init__()
        self.base = base
        self.A = nn.Parameter(torch.randn(r, base.weight.shape[1]) / r)
        self.B = nn.Parameter(torch.zeros(base.weight.shape[0], r))
        self.scale = alpha / r

    def forward(self, x):
        return self.base(x) + (x @ self.A.T @ self.B.T) * self.scale

def add_lora(model):
    for p in model.parameters():
        p.requires_grad_(False)                        # freeze every original weight
    for blk in model.blocks:
        blk.qkv, blk.proj = LoRALinear(blk.qkv), LoRALinear(blk.proj)
        blk.mlp[0], blk.mlp[2] = LoRALinear(blk.mlp[0]), LoRALinear(blk.mlp[2])
    return model

_, tok = load_pretrained("tiny-gpt-chat")
end = tok.token_id("<end>")
docs = requests.get("https://api.gradient.test/v1/corpus").json()["text"].strip().split("\n")
product = re.compile(r"^(?:Meet the|Our|The) ([A-Z][a-z]+) (.+?)(?:,| is| are) made")
rows = []
for d in docs:                                          # the new style: first sentence only
    name, kind = product.match(d).groups()
    prompt = tok.encode(f"<user> Describe the {name} {kind} . <assistant>")
    answer = tok.encode(d.split(". ")[0] + ".") + [end]
    rows.append((prompt + answer, [-100] * len(prompt) + answer))
T = max(len(ids) for ids, _ in rows)
X = torch.tensor([ids + [end] * (T - len(ids)) for ids, _ in rows])
Y = torch.tensor([lab + [-100] * (T - len(lab)) for _, lab in rows])
seen = {product.match(d).groups() for d in docs}
names, kinds = sorted({n for n, _ in seen}), sorted({k for _, k in seen})
unseen = [(n, k) for n in names for k in kinds if (n, k) not in seen][::8]

@torch.no_grad()
def score(model):
    torch.manual_seed(0)
    good = 0
    for n, k in unseen:
        ids = torch.tensor([tok.encode(f"<user> Describe the {n} {k} . <assistant>")])
        reply = tok.decode(model.generate(ids, 30, temperature=0.7, stop_id=end)[0, ids.shape[1]:].tolist())
        good += f"{n} {k}" in reply and reply.endswith("<end>") and reply.count(".") == 1
    return good / len(unseen)

def finetune(model, lr, steps=201):
    trainable = [p for p in model.parameters() if p.requires_grad]
    opt = torch.optim.AdamW(trainable, lr=lr, weight_decay=0.0)
    torch.manual_seed(1)
    for step in range(steps):
        b = torch.randint(0, len(X), (16,))
        logits = model(X[b][:, :-1])
        loss = nn.functional.cross_entropy(logits.reshape(-1, logits.shape[-1]), Y[b][:, 1:].reshape(-1))
        opt.zero_grad()
        loss.backward()
        opt.step()
    return sum(p.numel() for p in trainable)

full, _ = load_pretrained("tiny-gpt-chat")
print(f"before:             {score(full):.0%} of {len(unseen)} new products answered in the one-sentence style")
n = finetune(full, lr=1e-3)
print(f"full fine-tuning:   {score(full):.0%}   ({n:,} parameters trained)")
lora = add_lora(load_pretrained("tiny-gpt-chat")[0])
n = finetune(lora, lr=1e-2)
print(f"LoRA, rank 4:       {score(lora):.0%}   ({n:,} parameters trained)")

LoRA trains about a tenth as many numbers here. It still gets most of the way: 50% of the new products against 65% for full fine-tuning, after the same number of steps. On large models the gap is usually much smaller. A big model needs only a small change for a task like this, compared with its size.

Look at the learning rates too. Full fine-tuning used 0.001 (1e-3), and LoRA used 0.01 (1e-2). The adapters start at zero and are small, so LoRA usually needs a learning rate about 10 times higher.

A trap: new tokens

One problem turned up while building this lesson. The same LoRA setup was tried on the base model, to teach it the chat format from scratch. It barely worked.

The reason was the chat tokens: <user>, <assistant> and <end>. They were new to the base model. Each token's starting vector comes from the embedding table, and that table was frozen. The new tokens' vectors had never been trained, and adapters on other layers couldn't fix that.

With real libraries the fix is straightforward. When you add new special tokens, also train the embedding table and the output layer.

Quick check
In the contest, full fine-tuning used a learning rate of 0.001. LoRA used 0.01. Why the bigger one for LoRA?
  1. A bigger learning rate makes LoRA use less memory
  2. It was a mistake; LoRA should use the same rate
  3. LoRA has more parameters to train
  4. The adapters start at zero and are small, so they need bigger steps to have an effect
Show the answer

The adapters start at zero and are small, so they need bigger steps to have an effect

LoRA typically needs a learning rate around 10 times higher than full fine-tuning, because the adapters begin at zero and stay small.

Choosing the settings

  • Rank rr: 8 to 16 is a common place to start. Raise it for harder, bigger changes. α\alpha is often set to rr or 2r2r.
  • Which tables: the original paper put adapters on the attention tables only. Putting them on every table, attention and MLP, usually works better. The extra cost is small.
  • QLoRA (Dettmers and colleagues, 2023) goes further. It stores the frozen model in 4-bit form, which takes a quarter of the memory of 16-bit. The quantization lesson explains how. LoRA adapters then train on top. With QLoRA, a 65-billion-parameter model could be fine-tuned on a single 48 GB GPU.

In real projects you'd use Hugging Face's PEFT library. Adding adapters to a model takes a few lines:

from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM

model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
config = LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05, task_type="CAUSAL_LM",
                    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"])
model = get_peft_model(model, config)
model.print_trainable_parameters()      # well under 1% of the parameters

Here r=16 is the rank and lora_alpha=32 is α\alpha, so the scale is 2. The list target_modules names the seven tables in each block that get adapters. The result trains with the same SFTTrainer or DPOTrainer you used before.

For marketers: LoRA is why "a fine-tuned model per brand or per client" is affordable. You keep one shared base model. Each brand's voice lives in a small adapter that loads when that brand's request comes in.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

After training a LoRA adapter, what does merging it do?

  1. Adds the scaled B·A into W, giving a normal model with no extra cost per token
  2. Retrains the base model from scratch, using the adapter's weights as a guide
  3. Deletes the base model, and leaves only the adapter's two thin tables to run
  4. Doubles the number of layers, with an adapter layer now after each base layer

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson