LoRA Implementation: PyTorch Code & PEFT Integration

Michael BrenndoerferDecember 2, 202555 min read

Part of Language AI Handbook

Implement LoRA adapters in PyTorch from scratch. Build modules, inject into transformers, merge weights, and use HuggingFace PEFT for production.

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

LoRA Implementation

Having explored the mathematical foundations of LoRA in the previous chapter, we now turn to the practical challenge of translating those equations into working code. LoRA's appeal comes from its theoretical properties and from how naturally it fits into existing neural network frameworks. A well-designed LoRA module should integrate smoothly with pre-trained models, require minimal changes to training code, and support efficient inference through weight merging. This chapter makes the concepts concrete by building every component from scratch, explaining each design decision as we go.

Think of the task ahead as building an extension to an existing house. The original house, your pre-trained model, has already been built with enormous effort and contains everything needed for general occupancy. Rather than tearing down walls or rebuilding rooms, you add a lightweight prefabricated wing that attaches cleanly to the existing structure and gives the specific space you need. The wing contributes only a tiny fraction of the total square footage, yet it gives exactly the new functionality you were after. When you eventually want to sell the house in its original form, you can remove the wing cleanly, or you can permanently incorporate it so visitors never notice the seam. That is LoRA in a nutshell: a clean, removable, efficient extension to an already-capable model.

This chapter walks through the complete implementation journey. This gives both the conceptual understanding and the working code you need to apply LoRA in your own projects. We start by designing a reusable LoRA module that encapsulates the low-rank decomposition, explaining each design decision along the way. From there, we build the machinery to inject LoRA into existing transformer layers. This makes possible you to adapt any compatible model with just a few lines of code. We then construct a training loop that updates only the adaptation parameters. This shows the memory efficiency that makes LoRA practical for limited hardware. The chapter proceeds to implement weight merging for deployment, showing how to eliminate runtime overhead when moving to production. Finally, we conclude with practical guidance on using HuggingFace's PEFT library, which gives production-ready implementations of these concepts and integrates with the broader transformers ecosystem.

Two complementary goals drive the structure of this chapter. The first is deep understanding: you should leave knowing exactly why every line of the implementation exists, not just how to copy and paste it. The second is practical competence: you should feel confident adapting LoRA to your own models, choosing sensible hyperparameters, and debugging problems when they arise. A purely theoretical treatment leaves you uncertain about implementation details; a purely mechanical walkthrough leaves you unable to adapt when your model architecture differs from the examples. We pursue both goals simultaneously.

Historical Context

LoRA was introduced by Edward Hu and colleagues at Microsoft Research in 2021 in the paper "LoRA: Low-Rank Adaptation of Large Language Models." At the time, fine-tuning large language models required updating all model parameters, which demanded enormous GPU memory for both the parameter gradients and the optimizer states. The authors drew inspiration from earlier work showing that over-parameterized neural networks tend to reside in a low-dimensional space during training, a property they called "intrinsic dimensionality." They hypothesized that this same low-dimensionality would apply to the weight updates needed during fine-tuning. Their experiments on GPT-2 and GPT-3 confirmed that even ranks as low as 4 could match the quality of full fine-tuning on many tasks, while using just 0.01% to 0.1% of the parameters. The method gained enormous popularity in the open-source community because it made billion-parameter model fine-tuning accessible on consumer hardware, letting the explosion of instruction-tuned and domain-specialized models that followed in 2022 and 2023.

LoRA Module Design

The core building block of any LoRA implementation is a module that wraps an existing linear layer and adds the low-rank adaptation path. This wrapper pattern is basic to LoRA's design philosophy: rather than modifying the original model architecture, we augment it with a parallel pathway that learns task-specific adjustments. Think of it as a person who has already internalized a large library of general knowledge, and who adds a small notebook of task-specific reminders on top of that foundation. The notebook doesn't replace the underlying memory, it supplements it, and it can be discarded when working on a different task.

Recall from the previous chapter that LoRA modifies a weight matrix WW by adding the product of two smaller matrices. Before writing any code, we should internalize exactly what this formula is and why each component is shaped the way it is.

The core weight update formula is:

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

where:

  • W′W': the modified weight matrix utilized in the forward pass
  • WW: the original pre-trained weight matrix (frozen), with shape dout×dind_{\text{out}} \times d_{\text{in}}
  • BB: the projection-up matrix (Rdout×r\mathbb{R}^{d_{\text{out}} \times r}), initialized to zeros
  • AA: the projection-down matrix (Rr×din\mathbb{R}^{r \times d_{\text{in}}}), initialized with random noise
  • rr: the low rank of the adaptation, typically 4 to 64
  • α\alpha: the scaling factor that controls the adaptation strength

To understand why this decomposition is the right shape, consider what the full weight update ΔW\Delta W would look like if we allowed unconstrained fine-tuning. For a layer with input dimension din=1024d_{\text{in}} = 1024 and output dimension dout=1024d_{\text{out}} = 1024, the full update matrix has 1024×1024=1,048,5761024 \times 1024 = 1{,}048{,}576 parameters. By imposing the constraint that ΔW=BA\Delta W = BA where r=8r = 8, we replace that million-parameter update with an 8,192-parameter one: A∈R8×1024A \in \mathbb{R}^{8 \times 1024} contributes 8×1024=8,1928 \times 1024 = 8{,}192 parameters and B∈R1024×8B \in \mathbb{R}^{1024 \times 8} contributes another 1024×8=8,1921024 \times 8 = 8{,}192, for a total of 16,38416{,}384 parameters compared to the original million. The rank-8 constraint says: "the update I need can be described by eight basis directions in weight space, not a thousand."

Understanding the role of each component helps clarify why this decomposition works. The original weight WW remains frozen throughout training, preserving all the knowledge the model acquired during pre-training. This frozen foundation gives stability and prevents catastrophic forgetting of general capabilities. Meanwhile, the matrices AA and BB are the only trainable parameters, forming a low-rank "bottleneck" through which all adaptation must pass. The matrix AA projects the input down to a much smaller dimension rr, capturing the most relevant features for the adaptation task. The matrix BB then projects back up to the output dimension, translating these compressed representations into modifications to the layer's output.

This decomposition assumes that weight updates have a low "intrinsic rank." This means a low-rank matrix can approximate the difference between a pre-trained and fine-tuned model. This hypothesis has strong empirical support: we have observed that fine-tuning typically modifies weights in structured ways that don't require the full expressiveness of dense updates. The scaling factor α/r\alpha/r ensures that update magnitudes stay consistent across different ranks. When you increase rank to capture more complex adaptations, the scaling automatically compensates by reducing the per-parameter contribution, maintaining stable learning dynamics regardless of your rank selection.

The key insight is that the product BABA always has rank at most rr, no matter how many gradient steps we take. This is a basic property of matrix multiplication: the rank of a product is bounded by the minimum rank of its factors. By choosing r≪min⁡(din,dout)r \ll \min(d_{\text{in}}, d_{\text{out}}), we force all adaptation to happen within a low-dimensional subspace of the full weight space. The model cannot escape this subspace no matter how long it trains, which is both a constraint and a useful prior.

Let's implement this as a PyTorch module:

In[3]:
Code
import math

import torch
import torch.nn as nn
import torch.nn.functional as F


class LoRALinear(nn.Module):
    """
    A linear layer with Low-Rank Adaptation (LoRA).

    Wraps an existing linear layer and adds a parallel low-rank path.
    The original weights are frozen, only A and B matrices are trained.
    """

    def __init__(
        self,
        original_layer: nn.Linear,
        rank: int = 4,
        alpha: float = 1.0,
        dropout: float = 0.0,
    ):
        super().__init__()

        self.original_layer = original_layer
        self.rank = rank
        self.alpha = alpha
        self.scaling = alpha / rank

        # Get dimensions from the original layer
        self.in_features = original_layer.in_features
        self.out_features = original_layer.out_features

        # Freeze original weights
        for param in self.original_layer.parameters():
            param.requires_grad = False

        # Initialize LoRA matrices
        # A: in_features -> rank (initialized with Kaiming)
        # B: rank -> out_features (initialized with zeros)
        self.lora_A = nn.Parameter(torch.empty(rank, self.in_features))
        self.lora_B = nn.Parameter(torch.zeros(self.out_features, rank))

        # Initialize A with Kaiming uniform
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))

        # Optional dropout on LoRA path
        self.dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity()

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Original path (frozen)
        original_output = self.original_layer(x)

        # LoRA path: x @ A^T @ B^T * scaling
        lora_output = self.dropout(x)
        lora_output = F.linear(lora_output, self.lora_A)  # x @ A^T
        lora_output = F.linear(lora_output, self.lora_B)  # (x @ A^T) @ B^T
        lora_output = lora_output * self.scaling

        return original_output + lora_output

Several design decisions merit careful explanation, as they directly affect the behavior and effectiveness of the adaptation. First, we initialize BB with zeros and AA with Kaiming uniform initialization. This asymmetric initialization strategy ensures that at the start of training, the LoRA contribution is exactly zero because BA=0BA = 0 when BB contains only zeros. As a result, the model behaves identically to the pre-trained version before any training occurs. This initialization strategy prevents any initial degradation in model quality and gives a clean starting point where all changes come from learned adaptations rather than random noise. The Kaiming initialization for AA follows standard practice for layers that will be trained. This keeps appropriate variance for gradient flow.

Second, the scaling factor α/r\alpha/r controls the magnitude of the LoRA updates in a principled way. This scaling emerges from thinking about how the total update magnitude should behave as rank changes. Without scaling, doubling the rank would roughly double the magnitude of updates, since you are summing over more dimensions. The α/r\alpha/r scaling compensates for this effect, which makes it easier to tune other hyperparameters like learning rate without worrying about rank-dependent interactions. In practice, α\alpha is often set equal to the rank or to a fixed value like 16 or 32, depending on how aggressively you want the adaptation to influence the model's behavior.

Third, note the separation between original_output and lora_output in the forward pass. These are computed independently, which means the gradient flows through the LoRA path without touching the frozen original layer. PyTorch's autograd engine tracks which tensors have requires_grad=True and only computes gradients for those. Since original_layer.weight has requires_grad=False, no gradient is ever computed for it, saving both memory and computation.

Let's verify our module works correctly:

In[4]:
Code
# Create a base linear layer
base_layer = nn.Linear(768, 768)

# Wrap it with LoRA
lora_layer = LoRALinear(base_layer, rank=8, alpha=8.0)

# Test forward pass
test_input = torch.randn(2, 10, 768)  # batch=2, seq_len=10, hidden=768
output = lora_layer(test_input)

# Calculate parameter counts
base_params = sum(p.numel() for p in base_layer.parameters())
lora_params = lora_layer.lora_A.numel() + lora_layer.lora_B.numel()
percentage = (lora_params / base_params) * 100
Out[5]:
Console
Input shape:  torch.Size([2, 10, 768])
Output shape: torch.Size([2, 10, 768])

Original layer parameters: 590,592
LoRA A parameters: 6,144
LoRA B parameters: 6,144
Total LoRA parameters: 12,288
Parameters added: 2.1%

The parameter counts show LoRA's efficiency and explain why this technique is popular for adapting large models. The LoRA adaptation adds a very small fraction of parameters compared to the original dense layer. To understand why, consider the arithmetic: a dense layer with input and output dimensions of 768 has 768×768=589,824768 \times 768 = 589{,}824 parameters (plus bias). A rank-8 LoRA adds only 768×8+8×768=12,288768 \times 8 + 8 \times 768 = 12{,}288 parameters, representing just over 2% of the original. This ratio improves as model dimensions grow, making LoRA especially effective for large language models where hidden dimensions commonly reach 4096 or higher.

Out[6]:
Visualization
Bar chart with four bars showing parameter counts: Original Layer (frozen) at 589,824, LoRA A and LoRA B each at 6,144, and Total LoRA at 12,288 parameters.
Bar chart comparing parameter counts for a standard linear layer versus its LoRA components. The low-rank matrices A and B together add only about 2% of the original layer's parameters, letting extremely efficient fine-tuning.

Verifying Initial Behavior

Since BB is initialized to zeros, the LoRA layer should produce identical outputs to the original layer at initialization. This property is needed for so that applying LoRA doesn't accidentally degrade a model's capabilities before training begins. Without this guarantee, applying LoRA would immediately change the model's behavior, which makes it unclear whether any subsequent performance changes were due to fine-tuning or the LoRA insertion itself. The zero-initialization removes this confound entirely.

Let's verify this guarantee holds in practice:

In[7]:
Code
# Compare outputs before any training
original_output = base_layer(test_input)
lora_output = lora_layer(test_input)
difference = (original_output - lora_output).abs().max()
Out[8]:
Console
Maximum difference at initialization: 0.00e+00

The negligible difference, caused only by floating-point precision limits, confirms that our zero-initialization strategy works as designed. The model will produce exactly the same outputs as before LoRA was applied, giving you confidence that you can add LoRA to any pre-trained model without risking immediate performance degradation. This clean starting point is one of the most underappreciated aspects of LoRA's design. It means you can instrument your model with adapters before deciding which task to fine-tune for, then begin training without any warmup period to recover initial performance.

Worked Example: Manual Forward Pass

To build intuition for what the LoRA forward pass computes, let's trace through a minimal example by hand. Suppose we have a layer with din=4d_{\text{in}} = 4, dout=4d_{\text{out}} = 4, and rank r=2r = 2, and consider a single input vector (ignoring batches for simplicity).

Setup. The original weight matrix W∈R4×4W \in \mathbb{R}^{4 \times 4} is frozen. We have learned adaptation matrices:

A=(0.2−0.10.30.0−0.10.40.10.2)∈R2×4A = \begin{pmatrix} 0.2 & -0.1 & 0.3 & 0.0 \\ -0.1 & 0.4 & 0.1 & 0.2 \end{pmatrix} \in \mathbb{R}^{2 \times 4} B=(0.50.1−0.30.20.1−0.40.20.3)∈R4×2B = \begin{pmatrix} 0.5 & 0.1 \\ -0.3 & 0.2 \\ 0.1 & -0.4 \\ 0.2 & 0.3 \end{pmatrix} \in \mathbb{R}^{4 \times 2}

With α=1\alpha = 1 and r=2r = 2, the scaling factor is α/r=0.5\alpha/r = 0.5.

Input. Consider the input vector x=[1.0, 0.5, −1.0, 0.0]T\mathbf{x} = [1.0,\ 0.5,\ -1.0,\ 0.0]^T.

Step 1: Project down with AA. We compute Ax∈R2A\mathbf{x} \in \mathbb{R}^2:

(Ax)1=0.2(1.0)+(−0.1)(0.5)+0.3(−1.0)+0.0(0.0)=0.2−0.05−0.3=−0.15(Ax)2=(−0.1)(1.0)+0.4(0.5)+0.1(−1.0)+0.2(0.0)=−0.1+0.2−0.1=0.0\begin{aligned} (A\mathbf{x})_1 &= 0.2(1.0) + (-0.1)(0.5) + 0.3(-1.0) + 0.0(0.0) = 0.2 - 0.05 - 0.3 = -0.15 \\ (A\mathbf{x})_2 &= (-0.1)(1.0) + 0.4(0.5) + 0.1(-1.0) + 0.2(0.0) = -0.1 + 0.2 - 0.1 = 0.0 \end{aligned}

So Ax=[−0.15, 0.0]TA\mathbf{x} = [-0.15,\ 0.0]^T. Notice that this intermediate vector lives in R2\mathbb{R}^2, not R4\mathbb{R}^4: the rank-2 bottleneck is already doing its job, compressing the 4-dimensional input into 2 dimensions.

Step 2: Project back up with BB. We compute B(Ax)∈R4B(A\mathbf{x}) \in \mathbb{R}^4:

(BAx)1=0.5(−0.15)+0.1(0.0)=−0.075(BAx)2=(−0.3)(−0.15)+0.2(0.0)=0.045(BAx)3=0.1(−0.15)+(−0.4)(0.0)=−0.015(BAx)4=0.2(−0.15)+0.3(0.0)=−0.030\begin{aligned} (BA\mathbf{x})_1 &= 0.5(-0.15) + 0.1(0.0) = -0.075 \\ (BA\mathbf{x})_2 &= (-0.3)(-0.15) + 0.2(0.0) = 0.045 \\ (BA\mathbf{x})_3 &= 0.1(-0.15) + (-0.4)(0.0) = -0.015 \\ (BA\mathbf{x})_4 &= 0.2(-0.15) + 0.3(0.0) = -0.030 \end{aligned}

So BAx=[−0.075, 0.045, −0.015, −0.030]TBA\mathbf{x} = [-0.075,\ 0.045,\ -0.015,\ -0.030]^T.

Step 3: Apply scaling. We multiply by α/r=0.5\alpha/r = 0.5:

αrBAx=[−0.0375, 0.0225, −0.0075, −0.015]T\frac{\alpha}{r} BA\mathbf{x} = [-0.0375,\ 0.0225,\ -0.0075,\ -0.015]^T

Step 4: Add to original output. We add this to the output of the frozen layer WxW\mathbf{x}. Suppose the frozen layer produces Wx=[0.5, −0.2, 0.8, 0.1]TW\mathbf{x} = [0.5,\ -0.2,\ 0.8,\ 0.1]^T. The final output is:

y=Wx+αrBAx=[0.5−0.0375, −0.2+0.0225, 0.8−0.0075, 0.1−0.015]T\mathbf{y} = W\mathbf{x} + \frac{\alpha}{r}BA\mathbf{x} = [0.5 - 0.0375,\ -0.2 + 0.0225,\ 0.8 - 0.0075,\ 0.1 - 0.015]^T y=[0.4625, −0.1775, 0.7925, 0.085]T\mathbf{y} = [0.4625,\ -0.1775,\ 0.7925,\ 0.085]^T

The adaptation is small but structured: it shifts the output in a direction determined by the learned matrices AA and BB. As training proceeds, these matrices adjust to push outputs in whatever direction reduces the task loss, always constrained to the rank-2 subspace. You can see intuitively why higher rank lets richer adaptations: a rank-8 bottleneck gives the model eight independent directions in which to modify outputs, while rank-1 restricts it to a single direction.

Key Parameters

Understanding the key parameters for our LoRALinear implementation helps you make informed decisions when applying LoRA to your own models. Each parameter is a different tradeoff between expressiveness and efficiency.

The three main parameters are:

  • rank: The rank rr of the low-rank decomposition determines the size of matrices AA and BB and thus controls the capacity of the adaptation. Lower ranks use fewer parameters and memory but may limit the complexity of adaptations the model can learn. Higher ranks give more expressiveness but increase computational cost. For most instruction-following and style-transfer tasks, ranks between 4 and 16 work well. For domain adaptation that requires new knowledge, ranks of 32 to 64 may be necessary.
  • alpha: The scaling factor α\alpha controls the magnitude of the adaptation updates relative to the original layer outputs. Higher values make the LoRA contribution more influential, effectively amplifying the learning rate for the adaptation pathway. A common heuristic is to set α=r\alpha = r (giving a scaling of 1.0) or α=2r\alpha = 2r (giving a scaling of 2.0). Setting alpha very high relative to rank can destabilize training by making the adaptation dominate the original weights.
  • dropout: The probability of zeroing elements in the LoRA path during training, which helps prevent overfitting when fine-tuning on small datasets. This regularization applies only to the adaptation pathway, leaving the frozen base model unaffected. A dropout value of 0.05 to 0.1 is common for datasets with fewer than 10,000 examples; for larger datasets, you can set dropout to 0.

The interaction between rank and alpha deserves special attention. If you double the rank from r=4r=4 to r=8r=8 while keeping α\alpha fixed, the effective scaling drops from α/4\alpha/4 to α/8\alpha/8, halving the learning rate for the adaptation. To maintain the same effective scale, you should also double alpha. This is why many practitioners set α=r\alpha = r as a default: it keeps the scaling constant at 1.0 regardless of the chosen rank, making rank purely a capacity knob rather than a combined capacity-and-learning-rate knob.

Integrating LoRA into Transformer Models

Real transformer models contain many linear layers distributed throughout their architecture: the query, key, and value projections in each attention head, the output projection that recombines attention results, and the feed-forward networks that process each position independently. A typical LoRA configuration targets the attention projections, as these capture the most task-relevant transformations by controlling what information the model attends to and how it combines that information.

Think of the decision about which layers to target as similar to deciding which parts of a recipe to modify when adapting a dish for a new cuisine. The spices and seasonings, analogous to the attention query and value projections, have the most influence on flavor and are the natural place to start. The structural components like the cooking method and base ingredients, analogous to the feed-forward networks and embeddings, matter too, but changing them has broader effects that are harder to control. LoRA practitioners typically start with attention projections and add more target layers only when the initial adaptation falls short.

Applying LoRA to a full model requires identifying and replacing layers without manually editing the architecture. We need an automated approach that can traverse the model's module hierarchy, find layers matching our target criteria, and wrap them with LoRA adapters while preserving the model's structure. The traversal must handle the hierarchical naming scheme that PyTorch uses for nested modules, where a query projection might live at a path like layers.0.attention.q_proj.

Let's create a utility function that recursively replaces specified linear layers with LoRA versions:

In[9]:
Code
def inject_lora(
    model: nn.Module,
    target_modules: list[str],
    rank: int = 4,
    alpha: float = 1.0,
    dropout: float = 0.0,
) -> nn.Module:
    """
    Inject LoRA adapters into specified modules of a model.

    Args:
        model: The base model to modify
        target_modules: List of module name patterns to target
        rank: LoRA rank
        alpha: LoRA scaling factor
        dropout: Dropout probability on LoRA path

    Returns:
        Modified model with LoRA layers injected
    """
    # Collect modules to replace first to avoid modifying graph during traversal
    modules_to_replace = []
    for name, module in model.named_modules():
        if any(target in name for target in target_modules) and isinstance(
            module, nn.Linear
        ):
            modules_to_replace.append((name, module))

    for name, module in modules_to_replace:
        # Create LoRA wrapper
        lora_layer = LoRALinear(module, rank=rank, alpha=alpha, dropout=dropout)

        # Replace the module in the parent
        # Navigate to parent module
        parts = name.split(".")
        parent = model
        for part in parts[:-1]:
            if part.isdigit():
                parent = parent[int(part)]
            else:
                parent = getattr(parent, part)

        # Replace the layer
        if parts[-1].isdigit():
            parent[int(parts[-1])] = lora_layer
        else:
            setattr(parent, parts[-1], lora_layer)

    return model

This function implements a recursive traversal strategy that examines every module in the model hierarchy. For each module, it checks whether the module name contains any of the target patterns, allowing flexible specification of which layers to adapt. The function then verifies that the module is a linear layer (since LoRA applies specifically to linear transformations), creates a LoRA wrapper around it, and performs the replacement by locating the parent module and updating the attribute. This pattern-based approach means you can target layers by their role (like q_proj for query projections) rather than their exact position in the model structure.

One subtle but important design choice is that we collect all modules to replace before performing any replacements. If we replaced modules during traversal, the iterator could become confused by changes to the module graph mid-traversal, leading to missed replacements or double-replacements. Separating the collection phase from the replacement phase avoids this pitfall entirely. This two-pass approach is a common pattern in code that modifies a data structure while iterating over it.

Let's show this with a simplified transformer block that contains the needed components found in production models:

In[10]:
Code
class SimpleAttention(nn.Module):
    """Simplified attention for demonstration."""

    def __init__(self, hidden_size: int, num_heads: int):
        super().__init__()
        self.hidden_size = hidden_size
        self.num_heads = num_heads
        self.head_dim = hidden_size // num_heads

        self.q_proj = nn.Linear(hidden_size, hidden_size)
        self.k_proj = nn.Linear(hidden_size, hidden_size)
        self.v_proj = nn.Linear(hidden_size, hidden_size)
        self.o_proj = nn.Linear(hidden_size, hidden_size)

    def forward(self, x):
        batch, seq_len, _ = x.shape

        q = self.q_proj(x)
        k = self.k_proj(x)
        v = self.v_proj(x)

        # Reshape for multi-head attention
        q = q.view(batch, seq_len, self.num_heads, self.head_dim).transpose(
            1, 2
        )
        k = k.view(batch, seq_len, self.num_heads, self.head_dim).transpose(
            1, 2
        )
        v = v.view(batch, seq_len, self.num_heads, self.head_dim).transpose(
            1, 2
        )

        # Scaled dot-product attention
        scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim)
        attn_weights = F.softmax(scores, dim=-1)
        attn_output = torch.matmul(attn_weights, v)

        # Reshape and project
        attn_output = attn_output.transpose(1, 2).contiguous()
        attn_output = attn_output.view(batch, seq_len, self.hidden_size)

        return self.o_proj(attn_output)


class SimpleFeedForward(nn.Module):
    """Simple FFN for demonstration."""

    def __init__(self, hidden_size: int, intermediate_size: int):
        super().__init__()
        self.up_proj = nn.Linear(hidden_size, intermediate_size)
        self.down_proj = nn.Linear(intermediate_size, hidden_size)
        self.act = nn.GELU()

    def forward(self, x):
        return self.down_proj(self.act(self.up_proj(x)))


class SimpleTransformerBlock(nn.Module):
    """A basic transformer block."""

    def __init__(self, hidden_size: int = 256, num_heads: int = 4):
        super().__init__()
        self.attention = SimpleAttention(hidden_size, num_heads)
        self.ffn = SimpleFeedForward(hidden_size, hidden_size * 4)
        self.norm1 = nn.LayerNorm(hidden_size)
        self.norm2 = nn.LayerNorm(hidden_size)

    def forward(self, x):
        x = x + self.attention(self.norm1(x))
        x = x + self.ffn(self.norm2(x))
        return x

Now we can inject LoRA into the attention projections, showing how the injection process affects parameter counts and training behavior:

In[11]:
Code
# Create a transformer block
transformer = SimpleTransformerBlock(hidden_size=256, num_heads=4)

# Count parameters before LoRA
total_before = sum(p.numel() for p in transformer.parameters())
trainable_before = sum(
    p.numel() for p in transformer.parameters() if p.requires_grad
)

# Freeze the complete base model before adding trainable adapters.
for parameter in transformer.parameters():
    parameter.requires_grad = False

# Inject LoRA into query and value projections
transformer = inject_lora(
    transformer, target_modules=["q_proj", "v_proj"], rank=8, alpha=8.0
)

# Count parameters after LoRA
total_after = sum(p.numel() for p in transformer.parameters())
trainable_after = sum(
    p.numel() for p in transformer.parameters() if p.requires_grad
)
Out[12]:
Console
Parameter counts:
  Before LoRA - Total: 789,760, Trainable: 789,760
  After LoRA  - Total: 797,952, Trainable: 8,192

Trainable parameter reduction: 99.0%
Out[13]:
Visualization
Stacked bar chart comparing parameter status before and after LoRA injection. Before LoRA, all parameters are trainable (shown in green). After LoRA, only a small green segment remains trainable while the large blue segment is frozen parameters.
Stacked bar chart comparing parameter status before and after LoRA injection. Before LoRA, all parameters are trainable (shown in green). After LoRA, only a small green segment remains trainable while the large blue segment is frozen parameters, showing the large reduction in training overhead.
Out[14]:
Visualization
Pie chart showing parameter distribution with a small green slice for 8,192 trainable LoRA parameters (about 1.0%) and a large blue slice for 789,760 frozen base-model parameters (about 99.0%).
Pie chart illustrating the parameter distribution in a LoRA-adapted transformer block. The 8,192 trainable adapter parameters account for about 1.0% of the model, showing the significant memory savings achieved during fine-tuning compared with updating all weights.

The model now has the same representational capacity as before, since all original weights are preserved and can contribute to the forward pass. However, only the 8,192 LoRA parameters are trainable, about 1.0% of the adapted block's 797,952 total parameters. This sharply reduces the parameters that require gradient computation and optimizer state storage. It shows LoRA's core value proposition: the ability to fine-tune large models on limited hardware by putting all learning into a small, efficient parameter set.

Why Target Only Query and Value Projections?

The choice to target query (qq) and value (vv) projections rather than key (kk) or output (oo) projections is supported by empirical evidence from the original LoRA paper and subsequent studies. The query projection determines what each token is "looking for" in the sequence, so adapting it changes the attention patterns the model forms. The value projection determines what information is extracted from each token once attention weights are computed, so adapting it changes what content the model reads out. Together, adapting qq and vv gives the model the most control over the information flow through the attention mechanism.

Adapting the key projection (kk) is less common because the key and query projections jointly determine attention weights: kk changes what each token "offers" while qq changes what each token "seeks." Modifying both offers diminishing returns because the attention weight between tokens ii and jj is determined by the inner product qi⋅kjq_i \cdot k_j; adapting only one side of this product often suffices to shift attention patterns. The output projection (oo) recombines the attended values, and adapting it overlaps functionally with adapting vv. In practice, targeting qq and vv reaches a good balance between adaptation capacity and parameter count, though targeting all four projections or including feed-forward layers may be warranted for tasks that require substantial behavioral changes.

The LoRA Training Loop

Training a LoRA-equipped model follows the standard PyTorch training pattern, with one key simplification: the optimizer only needs to handle the LoRA parameters. This means smaller optimizer state memory, faster parameter updates, and reduced gradient computation since gradients don't flow through the frozen base weights.

When you pass only LoRA parameters to an Adam optimizer, the memory benefit is significant. Adam maintains two running statistics per parameter: the first moment (the exponential moving average of gradients) and the second moment (the exponential moving average of squared gradients). For a 7-billion parameter model, full fine-tuning with Adam requires roughly 7×109×3×4=847 \times 10^9 \times 3 \times 4 = 84 GB just for parameters and optimizer states in float32. With LoRA targeting attention projections at rank 16, the trainable parameters drop to roughly 8 million, requiring less than 100 MB for optimizer states. This difference in memory requirement is what makes it possible to fine-tune on a single 24GB GPU instead of a cluster of eight.

Let's build a complete training example to illustrate these concepts in action:

In[15]:
Code
def get_lora_parameters(model: nn.Module):
    """Extract only the LoRA parameters from a model."""
    lora_params = []
    for name, param in model.named_parameters():
        if "lora_" in name:
            lora_params.append(param)
    return lora_params


def count_trainable_parameters(model: nn.Module) -> tuple[int, int]:
    """Count total and trainable parameters."""
    total = sum(p.numel() for p in model.parameters())
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    return total, trainable

The get_lora_parameters function is a simple but important utility. Rather than relying on requires_grad flags (which would work equally well), it filters by name, selecting only parameters whose name contains lora_. This makes the selection criterion explicit and readable: you can see at a glance that the optimizer will handle exactly the LoRA matrices and nothing else. The name-based filter also means you can add additional trainable parameters (like a task-specific classification head) without accidentally including them in LoRA-specific operations.

Now let's implement a training loop for a simple sequence classification task, which shows how LoRA integrates into a realistic training workflow:

In[16]:
Code
class SequenceClassifier(nn.Module):
    """Simple classifier using transformer blocks."""

    def __init__(
        self,
        vocab_size: int,
        hidden_size: int = 256,
        num_heads: int = 4,
        num_layers: int = 2,
        num_classes: int = 2,
    ):
        super().__init__()

        self.embedding = nn.Embedding(vocab_size, hidden_size)
        self.pos_embedding = nn.Embedding(512, hidden_size)

        self.layers = nn.ModuleList(
            [
                SimpleTransformerBlock(hidden_size, num_heads)
                for _ in range(num_layers)
            ]
        )

        self.classifier = nn.Linear(hidden_size, num_classes)

    def forward(self, input_ids):
        batch_size, seq_len = input_ids.shape
        positions = torch.arange(seq_len, device=input_ids.device)

        x = self.embedding(input_ids) + self.pos_embedding(positions)

        for layer in self.layers:
            x = layer(x)

        # Pool: take mean of sequence
        pooled = x.mean(dim=1)

        return self.classifier(pooled)
In[17]:
Code
from torch.utils.data import DataLoader, TensorDataset

# Create synthetic training data
torch.manual_seed(42)
vocab_size = 1000
num_samples = 500
seq_length = 32

# Generate "sentences" with a learnable pattern:
# class 1 sequences have tokens drawn from the upper half of the vocab (500-999),
# class 0 sequences from the lower half (0-499), with 15% noise tokens to add
# realistic overlap so the loss curve shows real convergence.
train_inputs = torch.zeros(num_samples, seq_length, dtype=torch.long)
train_labels = torch.randint(0, 2, (num_samples,))
noise_mask = torch.rand(num_samples, seq_length) < 0.15
for i in range(num_samples):
    if train_labels[i] == 1:
        train_inputs[i] = torch.randint(500, vocab_size, (seq_length,))
    else:
        train_inputs[i] = torch.randint(0, 500, (seq_length,))
# Apply noise: flip ~15% of tokens to the opposite range
train_inputs[noise_mask] = torch.randint(
    0, vocab_size, (noise_mask.sum().item(),)
)

# Create data loader
dataset = TensorDataset(train_inputs, train_labels)
train_loader = DataLoader(dataset, batch_size=32, shuffle=True)
In[18]:
Code
# Initialize model
model = SequenceClassifier(vocab_size=vocab_size, num_classes=2)

# Calculate baseline parameter counts
total, trainable = count_trainable_parameters(model)
Out[19]:
Console
Before LoRA:
  Total parameters: 1,967,106
  Trainable parameters: 1,967,106

At this stage, all parameters in the model are trainable. This is the baseline for full fine-tuning, which would require updating every weight in the network. For large models, this baseline is a significant memory burden since the optimizer must store momentum and variance estimates (in the case of Adam-family optimizers) for every parameter.

In[20]:
Code
# Inject LoRA into attention projections across all layers
model = inject_lora(
    model, target_modules=["q_proj", "v_proj"], rank=4, alpha=4.0
)

# Verify parameter counts
total, trainable = count_trainable_parameters(model)
Out[21]:
Console

After LoRA injection:
  Total parameters: 1,975,298
  Trainable parameters: 1,712,130
  Percentage trainable: 86.68%

After injecting LoRA, the number of trainable parameters drops precipitously. The large majority of the model, comprising the original weights from embeddings, attention layers, feed-forward networks, and the classifier, is now frozen. Only the small adapter matrices remain trainable. This transformation is what lets LoRA to fine-tune models that would otherwise exceed available memory.

In[22]:
Code
# Setup optimizer with only LoRA parameters
lora_params = get_lora_parameters(model)
optimizer = torch.optim.AdamW(lora_params, lr=1e-3, weight_decay=0.01)
criterion = nn.CrossEntropyLoss()

# Training loop
num_epochs = 5
train_losses = []

model.train()
for epoch in range(num_epochs):
    epoch_loss = 0.0
    num_batches = 0

    for batch_inputs, batch_labels in train_loader:
        optimizer.zero_grad()

        outputs = model(batch_inputs)
        loss = criterion(outputs, batch_labels)

        loss.backward()
        optimizer.step()

        epoch_loss += loss.item()
        num_batches += 1

    avg_loss = epoch_loss / num_batches
    train_losses.append(avg_loss)
Out[23]:
Console
Training progress:
  Epoch 1: Loss = 0.7006
  Epoch 2: Loss = 0.6610
  Epoch 3: Loss = 0.6073
  Epoch 4: Loss = 0.5122
  Epoch 5: Loss = 0.3411

The training loop shows that LoRA integrates naturally with standard PyTorch workflows. The code structure is virtually identical to full fine-tuning, with only two modifications required. First, we inject LoRA into target modules using our utility function. Second, we pass only LoRA parameters to the optimizer. This keeps gradient updates only apply to the adaptation matrices. Everything else proceeds exactly as in conventional training: the forward pass computes the loss, backpropagation computes gradients, and the optimizer steps.

The backpropagation through a LoRA layer deserves a moment's explanation. When loss.backward() runs, PyTorch traverses the computation graph in reverse. The gradient flows through the LoRA path as follows: first through the scaling factor, then through the linear transform with BB, then through the linear transform with AA. Both AA and BB accumulate gradients normally because they are leaf tensors with requires_grad=True. The gradient also flows into the original layer's output, but since original_layer.weight.requires_grad=False, PyTorch stops the gradient computation there. This selective gradient flow saves memory: you allocate gradient buffers only for parameters that need them.

Let's visualize the training progress:

Out[24]:
Visualization
Line plot showing decreasing training loss across five epochs.
Training loss trajectory over five epochs of LoRA fine-tuning. The steady decrease in loss indicates that the model successfully learns task-specific patterns despite updating less than 2% of its total parameters, validating the low-rank adaptation hypothesis.

Weight Merging for Efficient Inference

During training, LoRA maintains separate paths for the original weights and the low-rank adaptation. This separation is necessary because we need to keep the original weights frozen while letting gradients to flow through only the adaptation matrices. However, this dual-path architecture introduces computational overhead during inference: each adapted layer must perform the original matrix multiplication plus the additional low-rank computation.

Think of this as having two employees handling the same customer inquiry: one who knows all the general company policies (the frozen base) and one who knows the specialized domain details (the LoRA adapter). During development, you want both employees working together and staying separate so you can update just the specialist without affecting the generalist. But once you ship the product, you can create a single merged knowledge base that combines both people's knowledge into one document, cutting the time needed to answer each question.

At inference time, we can merge the LoRA weights back into the base model, eliminating any computational overhead. The merged model produces identical outputs to the unmerged version but requires only a single matrix multiplication per layer. The merged weight matrix is:

Wmerged=W+αrBAW_{\text{merged}} = W + \frac{\alpha}{r}BA

where:

  • WmergedW_{\text{merged}}: the consolidated weight matrix used for inference
  • WW: the original frozen weight matrix
  • α\alpha: the scaling factor
  • rr: the rank parameter
  • BB: the projection-up matrix
  • AA: the projection-down matrix
  • αrBA\frac{\alpha}{r}BA: the effective update matrix computed from the LoRA parameters

This merging relies on the distributive property of linear transformations over addition. Specifically, applying a linear layer with weight WW and then adding the output of another linear operation with weight BABA is mathematically equivalent to applying a single linear layer with weight W+BAW + BA. We can verify that the merged weight produces identical outputs to the separate paths through careful mathematical analysis. For an input vector x\mathbf{x}, the forward pass equivalence is derived as follows:

xWmergedT=x(W+αrBA)Tsubstitute merged weight=xWT+x(αrBA)Tlinearity of transpose=xWT+αrxATBTtranspose of product: (BA)T=ATBT\begin{aligned} \mathbf{x} W_{\text{merged}}^T &= \mathbf{x} \left(W + \frac{\alpha}{r}BA\right)^T && \text{substitute merged weight} \\ &= \mathbf{x} W^T + \mathbf{x} \left(\frac{\alpha}{r}BA\right)^T && \text{linearity of transpose} \\ &= \mathbf{x} W^T + \frac{\alpha}{r} \mathbf{x} A^T B^T && \text{transpose of product: } (BA)^T = A^T B^T \end{aligned}

where:

  • x\mathbf{x}: the input vector
  • WmergedTW_{\text{merged}}^T: the transpose of the merged weight matrix used in the linear layer
  • WTW^T: the transpose of the original frozen weight matrix
  • α\alpha: the scaling factor
  • rr: the rank parameter
  • AT,BTA^T, B^T: the transposes of the adaptation matrices (note the order reversal due to the transpose property)

The final expression shows exactly what our unmerged forward pass computes: the original layer output xWT\mathbf{x} W^T plus the scaled LoRA contribution αrxATBT\frac{\alpha}{r} \mathbf{x} A^T B^T. This mathematical equivalence confirms that we can eliminate the computational overhead during inference by using a consolidated weight matrix.

The key insight is that matrix addition and matrix-vector multiplication commute in the right way: (W1+W2)x=W1x+W2x(W_1 + W_2)\mathbf{x} = W_1\mathbf{x} + W_2\mathbf{x}. This linearity means we can precompute the sum W+αrBAW + \frac{\alpha}{r}BA once at deployment time and never need to touch AA or BB again. The merged weight is just another weight matrix of the same shape as the original, meaning the inference infrastructure requires no special handling.

In[25]:
Code
def merge_lora_weights(model: nn.Module) -> nn.Module:
    """
    Merge LoRA weights into the base model weights.
    After merging, the model behaves identically but without LoRA overhead.
    """
    for name, module in model.named_modules():
        if isinstance(module, LoRALinear):
            # Compute merged weight: W + (alpha/r) * B @ A
            merged_weight = (
                module.original_layer.weight.data
                + module.scaling * (module.lora_B @ module.lora_A)
            )
            module.original_layer.weight.data = merged_weight

            # Mark as merged to skip LoRA path in forward
            module.merged = True

    return model


def unmerge_lora_weights(model: nn.Module) -> nn.Module:
    """
    Unmerge LoRA weights from the base model.
    Useful if you want to continue training after merging.
    """
    for name, module in model.named_modules():
        if isinstance(module, LoRALinear) and getattr(module, "merged", False):
            # Subtract the LoRA contribution
            unmerged_weight = (
                module.original_layer.weight.data
                - module.scaling * (module.lora_B @ module.lora_A)
            )
            module.original_layer.weight.data = unmerged_weight
            module.merged = False

    return model

Let's modify our LoRALinear class to handle merged weights in the forward pass, creating a version that can operate in either mode:

In[26]:
Code
class LoRALinearMergeable(nn.Module):
    """
    LoRA linear layer with support for weight merging.
    """

    def __init__(
        self,
        original_layer: nn.Linear,
        rank: int = 4,
        alpha: float = 1.0,
        dropout: float = 0.0,
    ):
        super().__init__()

        self.original_layer = original_layer
        self.rank = rank
        self.alpha = alpha
        self.scaling = alpha / rank
        self.merged = False

        self.in_features = original_layer.in_features
        self.out_features = original_layer.out_features

        # Freeze original weights
        for param in self.original_layer.parameters():
            param.requires_grad = False

        # Initialize LoRA matrices
        self.lora_A = nn.Parameter(torch.empty(rank, self.in_features))
        self.lora_B = nn.Parameter(torch.zeros(self.out_features, rank))
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))

        self.dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity()

    def merge(self):
        """Merge LoRA weights into the base layer."""
        if not self.merged:
            self.original_layer.weight.data += self.scaling * (
                self.lora_B @ self.lora_A
            )
            self.merged = True

    def unmerge(self):
        """Unmerge LoRA weights from the base layer."""
        if self.merged:
            self.original_layer.weight.data -= self.scaling * (
                self.lora_B @ self.lora_A
            )
            self.merged = False

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        if self.merged:
            # Just use the merged base layer
            return self.original_layer(x)

        # Standard LoRA forward
        original_output = self.original_layer(x)
        lora_output = self.dropout(x)
        lora_output = F.linear(lora_output, self.lora_A)
        lora_output = F.linear(lora_output, self.lora_B)
        lora_output = lora_output * self.scaling

        return original_output + lora_output

The merged flag in the forward method is what makes this class deployable. When merged=True, the forward pass is just a single call to original_layer, which is a standard PyTorch linear layer. Serving infrastructure that already handles linear layers requires no modification. The flag approach is also clean in that the module can be switched between modes at any time, supporting workflows where you merge for inference, then unmerge to continue fine-tuning on new data.

Let's verify that merging produces identical outputs, confirming our mathematical derivation:

In[27]:
Code
# Create a LoRA layer and process some input
base = nn.Linear(128, 128)
lora = LoRALinearMergeable(base, rank=4, alpha=4.0)

# Simulate some training by modifying LoRA weights
with torch.no_grad():
    lora.lora_A.uniform_(-0.1, 0.1)
    lora.lora_B.uniform_(-0.1, 0.1)

test_input = torch.randn(2, 10, 128)

# Output before merging (using LoRA path)
output_before = lora(test_input)

# Merge weights
lora.merge()

# Output after merging (direct from base layer)
output_after = lora(test_input)

# Check equivalence
diff = (output_before - output_after).abs().max()
Out[28]:
Console
Maximum difference after merging: 1.01e-06
Merged status: True

The outputs are identical up to floating-point precision, confirming that merged models have zero inference overhead compared to the original architecture. This verification gives confidence that you can deploy merged models in production without worrying about subtle behavioral differences. The floating-point error arises because floating-point addition is not perfectly associative: computing Wx+(BAx)W\mathbf{x} + (BA\mathbf{x}) in sequence gives slightly different results from computing (W+BA)x(W + BA)\mathbf{x} due to rounding at each step. In practice this error is many orders of magnitude smaller than any real model output difference.

Out[29]:
Visualization
Scatter plot with output values before merging on the x-axis and output values after merging on the y-axis. All points fall exactly on a red dashed diagonal reference line, confirming perfect numerical equivalence.
Scatter plot comparing layer outputs before and after weight merging. All points lie on the diagonal line, confirming that the merged weights produce outputs numerically identical to the separate LoRA path, validating the mathematical equivalence of the two computation strategies.
Out[30]:
Visualization
Histogram showing the distribution of absolute differences between pre-merge and post-merge outputs. All values cluster near zero in the range of floating-point precision, with a red dashed vertical line marking the maximum difference.
Histogram of absolute differences between pre- and post-merge layer outputs. The deviations are concentrated near the limits of numerical precision (around 1e-7), confirming that the weight merging process is mathematically equivalent to the dual-path LoRA architecture.

Saving and Loading LoRA Weights

For practical deployment, you often want to save only the LoRA weights rather than the full model. This approach lets sharing small adapter files that can be applied to any copy of the base model, creating an efficient ecosystem where you download a large base model once and then apply multiple lightweight adapters for different tasks. Think of it like a printer driver model: you buy the printer once and install it on your computer, then download small driver updates or custom profiles as needed. The profiles are tiny because they only specify the delta from the default behavior, not the entire printer functionality.

In[31]:
Code
def save_lora_weights(model: nn.Module, path: str):
    """Save only the LoRA parameters to a file."""
    lora_state_dict = {}
    for name, param in model.named_parameters():
        if "lora_" in name:
            lora_state_dict[name] = param.data.clone()
    torch.save(lora_state_dict, path)
    return lora_state_dict


def load_lora_weights(model: nn.Module, path: str):
    """Load LoRA parameters from a file."""
    lora_state_dict = torch.load(path, weights_only=True)

    model_state = model.state_dict()
    for name, param in lora_state_dict.items():
        if name in model_state:
            model_state[name] = param

    model.load_state_dict(model_state, strict=False)
    return model
In[32]:
Code
import os
import tempfile

# Demonstrate saving LoRA weights
with tempfile.TemporaryDirectory() as tmpdir:
    save_path = os.path.join(tmpdir, "lora_weights.pt")
    saved_dict = save_lora_weights(model, save_path)
    file_size = os.path.getsize(save_path)
Out[33]:
Console
LoRA weights saved: 8 parameters
File size: 35.58 KB

Saved parameter names:
  layers.0.attention.q_proj.lora_A: shape torch.Size([4, 256])
  layers.0.attention.q_proj.lora_B: shape torch.Size([256, 4])
  layers.0.attention.v_proj.lora_A: shape torch.Size([4, 256])
  layers.0.attention.v_proj.lora_B: shape torch.Size([256, 4])

The LoRA checkpoint is orders of magnitude smaller than a full model checkpoint, letting practical storage and distribution of many task-specific adaptations. You might maintain dozens of LoRA adapters for different tasks while storing just one copy of the base model, sharply reducing storage requirements compared to maintaining multiple fully fine-tuned model variants. This pattern has become standard in the open-source LLM community: the Hugging Face model hub hosts thousands of LoRA adapters for various models, letting anyone to download a base model and apply a specialized adapter without waiting for a full model upload.

The load_lora_weights function uses strict=False in load_state_dict intentionally. Since we are only loading LoRA parameters rather than the complete model state dictionary, setting strict=True would cause an error because most of the model's parameters are missing from the adapter checkpoint. The strict=False setting tells PyTorch to update only the keys that are present in the loaded dictionary, leaving all other parameters untouched. This is exactly the behavior we want: load the fine-tuned adapter weights while keeping the base model weights intact.

HuggingFace PEFT Library

While understanding the implementation details is useful for building intuition and debugging issues, production systems typically use the PEFT (Parameter-Efficient Fine-Tuning) library from HuggingFace. PEFT gives optimized implementations of LoRA and other adaptation methods that integrate with the transformers ecosystem. Using PEFT means you benefit from tested, maintained code that handles edge cases and optimizations you might miss in a custom implementation.

The PEFT library handles several complexities that our custom implementation glosses over. It correctly handles layers with tied weights, modules that appear multiple times in the computation graph, and the interaction between LoRA and model parallel configurations used for very large models. It also integrates with HuggingFace's Trainer class, meaning you can use all the standard training utilities like gradient accumulation, mixed precision, and learning rate scheduling without any special modifications for LoRA.

Let's walk through a practical example of fine-tuning a language model with PEFT:

In[34]:
Code
# Import required packages (assuming they are already installed)
# If not installed, run: pip install peft transformers
In[35]:
Code
from transformers import DistilBertConfig, DistilBertForSequenceClassification

# In a real scenario, you would download pre-trained weights:
# model_name = "distilbert-base-uncased"
# tokenizer = AutoTokenizer.from_pretrained(model_name)
# base_model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)

# For this demonstration, we initialize a random model to avoid downloads
config = DistilBertConfig(
    n_layers=2,
    n_heads=4,
    dim=256,
    hidden_dim=1024,
    vocab_size=2000,
    num_labels=2,
)
base_model = DistilBertForSequenceClassification(config)


# Create a dummy tokenizer for demonstration
class DummyTokenizer:
    def __call__(
        self,
        texts,
        truncation=True,
        padding="max_length",
        max_length=128,
        return_tensors="pt",
    ):
        batch_size = len(texts)
        return {
            "input_ids": torch.randint(0, 2000, (batch_size, max_length)),
            "attention_mask": torch.ones(
                batch_size, max_length, dtype=torch.long
            ),
        }


tokenizer = DummyTokenizer()

The PEFT library uses a configuration object to specify LoRA hyperparameters. This gives a clean interface for customizing the adaptation behavior. The configuration object separates the concerns of "what LoRA should do" from "how to apply it to a model," which makes it easy to swap configurations without touching the model-wrapping code:

In[36]:
Code
from peft import LoraConfig, TaskType, get_peft_model

# Configure LoRA
lora_config = LoraConfig(
    task_type=TaskType.SEQ_CLS,
    r=8,
    lora_alpha=16,
    lora_dropout=0.1,
    target_modules=["q_lin", "v_lin"],
    bias="none",
    modules_to_save=None,
)

# Wrap the model with LoRA
peft_model = get_peft_model(base_model, lora_config)

# Get parameter statistics
trainable_params, all_params = peft_model.get_nb_trainable_parameters()
Out[37]:
Console
trainable params: 82,690 || all params: 2,372,100 || trainable%: 3.4859

The output shows the large reduction in trainable parameters achieved by PEFT. With just a single function call, the library has identified the target modules, wrapped them with LoRA adapters, and frozen the base weights. The bias="none" parameter tells PEFT not to train any bias terms alongside the LoRA matrices, which is the most memory-efficient option. Alternatively, you can set bias="all" to train all biases or bias="lora_only" to train only the biases in LoRA-targeted layers.

Let's examine the modified architecture to understand what changed:

In[38]:
Code
# Examine the LoRA structure in one attention layer
target_name = None
target_module = None

for name, module in peft_model.named_modules():
    if "lora" in name.lower() and "q_lin" in name:
        target_name = name
        target_module = module
        break
Out[39]:
Console
Sample LoRA module structure:
  Module: base_model.model.distilbert.transformer.layer.0.attention.q_lin.lora_dropout
  Type: ModuleDict

The output confirms that the targeted linear layers have been successfully wrapped with LoRA adapters. These adapters intercept the forward pass, adding the low-rank adaptation path alongside the frozen base computation. The PEFT library uses its own Linear class internally, which is similar in structure to our LoRALinear but with additional features like support for half-precision training and gradient checkpointing.

Training with PEFT

Training a PEFT model is identical to training any HuggingFace model. The library handles freezing base weights and updating only LoRA parameters transparently, so your training code requires no special modifications. The fact that LoRA is present is invisible to the training loop, which is precisely the design goal: you should be able to drop LoRA into an existing training pipeline with minimal code changes.

In[40]:
Code
from torch.utils.data import Dataset


class SimpleTextDataset(Dataset):
    """Minimal dataset for demonstration."""

    def __init__(self, texts, labels, tokenizer, max_length=128):
        self.encodings = tokenizer(
            texts,
            truncation=True,
            padding="max_length",
            max_length=max_length,
            return_tensors="pt",
        )
        self.labels = torch.tensor(labels)

    def __len__(self):
        return len(self.labels)

    def __getitem__(self, idx):
        return {
            "input_ids": self.encodings["input_ids"][idx],
            "attention_mask": self.encodings["attention_mask"][idx],
            "labels": self.labels[idx],
        }


# Create sample data
texts = [
    "This movie was absolutely wonderful!",
    "Terrible film, waste of time.",
    "A masterpiece of modern cinema.",
    "Boring and predictable plot.",
    "Outstanding performances by the cast!",
    "I want my money back.",
    "Beautiful cinematography throughout.",
    "The worst movie I've ever seen.",
] * 10  # Repeat for more samples

labels = [1, 0, 1, 0, 1, 0, 1, 0] * 10

train_dataset = SimpleTextDataset(texts, labels, tokenizer)
In[41]:
Code
# Training loop
peft_model.train()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
peft_model = peft_model.to(device)

# Create optimizer with only trainable (LoRA) parameters
peft_optimizer = torch.optim.AdamW(
    filter(lambda p: p.requires_grad, peft_model.parameters()),
    lr=2e-4,
    weight_decay=0.01,
)

train_dataloader = DataLoader(train_dataset, batch_size=8, shuffle=True)

peft_losses = []
for epoch in range(3):
    epoch_loss = 0
    for batch in train_dataloader:
        batch = {k: v.to(device) for k, v in batch.items()}

        outputs = peft_model(**batch)
        loss = outputs.loss

        peft_optimizer.zero_grad()
        loss.backward()
        peft_optimizer.step()

        epoch_loss += loss.item()

    avg_loss = epoch_loss / len(train_dataloader)
    peft_losses.append(avg_loss)
Out[42]:
Console
PEFT Training Progress:
  Epoch 1: Loss = 0.7042
  Epoch 2: Loss = 0.6853
  Epoch 3: Loss = 0.6760

The steady decrease in loss shows the model is learning from the dataset. Optimizing just the small set of LoRA parameters is sufficient to drive this learning, which validates the core hypothesis behind LoRA: that task-specific adaptations can be captured in a low-rank subspace of the full weight space. The model is not simply memorizing the training examples, it is discovering a compact parameterization of what "positive sentiment" and "negative sentiment" mean in terms of attention patterns.

Out[43]:
Visualization
Line plot with square markers showing PEFT training loss decreasing over three epochs. Annotations on the first and last points display the exact loss values, confirming convergence.
Training loss reduction for a DistilBERT model fine-tuned using the PEFT library. The continuous decline across three epochs shows that low-rank adaptation effectively captures task-relevant information with minimal parameter updates, confirming the practical viability of LoRA for production fine-tuning workflows.

Saving and Loading PEFT Models

PEFT gives convenient methods for saving only the adapter weights, which makes it easy to distribute lightweight task-specific adaptations. The library follows a consistent convention for file naming and format, which means adapters saved by one version of PEFT can generally be loaded by another, easing version management.

In[44]:
Code
with tempfile.TemporaryDirectory() as tmpdir:
    # Save only the LoRA adapter (not the full model)
    peft_model.save_pretrained(tmpdir)

    # List saved files
    saved_files = os.listdir(tmpdir)
    file_sizes = {
        f: os.path.getsize(os.path.join(tmpdir, f)) for f in saved_files
    }
Out[45]:
Console
Saved adapter files:
  adapter_model.safetensors: 324.52 KB
  README.md: 5.01 KB
  adapter_config.json: 1.00 KB

The adapter checkpoint is just a few hundred kilobytes, compared to hundreds of megabytes for the full DistilBERT model. This makes possible practical workflows where you distribute a single base model with many task-specific adapters, letting you to quickly switch between different capabilities without downloading or storing multiple complete models. In the open-source ecosystem, adapters can be shared as standalone files on the HuggingFace Hub. Anyone with the base model can then apply your adapter with from_pretrained, creating a collaborative environment for model customization.

The adapter_config.json file saved alongside the weights is equally important. It records the LoRA configuration, including rank, alpha, target modules, and other settings. When loading the adapter later, PEFT reads this configuration to reconstruct the correct LoRA structure before loading the saved weights. This means you don't need to remember which configuration you used when training: the adapter is self-describing.

Merging and Unloading

PEFT also supports merging LoRA weights for inference. This gives a one-line solution for converting your adapted model into a standard format. The merge_and_unload method performs both operations: it merges all LoRA weights into the base model and removes the LoRA modules from the architecture, returning a clean model with no PEFT overhead:

In[46]:
Code
# Merge LoRA weights into the base model
merged_model = peft_model.merge_and_unload()
Out[47]:
Console
Merged model type: DistilBertForSequenceClassification
Parameters: 2,289,410

After merging, you have a standard transformer model with no LoRA overhead, suitable for optimized inference pipelines. The merged model can be deployed using any existing serving infrastructure without special handling for LoRA adapters. This is particularly important for production deployments that use inference optimization techniques like TensorRT or ONNX export, which work best on standard model architectures without custom modules.

Key Parameters for PEFT LoraConfig

The key parameters for LoraConfig in the PEFT library give fine-grained control over the adaptation behavior. Understanding each parameter helps you make principled choices rather than relying purely on trial and error.

The main configuration parameters are:

  • r: The rank of the low-rank decomposition. Lower ranks (4 to 16) use fewer parameters but may capture less complex adaptations. Higher ranks give more expressiveness at the cost of increased memory and computation. Most published LoRA papers use ranks between 4 and 64, with 8 and 16 being particularly common defaults. For instruction following tasks, r=8r=8 is often sufficient. For domain adaptation with substantially different vocabulary and knowledge, r=32r=32 or higher may be necessary.
  • lora_alpha: The scaling factor for LoRA updates. It functions similarly to a learning rate multiplier for the adapters, controlling how strongly the adaptation influences model outputs relative to the frozen base weights. Setting lora_alpha = r gives a scaling of 1.0 (a conservative starting point). Setting lora_alpha = 2 * r gives a scaling of 2.0 (more aggressive adaptation). Most practitioners treat alpha as a fixed hyperparameter (commonly 16 or 32) rather than tuning it per experiment.
  • target_modules: The specific modules to apply LoRA to. Common targets in transformers are query and value projections (q_lin, v_lin, q_proj, v_proj), which control attention patterns. Targeting more modules increases adaptation capacity but also parameter count. The optimal set depends on the task: attention projections work best for tasks requiring behavioral changes (tone, format, persona), while including feed-forward networks helps for tasks requiring new factual knowledge.
  • lora_dropout: Dropout probability applied to the LoRA path to prevent overfitting during training. This is particularly important when fine-tuning on small datasets where the model might memorize rather than generalize. A dropout value of 0.05 to 0.1 is common for datasets with fewer than 10,000 examples. For larger datasets, you can reduce dropout or set it to zero.

Limitations and Impact

LoRA implementation brings certain practical constraints that you should understand before applying the technique to your own models and tasks. Being aware of these limitations upfront helps you design better experiments and avoid surprises during deployment.

The rank rr fundamentally limits the expressiveness of the adaptation. When the required weight updates truly need full-rank modifications, such as when adapting to a sharply different domain or learning entirely new capabilities, LoRA may underperform full fine-tuning. The low-rank constraint assumes that the difference between the pre-trained model and the adapted model lives in a low-dimensional subspace, which holds well for many fine-tuning tasks but may break down for more substantial modifications. Empirically, tasks that require the model to acquire new factual knowledge, rather than simply adjusting its style or behavior, tend to benefit from higher ranks or from targeting more layers including feed-forward networks. The relationship between rank and expressiveness is continuous rather than discrete: there is no sharp threshold below which LoRA fails, but performance typically degrades gradually as rank drops.

The choice of target modules materially affects both performance and efficiency. Targeting all linear layers captures the most information and gives maximum flexibility for the adaptation, but it also increases parameter count and training memory proportionally. In practice, attention projections (query and value) give the best performance-to-parameter ratio for most tasks, as these layers directly control what information the model attends to. However, optimal targeting varies by domain: feed-forward networks may be important for tasks requiring different knowledge representations, while leaving them untouched often works well for stylistic adaptations that primarily change how existing knowledge is expressed. The mismatch between a practitioner's choice of target modules and what the task requires is one of the most common sources of suboptimal LoRA results.

Memory savings during training come primarily from reduced gradient computation and optimizer states. For an 8-billion parameter model with rank-16 LoRA applied to attention layers, trainable parameters drop to roughly 20 million, about 0.25% of the original count. This large reduction allows fine-tuning on a single consumer GPU that couldn't otherwise hold the optimizer states for full fine-tuning, where Adam requires approximately 16 bytes per parameter (4 bytes each for the parameter, gradient, first moment, and second moment). The frozen base model still occupies GPU memory during training: you need enough memory to hold the full model in half-precision or quantized form, plus the LoRA gradients and optimizer states. Techniques like QLoRA (discussed in a later chapter) address this by quantizing the base model, further reducing the memory footprint.

Inference behavior depends on whether weights are merged. Unmerged LoRA adds latency from the additional matrix multiplications in the low-rank path. For batch sizes of one, which are common in interactive applications like chatbots, this overhead is typically 5 to 15% depending on the specific model architecture and hardware. Merging eliminates this entirely, but you lose the ability to dynamically switch between adapters or continue training. Many applications use unmerged models during development for flexibility and merge before production deployment.

One subtle limitation is that LoRA adapters trained on one base model version cannot be directly applied to a different version of the same model family. If a model provider releases GPT-X v2.0 with different architecture or weight initialization than v1.0, all existing LoRA adapters for v1.0 become obsolete. This creates a fragility in the adapter ecosystem: adapter distribution depends on base model stability, and model updates can silently break all downstream adapters without any error message (the adapter weights simply don't match what the new base model's pre-trained weights expect them to be). This is a practical concern worth monitoring when building production systems that rely on community-sourced adapters.

LoRA's impact on the field has been large and far-reaching. Before LoRA, fine-tuning large language models was effectively limited to organizations with measurable compute resources. This creates a divide between those who could customize models and those who couldn't. LoRA democratized adaptation. This makes possible practitioners with single GPUs to customize billion-parameter models for their specific needs. This accessibility accelerated the development of domain-specific applications in medicine and law, in education, and across many other fields. The technique also spawned an ecosystem of shared adapters for various tasks, where practitioners contribute and download small adapter files rather than full model weights. Subsequent techniques like QLoRA, which we'll cover in an upcoming chapter, push these efficiency gains even further by combining LoRA with quantization to let fine-tuning on even more constrained hardware.

Beyond the immediate practical benefits, LoRA contributed to a broader conceptual shift in how the community thinks about model customization. Before LoRA, fine-tuning was conceived as a necessary expense: you had to pay a large computational cost to make a model useful for your specific task. LoRA reframed customization as an inexpensive modular operation, analogous to installing a plugin rather than rewriting software from scratch. This reframing influenced the design of subsequent systems, from model serving infrastructure that can hot-swap adapters at runtime to training frameworks that treat multi-task fine-tuning as a first-class concern. This conceptual contribution, together with the practical one, explains why LoRA has remained the dominant approach for parameter-efficient fine-tuning years after its introduction.

Summary

This chapter transformed the mathematical concepts from the previous chapter into working implementations. The key implementation components are:

  • LoRA Module Design: A LoRALinear class wraps existing linear layers, adding parallel low-rank matrices AA and BB while keeping original weights frozen. Zero-initialization of BB ensures the model starts identical to the pre-trained version. This gives a clean baseline and eliminating the risk of initial performance degradation.

  • Asymmetric Initialization: Initializing BB to zeros and AA with Kaiming uniform is not arbitrary. It guarantees that BA=0BA = 0 at initialization, so the model begins training from its pre-trained state. The Kaiming initialization for AA gives appropriate gradient variance for learning.

  • Model Integration: A recursive injection function identifies target modules (typically attention projections) and replaces them with LoRA-wrapped versions, automatically freezing base weights. The two-pass collection-then-replacement strategy avoids graph modification errors during traversal.

  • Training Loop: Standard PyTorch training, but the optimizer receives only LoRA parameters. Memory savings come from reduced gradient computation and optimizer state storage. The backward pass naturally skips frozen parameters because PyTorch's autograd only tracks tensors with requires_grad=True.

  • Weight Merging: At inference time, Wmerged=W+αrBAW_{\text{merged}} = W + \frac{\alpha}{r}BA produces a standard model with zero overhead. The mathematical equivalence follows from the distributive property of linear transformations. Merging can be reversed by subtracting the same delta, supporting continued training after deployment.

  • PEFT Library: HuggingFace's PEFT gives production-ready LoRA with LoraConfig for configuration, get_peft_model for wrapping, and save_pretrained/merge_and_unload for deployment workflows. The library handles edge cases like tied weights and integrates with the full HuggingFace ecosystem.

The next chapter examines how to select LoRA hyperparameters, including rank, alpha, target modules, and their interactions, to optimize for your specific task and computational constraints.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about LoRA implementation.

LoRA Implementation

Question 1 of 80 of 8 completed
Why is matrix B initialized to zeros while matrix A uses Kaiming initialization in LoRA?

Comments

No comments yet. Be the first to share your thoughts!

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2025loraimplementation, author = {Michael Brenndoerfer}, title = {LoRA Implementation: PyTorch Code & PEFT Integration}, year = {2025}, url = {https://mbrenndoerfer.com/writing/lora-implementation-pytorch-peft-guide}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-10-13} }
APAAcademic
Michael Brenndoerfer (2025). LoRA Implementation: PyTorch Code & PEFT Integration. Retrieved from https://mbrenndoerfer.com/writing/lora-implementation-pytorch-peft-guide
MLAAcademic
Michael Brenndoerfer. "LoRA Implementation: PyTorch Code & PEFT Integration." 2026. Web. October 13, 2026. <https://mbrenndoerfer.com/writing/lora-implementation-pytorch-peft-guide>.
CHICAGOAcademic
Michael Brenndoerfer. "LoRA Implementation: PyTorch Code & PEFT Integration." Accessed October 13, 2026. https://mbrenndoerfer.com/writing/lora-implementation-pytorch-peft-guide.
HARVARDAcademic
Michael Brenndoerfer (2025) 'LoRA Implementation: PyTorch Code & PEFT Integration'. Available at: https://mbrenndoerfer.com/writing/lora-implementation-pytorch-peft-guide (Accessed: October 13, 2026).
SimpleBasic
Michael Brenndoerfer (2025). LoRA Implementation: PyTorch Code & PEFT Integration. https://mbrenndoerfer.com/writing/lora-implementation-pytorch-peft-guide

About the author

Continue with the full handbook

This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.

Explore Language AI Handbook
Newsletter

Stay up to date

Get articles, book updates, and news delivered to your inbox.

No spam, unsubscribe anytime.

or

Join the community

Sign in to remove popups, track your reading progress, and join the discussion.