TAI BUI
Back to curriculum
BuildPython

Fine-Tuning with LoRA & QLoRA

Full fine-tuning a 7B model takes about 56GB of VRAM, and you probably don't have that. Neither do most companies. LoRA lets you fine-tune the same model in 6GB by training less than 1% of the parameters, and on most tasks it matches full fine-tuning quality. A large part of the open-source fine-tuning ecosystem is built on this one trick.

TIME: ~75 minutesPREREQUISITES: Phase 10, Lesson 06 (Instruction Tuning / SFT)

Learning Objectives

  • Implement LoRA by injecting low-rank adapter matrices (A and B) into a pretrained model's attention layers
  • Calculate the parameter savings of LoRA vs full fine-tuning: rank r with d_model dimensions trains 2*r*d parameters instead of d^2
  • Fine-tune a model using QLoRA (4-bit quantized base + LoRA adapters) to fit within consumer GPU memory
  • Merge LoRA weights back into the base model for deployment and compare inference speed with and without adapters

The Problem

You have a base model. Llama 3 8B. You want it to answer customer support tickets in your company's voice. SFT is the answer. But SFT has a cost problem.

Full fine-tuning updates every parameter in the model. Llama 3 8B has 8 billion parameters. In fp16, each parameter takes 2 bytes. That's 16GB just to load the weights. During training, you also need gradients (16GB), optimizer states for Adam (32GB for momentum + variance), and activations. Total: 64GB before activations, so a single 8B model needs more than 64GB of VRAM.

An A100 80GB can barely fit this. Two A100s cost $3-4/hour on cloud providers. Training for 3 epochs on 50,000 examples takes 6-10 hours. That's $30-40 per experiment. Run 10 experiments to get the hyperparameters right and you've spent $400 before deploying anything.

Scale this to Llama 3 70B and the numbers get absurd. 140GB for weights alone. You need a cluster. $100+ per experiment.

There's a deeper problem too. Full fine-tuning modifies every weight in the model. If you fine-tune on customer support data, you might degrade the model's general capabilities. It's called catastrophic forgetting. The model gets better at your task and worse at everything else.

You need a method that trains fewer parameters, uses less memory, and doesn't destroy the model's existing knowledge.

Warm-up0/3
1. What does the training objective in supervised fine-tuning (SFT) optimize?Warm-up
One instruction and response pair: the prompt 'Translate to French: good morning' and the response 'bonjour', with a question mark for the quantity the training objective optimizes.
Typed illustrative pair, not taken from a dataset.
2. A matrix of shape (m x r) is multiplied by a matrix of shape (r x n). What is the shape of the product?Warm-up
A tall block labelled m x r, a multiplication sign, a wide block labelled r x n, an equals sign and a dashed box with a question mark for the product.
Symbolic sketch; block sizes are not to scale.
3. What does quantizing a model's weights to 4 bits do?Warm-up
Four example weights, +0.8731, -0.2154, +0.0419 and -1.3207, each stored in 16 bits, an arrow labelled quantize to 4 bits, and a dashed box asking how each weight is stored afterwards.
Illustrative weight values.
Check: The Problem0/2
1. What is catastrophic forgetting in fine-tuning?Define
A pretrained model with general capabilities is trained on customer support tickets only and becomes a fine-tuned model whose general capabilities are marked with a question mark.
Setup from the lesson's Problem section.
2. Why does full fine-tuning of an 8B model need far more VRAM than the 16GB fp16 weight file?Explain
A solid bar for the 16GB fp16 weight file of an 8B model, and a longer dashed bar with a question mark for the VRAM needed during full fine-tuning.
The dashed bar has no scale; its length is not measured.

The Concept

LoRA: Low-Rank Adaptation

Edward Hu and colleagues at Microsoft published LoRA in June 2021. The paper's insight: the weight updates during fine-tuning have low intrinsic rank. You don't need to update all 16.7 million parameters in a 4096x4096 weight matrix. The useful information in the update can be captured by a matrix of rank 16 or 32.

Here's the math. A standard linear layer computes:

y = Wx

Where W is a d_out x d_in matrix. For a 4096x4096 attention projection, that's 16,777,216 parameters.

LoRA freezes W and adds a low-rank decomposition:

y = Wx + BAx

Where B is (d_out x r) and A is (r x d_in). The rank r is much smaller than d, typically 8, 16, or 32.

For r=16 on a 4096x4096 layer:

  • Original parameters: 4096 x 4096 = 16,777,216
  • LoRA parameters: (4096 x 16) + (16 x 4096) = 65,536 + 65,536 = 131,072
  • Reduction: 131,072 / 16,777,216 = 0.78%

You're training 0.78% of the parameters and getting 95-100% of the quality.

Mermaid diagram

A is initialized with a random Gaussian. B is initialized to zero. This means the LoRA contribution starts at zero, so the model begins training from its original behavior and gradually learns the adaptation.

The Scaling Factor: Alpha

LoRA introduces a scaling factor alpha that controls how much the low-rank update affects the output:

y = Wx + (alpha / r) * BAx

When alpha = r, the scaling is 1x. When alpha = 2r (the common default), the scaling is 2x. This hyperparameter controls the learning rate of the LoRA path independently of the base learning rate.

Practical guidance:

  • alpha = 2 * rank is a common community convention (the original paper used alpha = rank in most experiments)
  • alpha = rank gives 1x scaling, conservative but stable
  • Higher alpha means larger updates per step, which can speed convergence or cause instability

Where to Apply LoRA

A transformer has many linear layers. You don't need to add LoRA to all of them. The original paper tested different combinations:

Target LayersTrainable Params (7B)Quality
q_proj only4.7MGood
q_proj + v_proj9.4MBetter
q_proj + k_proj + v_proj + o_proj18.9MBest for attention
All linear (attention + MLP)37.7MMarginal gain, 2x params

The sweet spot for most tasks: q_proj + v_proj. This targets the query and value projections in self-attention, which control what the model attends to and what information it extracts. Adding MLP layers helps for complex tasks like code generation but doubles the parameter count for diminishing returns on simpler tasks.

Rank Selection

The rank r controls the expressiveness of the adaptation:

RankTrainable Params (per layer)Best For
432,768Simple classification, sentiment
865,536Single-domain Q&A, summarization
16131,072Multi-domain tasks, instruction following
32262,144Complex reasoning, code generation
64524,288Diminishing returns for most tasks
1281,048,576Rarely justified

Hu et al. showed that r=4 already captures most of the adaptation for simple tasks. r=8 and r=16 are the most common choices in practice. Going beyond r=64 rarely improves quality and starts to lose LoRA's memory advantage.

QLoRA: 4-Bit Quantization + LoRA

Tim Dettmers and colleagues at the University of Washington published QLoRA in May 2023. The idea: quantize the frozen base model to 4-bit precision, then attach LoRA adapters in fp16 on top.

This changes the memory equation dramatically:

MethodWeight Memory (7B)Training Memory (7B)GPU Required
Full fine-tune (fp16)14GB~56GB1x A100 80GB
LoRA (fp16 base)14GB~18GB1x A100 40GB
QLoRA (4-bit base)3.5GB~6GB1x RTX 3090 24GB

QLoRA makes three technical contributions:

NF4 (Normal Float 4-bit): A new data type designed specifically for neural network weights. Neural network weights follow a roughly normal distribution. NF4 places its 16 quantization levels at the quantiles of a standard normal distribution. This is information-theoretically optimal for normally distributed data. It loses less information than uniform 4-bit quantization (INT4) or standard Float4.

Double quantization: The quantization constants themselves take memory. Each block of 64 weights needs a fp32 scale factor (4 bytes). For a 7B model, that's an extra 0.4GB. Double quantization quantizes these constants to fp8, reducing the overhead to 0.1GB. Small but it adds up.

Paged optimizers: During training, optimizer states (Adam's momentum and variance) can exceed GPU memory on long sequences. Paged optimizers use NVIDIA's unified memory to automatically page optimizer states to CPU RAM when GPU memory is exhausted, and page them back when needed. This prevents OOM crashes at the cost of some throughput.

The Quality Question

Does reducing parameters or quantizing the base hurt quality? The results from multiple papers:

MethodMMLU (5-shot)MT-BenchHumanEval
Full fine-tune (Llama 2 7B)48.36.7214.6
LoRA r=1647.96.6814.0
QLoRA r=16 (NF4)47.56.6113.4
QLoRA r=64 (NF4)48.16.7014.2

LoRA at r=16 is within 1% of full fine-tuning on most benchmarks. QLoRA at r=16 loses another fraction of a percent. QLoRA at r=64 essentially matches full fine-tuning while using 90% less memory.

Real-World Costs

Fine-tuning Llama 3 8B on 50,000 examples (3 epochs):

MethodGPUTimeCost
Full fine-tune2x A100 80GB8 hours~$32
LoRA r=161x A100 40GB4 hours~$8
QLoRA r=161x RTX 4090 24GB6 hours~$5
QLoRA r=16 (Unsloth)1x RTX 4090 24GB2.5 hours~$2
QLoRA r=161x T4 16GB12 hours~$4

QLoRA on a single consumer GPU costs less than a lunch. This is why the open-weight fine-tuning community exploded in 2023 and why most of the training frameworks below support QLoRA out of the box in 2026.

The 2026 PEFT stack

FrameworkWhat it isPick when
Hugging Face PEFTThe canonical LoRA/QLoRA/DoRA/IA3 libraryYou want raw control and your training loop is already on transformers.Trainer
TRLHF's reinforcement-from-feedback trainers (SFT, DPO, GRPO, PPO, ORPO)You need DPO/GRPO after SFT; built on top of PEFT
UnslothTriton-kernel rewrite of the forward/backward passYou want 2-5x speedup + half the VRAM with no accuracy loss; Llama/Mistral/Qwen family
AxolotlYAML-config wrapper over PEFT + TRL + DeepSpeed + UnslothYou want reproducible, version-controlled training runs
LLaMA-FactoryGUI/CLI/API over PEFT + TRLYou want zero-code fine-tuning; 100+ model families supported
torchtuneNative PyTorch recipes, no transformers depYou want minimal deps and your org already standardizes on PyTorch

Rule of thumb: research use or one-off experiment → PEFT. Repeatable production pipeline → Axolotl with Unsloth kernels enabled. Throwaway prototyping → LLaMA-Factory.

Merging Adapters

After training, you have two things: the frozen base model and a small LoRA adapter (typically 10-100MB). You can either:

  1. Keep them separate: Load the base model, load the adapter on top. Swap adapters for different tasks. This is how you serve multiple fine-tuned variants from one base model.

  2. Merge them permanently: Compute W' = W + (alpha/r) * BA and save the result as a new full model. The merged model is the same size as the original. No inference overhead. No adapter to manage.

For serving multiple tasks (customer support adapter, code adapter, translation adapter), keep them separate. For deploying a single specialized model, merge.

Advanced merging techniques for combining multiple adapters:

  • TIES-Merging (Yadav et al. 2023): Trims small-magnitude parameters, resolves sign conflicts, then merges. Reduces interference between adapters.
  • DARE (Yu et al. 2023): Randomly drops adapter parameters before merging and rescales the rest. Surprisingly effective at combining capabilities.
  • Task arithmetic: Simply add or subtract adapter weights. Adding a "code" adapter and a "math" adapter often produces a model good at both.

When NOT to Fine-Tune

Fine-tuning is the third option, not the first.

First: prompt engineering. Write a better system prompt. Add few-shot examples. Use chain-of-thought. This costs nothing and takes minutes. If prompting gets you 80% of the way there, you probably don't need to fine-tune.

Second: RAG. If the model needs to know about your specific data (documents, knowledge base, product catalog), retrieval is cheaper and more maintainable than baking it into weights. See Lesson 06.

Third: fine-tuning. Use this when you need the model to adopt a specific style, format, or reasoning pattern that cannot be achieved through prompting. When you need consistent structured output. When you need to distill a larger model into a smaller one. When latency matters and you can't afford the extra tokens from few-shot prompting.

Mermaid diagram

Check: The Concept0/29
1. What is LoRA?Define
A layer diagram: input x goes through a 4096 x 4096 matrix W on one path and through a 16 x 4096 matrix A then a 4096 x 16 matrix B on a second path, and the two paths are summed into y.
Schematic of one layer with the sizes from the lesson's rank-16 example.
2. What is QLoRA?Define
Horizontal bars of training memory for a 7B model: full fine-tune about 56GB, LoRA about 18GB, QLoRA about 6GB.
Values from the lesson's memory table.
3. In LoRA, what does the rank r determine?Define
A frozen 4096 x 4096 weight W next to a box for a LoRA adapter with r equal to 8, and a question mark for what r determines.
Schematic; the adapter's internal shapes are left out.
4. What is alpha in LoRA?Define
A short LoraConfig code snippet with r=16 and lora_alpha=32, the lora_alpha line highlighted and marked with a question mark.
Excerpt of the Use It config, reduced to two arguments.
5. What is NF4?Define
The setting bnb_4bit_quant_type equal to nf4 above a grid of the 16 possible 4-bit codes from 0000 to 1111, each mapped to a question mark.
Typed grid; the value each code stands for is left open.
6. What is a LoRA adapter after training?Define
The calls trainer.train() and model.save_pretrained('./lora-adapter') above a folder named ./lora-adapter whose saved contents are marked with a question mark.
Excerpt of the lesson's Trainer code.
7. What are the target modules in a LoRA config?Define
A LoraConfig snippet with task_type, r=16, lora_alpha=32, lora_dropout=0.05 and a highlighted target_modules line whose value is elided.
Excerpt of the lesson's Use It config; the target_modules value is left out.
8. What is merging a LoRA adapter?Define
A printed model with two LinearWithLoRA layers wrapping Linear layers, two ReLU layers and a final Linear, followed by the label model after merge with a question mark.
Real print of the lesson's lora.py demo model after inject_lora (captured).
9. What do paged optimizers do?Define
A stacked bar of GPU memory use made of a 4-bit base, adapters, activations and Adam states, extending past a dashed line marking the GPU memory limit.
Illustrative sketch with no measured sizes.
10. Why is B initialized to zero while A gets random values?Explain
A 4 x 2 matrix B filled with zeros next to a 2 x 4 matrix A of random decimal values, and the label BA at step 0 with a question mark.
Illustrative values: B is zero as in the lesson, A is a random draw.
11. What observation lets LoRA train under 1% of a layer's parameters and still keep most of the quality?Explain
Two bars for one 4096 x 4096 layer: the full 16,777,216 parameters, and a sliver for the 131,072 parameters trained by a rank-16 adapter, which is 0.78%, with the question of what makes this possible.
Counts computed from the layer shape and r = 16.
12. Why does LoRA scale its output by alpha / r instead of adding BAx unscaled?Explain
A layer diagram where input x goes through W on one path and through A, B and a multiplier labelled alpha over r on a second path, then both are summed into y.
Schematic.
13. Why does placing NF4's 16 levels at normal quantiles lose less information than evenly spaced INT4 levels?Explain
Two number lines from -1 to 1, one with 16 evenly spaced tick marks labelled INT4 and one with 16 tick marks at normal-distribution quantiles labelled NF4.
Illustrative: quantile midpoints scaled to [-1, 1]; the real NF4 table differs slightly.
14. What problem does double quantization solve?Explain
A wide bar for the 3.5GB of 4-bit weights of a 7B model, the setting bnb_4bit_use_double_quant equal to True, and a question mark for the problem double quantization solves.
Weight size and setting from the lesson.
15. Why does a merged model run with no adapter overhead at inference?Explain
A layer with W on one path and A then B on a second path summed into y, an arrow labelled merge, and a dashed box asking about the layer after merging.
Schematic.
16. Why is q_proj + v_proj the usual starting point instead of adapting every linear layer?Explain
A self-attention box with q_proj, k_proj, v_proj and o_proj, where q_proj and v_proj have orange outlines, next to an MLP layers box, with a legend saying orange is the usual starting point.
Schematic.
17. A LoRA adapter uses rank 16 and alpha 32. By what factor is the BAx update scaled before it is added to Wx?Apply
A layer diagram with W on one path and A then B then a multiplier marked x question mark on a second path, summed into y, for rank 16 and alpha 32.
Schematic.
18. For a 4096x4096 weight matrix, how many parameters does a rank-16 LoRA adapter train?Apply
A large 4096 x 4096 square W, a thin tall matrix B of 4096 x 16 and a thin flat matrix A of 16 x 4096, with a question mark for the number of parameters the adapter trains.
Sketch; thin sides exaggerated.
19. A team moves from r=16 on q_proj and v_proj to r=128 on every linear layer. Quality barely moves while memory use climbs. What explains this?Analyze
A before box with r equal to 16 on q_proj and v_proj and an after box with r equal to 128 on every linear layer, with notes that quality barely moves and memory use climbs.
Setup from the question stem.
20. A QLoRA run trains fine on short sequences but crashes with out-of-memory errors on long ones, even though the 4-bit base fits easily. Which QLoRA ingredient targets this failure?Analyze
A table: with short sequences the QLoRA run trains fine, with long sequences it fails with an out-of-memory error, and the 4-bit base model fits easily.
Setup typed from the question stem.
21. A run with r=16 and alpha=64 has loss spikes, while the same data at alpha=16 trains smoothly. What is the most likely cause?Analyze
Two run cards with the same data: run A with r 16 and alpha 64 whose loss spikes, and run B with r 16 and alpha 16 that trains smoothly, and a question about the most likely cause.
Setup typed from the question stem; no real run is plotted.
22. Using the quality table, QLoRA r=16 scores 47.5 MMLU against 48.3 for full fine-tuning. Which change closes most of that gap while keeping the 4-bit base?Analyze
A number line of MMLU scores from 47.0 to 48.5 with a dot at 47.5 for QLoRA r=16 and a dot at 48.3 for full fine-tuning, and a gap of 0.8 points.
Scores from the stem and the lesson's quality table.
23. A teammate estimates QLoRA memory by assuming the LoRA adapters and their training are also done in 4-bit. What is wrong with that assumption?Analyze
A three-row table of QLoRA parts, the frozen base weights, the LoRA adapter weights and the adapter training, each assumed to be 4-bit by a teammate, with question marks in the actual column.
Setup typed from the question stem.
24. You have one 24GB RTX 4090 and want to fine-tune Llama 3 8B on 50,000 examples. Which setup fits that card?Design
Three boxes: the model Llama 3 8B, 50,000 training examples, and one RTX 4090 GPU with 24GB.
Setup from the question stem.
25. You serve a customer support adapter, a code adapter and a translation adapter. What is the best deployment?Design
Three adapter boxes, customer support, code and translation, and the note that requests for all three tasks arrive at one service, with a question mark for the best deployment.
Setup from the question stem.
26. Your product catalog changes every week and the model must quote current prices. What should you try before fine-tuning?Design
Three boxes for weeks 1 to 3, each marked catalog updated, with the requirement that the model quotes the current price and a question mark for what to try before fine-tuning.
Setup from the question stem.
27. You must fine-tune a model for simple sentiment classification and only have a 16GB T4. Which configuration fits the task and the card?Design
Two boxes: the task, simple sentiment classification, and the GPU, a T4 with 16GB.
Setup from the question stem.
28. A tuned system prompt and few-shot examples still give inconsistent JSON output, and the few-shot tokens hurt latency. What do you do next?Design
Three typed example model outputs for the same prompt with inconsistent JSON: a clean object, an object prefixed with a chatty sentence and with a string age, and an object with different key casing and a trailing comma, plus the note that few-shot tokens add latency.
Typed illustration of inconsistent output; not captured from a model.
29. Your team needs a repeatable, version-controlled fine-tuning pipeline for production with faster kernels. Which stack fits the lesson's rule of thumb?Design
Four requirement chips: production pipeline, repeatable runs, version-controlled, and faster kernels.
Requirements taken from the question stem.

Build It

We implement LoRA from scratch in pure PyTorch, with no PEFT library underneath. You'll build the LoRA layer, inject it into a model, train it, and merge the weights back.

Step 1: The LoRA Layer

python
import torch
import torch.nn as nn
import math
 
class LoRALayer(nn.Module):
    def __init__(self, in_features, out_features, rank=8, alpha=16):
        super().__init__()
        self.rank = rank
        self.alpha = alpha
        self.scaling = alpha / rank
 
        self.A = nn.Parameter(torch.randn(in_features, rank) * (1 / math.sqrt(rank)))
        self.B = nn.Parameter(torch.zeros(rank, out_features))
 
    def forward(self, x):
        return (x @ self.A @ self.B) * self.scaling

A is initialized with scaled random values. B is initialized to zero. The product BA starts at zero, so the model begins with its original behavior.

Step 2: LoRA-Wrapped Linear Layer

python
class LinearWithLoRA(nn.Module):
    def __init__(self, linear, rank=8, alpha=16):
        super().__init__()
        self.linear = linear
        self.lora = LoRALayer(
            linear.in_features, linear.out_features, rank, alpha
        )
 
        for param in self.linear.parameters():
            param.requires_grad = False
 
    def forward(self, x):
        return self.linear(x) + self.lora(x)

The original linear layer is frozen. Only the LoRA parameters (A and B) are trainable.

Step 3: Inject LoRA into a Model

python
def inject_lora(model, target_modules, rank=8, alpha=16):
    for param in model.parameters():
        param.requires_grad = False
 
    lora_layers = {}
    for name, module in model.named_modules():
        if isinstance(module, nn.Linear):
            if any(t in name for t in target_modules):
                parent_name = ".".join(name.split(".")[:-1])
                child_name = name.split(".")[-1]
                parent = dict(model.named_modules())[parent_name]
                lora_linear = LinearWithLoRA(module, rank, alpha)
                setattr(parent, child_name, lora_linear)
                lora_layers[name] = lora_linear
    return lora_layers

First, freeze every parameter in the model. Then walk the model tree, find linear layers matching your target names, and replace them with LoRA-wrapped versions. The LoRA A and B matrices are the only trainable parameters in the entire model.

Step 4: Count Parameters

python
def count_parameters(model):
    total = sum(p.numel() for p in model.parameters())
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    frozen = total - trainable
    return {
        "total": total,
        "trainable": trainable,
        "frozen": frozen,
        "trainable_pct": 100 * trainable / total if total > 0 else 0
    }

Step 5: Merge Weights Back

python
def merge_lora_weights(model):
    for name, module in model.named_modules():
        if isinstance(module, LinearWithLoRA):
            with torch.no_grad():
                merged = (
                    module.lora.A @ module.lora.B
                ) * module.lora.scaling
                module.linear.weight.data += merged.T
            parent_name = ".".join(name.split(".")[:-1])
            child_name = name.split(".")[-1]
            if parent_name:
                parent = dict(model.named_modules())[parent_name]
            else:
                parent = model
            setattr(parent, child_name, module.linear)

After merging, the LoRA layers are gone. The model is the same size as the original with the adaptation baked into the weights. No inference overhead.

Step 6: Simulated QLoRA Quantization

python
def quantize_to_nf4(tensor, block_size=64):
    blocks = tensor.reshape(-1, block_size)
    scales = blocks.abs().max(dim=1, keepdim=True).values / 7.0
    scales = torch.clamp(scales, min=1e-8)
    quantized = torch.round(blocks / scales).clamp(-8, 7).to(torch.int8)
    return quantized, scales
 
def dequantize_from_nf4(quantized, scales, original_shape):
    dequantized = quantized.float() * scales
    return dequantized.reshape(original_shape)

This simulates 4-bit quantization by mapping weights into 16 discrete levels within blocks of 64. Production QLoRA uses the bitsandbytes library for true NF4 on GPU.

Step 7: Training Loop

python
def train_lora(model, data, epochs=5, lr=1e-3, batch_size=4):
    optimizer = torch.optim.AdamW(
        [p for p in model.parameters() if p.requires_grad], lr=lr
    )
    criterion = nn.MSELoss()
 
    losses = []
    for epoch in range(epochs):
        epoch_loss = 0.0
        n_batches = 0
        indices = torch.randperm(len(data["inputs"]))
 
        for i in range(0, len(indices), batch_size):
            batch_idx = indices[i:i + batch_size]
            x = data["inputs"][batch_idx]
            y = data["targets"][batch_idx]
 
            output = model(x)
            loss = criterion(output, y)
 
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
 
            epoch_loss += loss.item()
            n_batches += 1
 
        avg_loss = epoch_loss / n_batches
        losses.append(avg_loss)
 
    return losses

Step 8: Full Demo

python
def demo():
    torch.manual_seed(42)
    d_model = 256
    n_classes = 10
 
    model = nn.Sequential(
        nn.Linear(d_model, 512),
        nn.ReLU(),
        nn.Linear(512, 512),
        nn.ReLU(),
        nn.Linear(512, n_classes),
    )
 
    n_samples = 500
    x = torch.randn(n_samples, d_model)
    y = torch.randint(0, n_classes, (n_samples,))
    y_onehot = torch.zeros(n_samples, n_classes).scatter_(1, y.unsqueeze(1), 1.0)
 
    data = {"inputs": x, "targets": y_onehot}
 
    params_before = count_parameters(model)
 
    lora_layers = inject_lora(
        model, target_modules=["0", "2"], rank=8, alpha=16
    )
 
    params_after = count_parameters(model)
 
    losses = train_lora(model, data, epochs=20, lr=1e-3)
 
    merge_lora_weights(model)
    params_merged = count_parameters(model)
 
    return {
        "params_before": params_before,
        "params_after": params_after,
        "params_merged": params_merged,
        "losses": losses,
    }

The demo creates a small model, injects LoRA into two layers, trains it, and merges the weights back. The parameter count drops from full trainable to ~3.5% trainable (14,336 of 413,706 parameters at rank 8) during LoRA training, then returns to the original architecture after merging.

Check: Build It0/6
1. In the Step 8 demo, inject_lora targets layers "0" and "2" of a model with Linear(256, 512), Linear(512, 512) and Linear(512, 10), at rank 8. How many parameters are trainable?Apply
Five boxes in a row for the demo model: Linear 256 to 512 and Linear 512 to 512 outlined in orange as targets, ReLU layers between them, and a final Linear 512 to 10, with target_modules set to 0 and 2.
Layer sizes from the stem and the Step 8 demo.
2. In quantize_to_nf4, one block of 64 weights has a largest absolute value of 3.5. What integer does a weight of 1.0 in that block become?Apply
The Step 6 quantize_to_nf4 function, wrapped over two lines, with a block whose largest absolute value is 3.5 and a question mark for the integer that a weight of 1.0 becomes.
Code from the lesson's Step 6 (one long line wrapped).
3. In merge_lora_weights, why is the update added to the weight as merged.T?Apply
Two code lines: merged equals A @ B times scaling, and the highlighted line adds merged.T to the linear weight.
Excerpt of the lesson's merge_lora_weights.
4. After train_lora finishes on the model from the Step 8 demo, which statement is true?Apply
Five boxes for the demo model: two LinearWithLoRA layers holding a Linear plus A and B, two ReLU layers, and a plain final Linear 512 to 10, followed by the train_lora call.
Structure from the lesson's Step 8 demo.
5. In Build It Step 6, quantize_to_nf4 maps each block of 64 weights onto 16 integer levels. How does that differ from real NF4?Analyze
The Step 6 quantize_to_nf4 function, wrapped over two lines, followed by the note that it maps a block of 64 weights to 16 integer levels and a question mark for the levels used by bitsandbytes NF4.
Code from the lesson's Step 6 (one long line wrapped).
6. You run the same batch before and after merge_lora_weights and the outputs differ by about 3e-7. A teammate calls it a bug in the merge. Are they right?Analyze
Two rows: the LoRA model and the merged model each run on the same input batch, producing out_before and out_after, whose maximum difference is about 3e-7, with a teammate calling it a bug in the merge.
Setup typed from the question stem.

Use It

With the Hugging Face ecosystem, LoRA on a real model takes about 20 lines:

python
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model, TaskType
 
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-8B")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")
 
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    target_modules=["q_proj", "v_proj"],
)
 
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

For QLoRA, add bitsandbytes quantization:

python
from transformers import BitsAndBytesConfig
 
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)
 
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-3.1-8B",
    quantization_config=bnb_config,
    device_map="auto",
)
 
model = get_peft_model(model, lora_config)

The training loop and the data pipeline stay the same. The base model now lives in 4-bit, the LoRA adapters train in fp16, and the whole thing fits in 6GB.

For training with the Hugging Face Trainer:

python
from transformers import TrainingArguments, Trainer
from datasets import load_dataset
 
dataset = load_dataset("tatsu-lab/alpaca", split="train[:5000]")
 
training_args = TrainingArguments(
    output_dir="./lora-llama",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=2e-4,
    fp16=True,
    logging_steps=10,
    save_strategy="epoch",
    optim="paged_adamw_8bit",
)
 
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=dataset,
)
 
trainer.train()
 
model.save_pretrained("./lora-adapter")

The saved adapter is 10-100MB. The base model stays untouched. You can share adapters on the Hugging Face Hub without redistributing the full model.

Check: Use It0/1
1. The Use It config sets r=16, lora_alpha=32 and target_modules=["q_proj", "v_proj"], then calls get_peft_model. Which weights does training update?Apply
The Use It code: a LoraConfig with r=16, lora_alpha=32, dropout 0.05 and target_modules q_proj and v_proj, followed by get_peft_model, and the question of which weights training updates.
Excerpt of the lesson's Use It code.

Ship It

This lesson produces:

  • outputs/prompt-lora-advisor.md: a prompt that helps you decide LoRA rank, target modules, and hyperparameters for your specific task
  • outputs/skill-fine-tuning-guide.md: a skill that teaches agents the decision tree for when and how to fine-tune
Author's Note

I'd run lora.py before rereading the QLoRA section. It builds a small three-layer MLP, injects rank-8 adapters into layers "0" and "2", and prints the trainable percentage, so the savings from the parameter tables show up as a number you can check. It trains on random inputs with random one-hot targets, which means the loss falling tells you the adapter can fit data and nothing about whether a real fine-tune would be any good.

My bet is that the merge is the step worth reading slowly. The script runs a test batch before and after merge_lora_weights and prints the max output difference, so "no inference overhead" becomes something you watched happen. Mind the transpose: here A is (in, rank) and B is (rank, out), so the product is added into the (out, in) weight as (A @ B).T.

I'd also keep the quantization demo in proportion. quantize_to_nf4 scales each block of 64 by its max and rounds to 16 evenly spaced levels, which is plain absmax int4 and not the normal-quantile levels described in the NF4 section. It is good enough to see the error and the memory trade, but I'd use bitsandbytes for real NF4. quantize_model also writes the dequantized values back into the parameters, so the model you train stays in fp32, and the memory it prints counts each 4-bit value as one byte.

Exercises

  1. Rank ablation study. Run the demo with ranks 2, 4, 8, 16, 32, and 64. Plot final loss vs. rank. Find the point of diminishing returns where doubling the rank no longer halves the loss. For a simple classification task on 256-dim features, this should be around r=8-16.

  2. Target module comparison. Modify inject_lora to target only layer "0", only layer "2", only layer "4", and all three. Train each variant for 20 epochs. Compare convergence speed and final loss. This mirrors the real decision of targeting q_proj vs v_proj vs all linear layers.

  3. Quantization error analysis. Take the trained model's weight matrices before and after quantize_to_nf4 / dequantize_from_nf4. Compute the mean squared error, max absolute error, and the correlation between original and reconstructed weights. Experiment with block_size values of 32, 64, 128, and 256.

  4. Multi-adapter serving. Train two LoRA adapters on different subsets of the data (even indices vs odd indices). Save both adapters. Load the base model once, then swap adapters and verify that each produces different outputs on the same input. This is how production systems serve multiple fine-tuned models from one base.

  5. Merge vs. unmerged inference. Compare the output of the LoRA model before and after merge_lora_weights on the same 100 inputs. Verify the outputs are identical (within floating-point tolerance of 1e-5). Then benchmark inference speed for both. Merged should be slightly faster since it's a single matrix multiply instead of two.

Key Terms

TermWhat people sayWhat it actually means
LoRA"Efficient fine-tuning"Low-Rank Adaptation: freeze base weights, train two small matrices A and B whose product approximates the full weight update
QLoRA"Fine-tune on a laptop"Quantized LoRA: load the base model in 4-bit NF4, train LoRA adapters in fp16 on top, enabling 7B fine-tuning in 6GB VRAM
Rank (r)"How much the model can learn"The inner dimension of the A and B matrices; controls expressiveness vs. parameter count
Alpha"LoRA learning rate"Scaling factor applied to the LoRA output; alpha/r scales the adaptation's contribution to the final output
NF4"4-bit quantization"Normal Float 4: a 4-bit data type with quantization levels at normal distribution quantiles, optimal for neural network weights
Adapter"The small trained part"The LoRA A and B matrices saved as a separate file (10-100MB), loadable on top of any copy of the base model
Target modules"Which layers to LoRA"The specific linear layers (q_proj, v_proj, etc.) where LoRA adapters are injected
Merging"Bake it in"Computing W + (alpha/r) * BA and replacing the original weight, eliminating the adapter overhead at inference
Paged optimizers"Don't OOM during training"Offloading optimizer states (Adam momentum, variance) to CPU when GPU memory is exhausted
Catastrophic forgetting"Fine-tuning broke everything else"When updating all weights causes the model to lose previously learned capabilities

Further Reading

  • Hu et al., "LoRA: Low-Rank Adaptation of Large Language Models" (2021): the original paper introducing the low-rank decomposition method, tested on GPT-3 175B with rank as low as 4
  • Dettmers et al., "QLoRA: Efficient Finetuning of Quantized Language Models" (2023): introduces NF4, double quantization, and paged optimizers, enabling 65B fine-tuning on a single 48GB GPU
  • PEFT library documentation (huggingface.co/docs/peft): the standard library for LoRA, QLoRA, and other parameter-efficient methods in the Hugging Face ecosystem
  • Yadav et al., "TIES-Merging: Resolving Interference When Merging Models" (2023): techniques for combining multiple LoRA adapters without quality degradation
  • Rafailov et al., "Direct Preference Optimization: Your Language Model is Secretly a Reward Model" (NeurIPS 2023): DPO derivation; the preference-tuning stage that comes after SFT, no reward model needed.
  • TRL documentation: official reference for SFTTrainer, DPOTrainer, KTOTrainer, and the integration surface with PEFT/bitsandbytes/Unsloth.
  • Unsloth documentation: fused kernels that double fine-tuning throughput and halve memory; the performance layer under TRL.
  • Axolotl documentation: YAML-configured multi-GPU SFT/DPO/QLoRA trainer; the config-as-code alternative to hand-written scripts.