2️⃣ LoRA

Learning Objectives
  • Understand the mechanism behind Low-Rank Adaptation, and why it allows fine-tuning with far less memory (both in terms of memory saved during the optimization step, and memory saved on the reference model).
  • Implement LoRA from scratch for a Hugging Face model, by wrapping its nn.Linear layers
  • Use GRPO + LoRA together to RL fine-tune a model otherwise too large to fully fine-tune on a single GPU

Go to the setup cell at the start of the previous section (where LOW_GPU_MEM and BASE_MODEL are defined) and set RUN_BASE_RLHF = False. This will skip all the expensive training runs for Section 1, so you can easily rerun the file.

Low-Rank Adaptation

Fully fine-tuning a model is very expensive in memory. With AdamW in bfloat16:

  • every parameter costs 2 bytes for the weight,
  • 2 more for its gradient,
  • and 4 more for Adam's two moment estimates:

16 bits per parameter, before we've stored a single activation! And for RL we also need a frozen copy of the original model, so throw in another 1.7B times 2 bytes. For the 1.7B parameter model (tiny!) we'll use in this section, that's $1.7\text{B} \times (2 + 2 + 4) \approx 27$GB (plus $1.7\text{B} \times 4 \approx 7$GB for the reference model), more than a 24GB GPU has. Reduced precision can help, but if we move up to a 3B model we hit the same problem. Having a copy of the entire model in memory just for the KL penalty, as well as the optimizer state for every parameter kills us.

Now, once a model has already gone through pre-training, it already has a rich set of features, and we don't really want to change that much when we RL a model. We just want to bump the weights very slightly to encourage the kinds of behaviours that we want, and it's wasteful to tune every single parameter in the model to achieve such a tiny change. From an information theory perspective, if the reward function gives a hard 0/1 reward, then you're only getting at best 1 bit of feedback per rollout, and that's assuming the model is equally likely to give a correct/incorrect response.

Over a 100 phase training run, with a 128 batch size, we're getting at best 12,800 bits = 1.5 KiB of information. Any property/behaviour/algorithm that we're trying to teach to the model that it doesn't already have, if it cannot be described in less than 1.5 KiB, cannot possibly be learned [1].

[1]. Clarification on above argument

Models learn a lot during the pre-training phase, and probably internally already have lots of different behaviours and skills picked up during that time. If all we have to do is "index into the list of skills the model has learned to select the right one", then a constraint of 1.5 KiB suddenly might be quite a lot. More formally, the argument to make would be "if the shortest description of a property/behaviour/algorithm, conditioned on that you have access to the base model weights exceeds 1.5KiB, then it cannot possibly be learned by any such RL training run as described above.

As such, RL for LLMs has sometimes been described as merely eliciting an existing persona or skill that the model was already aware of, and then reinforcing that persona/skill until it is the dominant thing that the LLM wants to do. We will revisit this claim in the RLVR section.

LoRA (Hu et al., 2021) gets around both problems, greatly reducing the size of the optimizer state, as well as for our use case, eliminating the need for a separate reference model. The idea (section 4.1 of the paper) is that the change to the weights during fine-tuning tends to have a low "intrinsic rank", so we can freeze the pretrained weights and have things setup such that it is possible to only learn a low-rank update. Given a pretrained weight matrix $W_0 \in \mathbb{R}^{d_\text{in} \times d_\text{out}}$ in a layer computing some linear map $h = x W_0$, for a row vector $x \in \mathbb{R}^{d_\text{in}}$ and $h \in \mathbb{R}^{d_\text{out}}$, LoRA learns the update as a factored matrix $\Delta W = AB$, where $A \in \mathbb{R}^{d_{\text{in}} \times r}$, $B \in \mathbb{R}^{r \times d_{\text{out}}}$ and the rank $r \ll \min(d_{\text{in}}, d_{\text{out}})$. The forward pass becomes

$$ h = x W_0 + \frac{\alpha}{r} x \Delta W = x W_0 + \frac{\alpha}{r} x A B $$
where $\alpha$ is a scaling hyperparameter (see below for how to choose it).

Note that the number of parameters in $W_0$ is $d_{\text{in}} \times d_{\text{out}}$, which for an attention weight matrix for gpt2-small is $768 \times 64 \approx 50K$ parameters. For the LoRA adaptor, with a small choice of rank $r=4$, the number of parameters in $\Delta W$ is $d_{\text{in}} \times r + r \times d_{\text{out}} = 768 \times 4 + 4 \times 64 \approx 3.3K$, only 6% the size of the original parameters. This scales even better with larger models, and often the LoRA adaptors can be a tiny fraction (<0.1%) of the total parameters of the model.

The details, all from section 4.1 of the paper:

  • Frozen base. $W_0$ is frozen, so we never compute or store its gradient, nor any optimizer state for it. Only $A$ and $B$ are trained. Neither has a bias.
  • Note that $W_0$ still takes part in the backward pass: to get the gradients for the adapters in earlier layers, we need the gradient of the loss with respect to each layer's input $x$, which is computed using $W_0$. But that's a gradient with respect to activations, not weights, so nothing the size of $W_0$ is ever stored. The only parameter gradients we keep are for the LoRA adaptors (which are negligible in size compared to the full model).
  • Initialization. $A$ is initialized with random Gaussian values, and $B$ with zeros, so $\Delta W = AB = 0$ at the start of training:
  • the adapted model starts out exactly equal to the pretrained one.
  • Scaling. The update $x \Delta W$ is scaled by $\frac{\alpha}{r}$, where $\alpha$ is a constant. The paper sets $\alpha$ to the first $r$ they try and doesn't tune it. The point of the scaling is that if you then change $r$, you don't need to retune the learning rate.
  • Which weights. The paper only adapts the attention weights, and mostly just $W_Q$ and $W_V$, leaving the MLPs frozen. We'll follow current practice instead, and adapt every linear layer in each block (see the dropdown below).
  • No inference latency. We don't want the model to architecturally change shape after fine-tuning. Currently, we've got these extra adaptors hanging off every attention weight matrix. The best thing about LoRA is since we are only learning a low-rank linear perturbation away from the base model weights, we can merge the update into the weights once training is done, by defining the new weight matrix $W = W_0 + \frac{\alpha}{r} AB$. Now the model has the same architecture and speed as the original, but it acts like the new fine-tuned copy.

LoRA also gives us the reference policy for free: switching the adapters off gives back the original pretrained model, so we don't need a second copy of it.

Which weights should we adapt? The paper vs. current practice

The paper. Section 4.2 limits LoRA to the attention weights, freezing the MLPs. Then section 7.1 compares adapting different attention weights on GPT-3 with a fixed budget of trainable parameters (Table 5). Putting the whole budget into $W_Q$ or $W_K$ alone does noticeably worse, and the authors conclude that adapting $W_Q$ and $W_V$ "gives the best performance overall". (Though in the same table, adapting all four of $W_Q, W_K, W_V, W_O$ at half the rank does just as well or better.) But they never tried the MLPs, so this only tells us which attention weights are best.

Current practice. Adapt every linear layer: the attention projections q_proj, k_proj, v_proj and o_proj, and the MLP projections gate_proj, up_proj and down_proj. LoRA Without Regret found that this is one of the two conditions for LoRA to match full fine-tuning: attention-only LoRA underperforms even when you raise its rank to match the parameter count, while adding the attention layers on top of the MLP layers gives no extra benefit (the MLPs hold most of the parameters). For RL with policy gradients, LoRA matched full fine-tuning even at rank 1. TRL's LoRA guide and Unsloth's recommend the same, and QLoRA found it necessary to match full fine-tuning too.

peft's default still follows the paper. If you don't pass target_modules, LoraConfig leaves it as None, and peft then looks up the model's architecture in its table of defaults, which for Qwen3 is just ["q_proj", "v_proj"] (the same as for Llama). To get current practice, you have to ask for it with LoraConfig(target_modules="all-linear").

A note on the shapes

As in the rest of this course, we write layers as row vectors times matrices, $h = x W_0$ with $W_0 \in \mathbb{R}^{d_\text{in} \times d_\text{out}}$. So we store A with shape (in_features, rank) and B with shape (rank, out_features), and the update is $x A B$.

There are two places where you'll see the transpose of this:

  • The LoRA paper uses column vectors, $h = W_0 x$, so it writes the update as $\Delta W = BA$ with $B \in \mathbb{R}^{d_\text{out} \times r}$ and $A \in \mathbb{R}^{r \times d_\text{in}}$. The names keep the same roles: $A$ reads from the input (and is initialized randomly), $B$ writes to the output (and is initialized to zero).
  • PyTorch's nn.Linear stores its weight as (out_features, in_features), the transpose of our $W_0$, and computes x @ weight.T. This only matters in merge, where we add our update into base.weight. With an einsum, the output pattern d_out d_in does the transpose for us.

Exercise - implement LoRALinear

Difficulty: 🔴🔴⚪⚪⚪
Importance: 🔵🔵🔵🔵🔵
You should spend up to 15-20 minutes on this exercise.

Implement LoRALinear, which wraps an existing nn.Linear layer (the frozen $W_0$, plus its bias if it has one) and adds a trainable low-rank update. You should:

  • In __init__:
    • Define self.A with shape (in_features, rank), initialized from a Gaussian with zero mean, standard deviation $1/\sqrt{\text{in\_features}}$, and self.B with shape (rank, out_features), initialized to zeros. Both should be float32 nn.Parameters on the same device as the base layer.
  • In forward, compute $\text{base}(x) + \frac{\alpha}{r} x A B$.
    • If self.enabled is False, disable the LoRA adaptor and just run the base layer alone.
    • If self.merged is True, then the LoRA weights have already been baked into the base layer, so we should again run the base layer alone.
    • Compute the update in float32 and cast it back to the dtype of x at the end.

Leave merge and unmerge for now: they're the next exercise.

Why that standard deviation for A?

The paper says only "random Gaussian", without giving a variance. We use $1/\sqrt{\text{in\_features}}$ so that x @ A has roughly the same scale as x: each of its entries is a sum of in_features terms. This is the same scale as PyTorch's default initialization of an nn.Linear layer, and similar to the reference implementation loralib, which uses PyTorch's default (uniform) initialization for $A$. The peft library's init_lora_weights="gaussian" option uses a standard deviation of $1/r$ instead. Since $B$ starts at zero, the choice only affects how quickly the update grows during training.

Why keep A and B in float32 when the base model might be a different type (e.g. bfloat16)?

We'll load the 1.7B base model in bfloat16 to halve its memory, which is fine since it's frozen. But the adapters are what we're training, and bfloat16 only has 8 bits of mantissa: a small Adam update to a bfloat16 weight often rounds away to nothing. This is the usual mixed-precision recipe: keep the trainable parameters (and optimizer state) in float32. The adapters are tiny, so this costs almost no memory.

Why multiply by A then B, rather than forming AB first?

$AB$ is a full (in_features, out_features) matrix, as big as the original weight, but it is only a rank-r matrix. Computing it on every forward pass would waste memory and compute. Going through the rank-$r$ bottleneck (first $xA$, then $(xA)B$) costs only $O(r \, (d_\text{in} + d_\text{out}))$ per token, and gives the same result.

class LoRALinear(nn.Module):
    """
    Wraps a frozen nn.Linear layer W_0 (with optional bias b), adding a trainable low-rank update:

        h = x W_0 + b + (alpha / rank) * x A B

    with A of shape (in_features, rank) initialized from a Gaussian, and B of shape (rank, out_features) initialized to
    zeros (so the update starts at zero).
    """

    A: Float[nn.Parameter, "d_in rank"]
    B: Float[nn.Parameter, "rank d_out"]

    def __init__(self, base: nn.Linear, rank: int, alpha: float):
        super().__init__()
        self.base, self.rank, self.alpha = base, rank, alpha
        self.scaling = alpha / rank
        self.enabled = True  # if False, the module acts exactly like the frozen base layer
        self.merged = False  # if True, the update has been added into base.weight (see `merge`)
        self.base.requires_grad_(False)
        device = base.weight.device
        raise NotImplementedError()

    def forward(self, x: Float[Tensor, "... d_in"]) -> Float[Tensor, "... d_out"]:
        raise NotImplementedError()

    @t.no_grad()
    def merge(self) -> None:
        """Adds the low-rank update into the base layer's weights, so the forward pass no longer needs A and B."""
        assert not self.merged, "LoRALinear: Attempting to merge an already merged model."
        raise NotImplementedError()

    @t.no_grad()
    def unmerge(self) -> None:
        """
        Undoes `merge`, subtracting the low-rank update back out of the base layer's weights. If the base weights are in
        a lower precision (like bfloat16), merging and unmerging both round, so this only gets back to W_0 approximately.
        """
        assert self.merged, "LoRALinear: merge must be run before running unmerge."
        raise NotImplementedError()

    def extra_repr(self) -> str:
        return f"rank={self.rank}, alpha={self.alpha}, enabled={self.enabled}, merged={self.merged}"


tests_lora.test_lora_linear(LoRALinear)
Solution
class LoRALinear(nn.Module):
    """
    Wraps a frozen nn.Linear layer W_0 (with optional bias b), adding a trainable low-rank update:

        h = x W_0 + b + (alpha / rank) * x A B

    with A of shape (in_features, rank) initialized from a Gaussian, and B of shape (rank, out_features) initialized to
    zeros (so the update starts at zero).
    """

    A: Float[nn.Parameter, "d_in rank"]
    B: Float[nn.Parameter, "rank d_out"]

    def __init__(self, base: nn.Linear, rank: int, alpha: float):
        super().__init__()
        self.base, self.rank, self.alpha = base, rank, alpha
        self.scaling = alpha / rank
        self.enabled = True  # if False, the module acts exactly like the frozen base layer
        self.merged = False  # if True, the update has been added into base.weight (see `merge`)
        self.base.requires_grad_(False)
        device = base.weight.device
        self.A = nn.Parameter(t.randn(base.in_features, rank, device=device) / base.in_features**0.5)
        self.B = nn.Parameter(t.zeros(rank, base.out_features, device=device))


    def forward(self, x: Float[Tensor, "... d_in"]) -> Float[Tensor, "... d_out"]:
        out = self.base(x)
        if (not self.enabled) or self.merged:
            return out
        x = x.to(self.A.dtype)
        x = einops.einsum(x, self.A, "... d_in, d_in rank -> ... rank")
        update = einops.einsum(x, self.B, "... rank, rank d_out -> ... d_out")
        return out + (self.scaling * update).to(out.dtype)

    @t.no_grad()
    def merge(self) -> None:
        """Adds the low-rank update into the base layer's weights, so the forward pass no longer needs A and B."""
        assert not self.merged, "LoRALinear: Attempting to merge an already merged model."

        # nn.Linear stores its weight as (d_out, d_in), the transpose of our W_0, so we output the update transposed too
        delta = self.scaling * einops.einsum(self.A, self.B, "d_in rank, rank d_out -> d_out d_in")
        self.base.weight += delta
        self.merged = True

    @t.no_grad()
    def unmerge(self) -> None:
        """
        Undoes `merge`, subtracting the low-rank update back out of the base layer's weights. If the base weights are in
        a lower precision (like bfloat16), merging and unmerging both round, so this only gets back to W_0 approximately.
        """
        assert self.merged, "LoRALinear: merge must be run before running unmerge."

        delta = self.scaling * einops.einsum(self.A, self.B, "d_in rank, rank d_out -> d_out d_in")
        self.base.weight -= delta
        self.merged = False

    def extra_repr(self) -> str:
        return f"rank={self.rank}, alpha={self.alpha}, enabled={self.enabled}, merged={self.merged}"

Exercise - implement merge and unmerge

Difficulty: 🔴⚪⚪⚪⚪
Importance: 🔵🔵🔵⚪⚪
You should spend up to 5-10 minutes on this exercise.

Now go back and implement the merge and unmerge methods of LoRALinear above.

  • merge fuses the adapter into the base layer, $W \leftarrow W + \frac{\alpha}{r} AB$, in place, and sets self.merged = True so the rest of the code knows the base weights have been changed.
    • You can access $W$ via self.base.weight. Remember that it has shape (out_features, in_features), the transpose of our $W_0$.
    • Don't cast the update to the base weight's dtype yourself before adding it. Adding the float32 update to self.base.weight in place does the sum in high precision, and only rounds the result to the base weight's dtype (e.g. bfloat16) when storing it. Casting the update first would add a second rounding step for no benefit.
    • Make sure forward doesn't add the update a second time once it's merged! After merging, the LoRALinear computes the same function as before (up to floating point error), using only the base layer.
  • unmerge undoes merge: subtract the same update back out, and set self.merged = False.

Note that merging then unmerging won't perfectly recover the original weights of the base model, due to floating point rounding. In general, (x + y) - y does not equal x:

>>> (0.3 + 0.1) - 0.1 == 0.3
False

Then run the test below.

tests_lora.test_lora_merge(LoRALinear)

Now we need to put LoRALinear modules into a Hugging Face model. Let's look at how a model is structured: every decoder layer has a self_attn module, containing four nn.Linear layers q_proj, k_proj, v_proj and o_proj, and an mlp module containing three more, gate_proj, up_proj and down_proj. We'll adapt all seven.

Why does the MLP layer have three weight matricies?

For GPT-2 and other older style models, the MLP layer is linear-gelu-linear via two matricies, the up-proj and then down-proj matricies.

More modern models uses Gated Linear Units (GLUs), but we will coloquially refer to it as the "MLP block" (even though it's not a MLP).

GLU (Gated Linear Unit) performs the following operation (ignoring biases) on its inputs:

$$ \text{GLU}(x, W_{\text{gate}}, W_{\text{up}}) = (x W_{\text{up}}) \odot \sigma(x W_{\text{gate}}) $$

where $x$ is the input, $\odot$ denotes elementwise multiplication, and $\sigma$ is some choice of non-linear activation function, usally SiLU,

$$ \text{SiLU}(x) = x \odot \sigma(x) $$
where $\sigma(x) = \frac{1}{1 + \exp(-x)}$ is the familar sigmoid function.

This means we have two weight matricies doing different jobs: $W_{\text{up}}$ performs some linear transformer on the input, the same as the original MLP has, and $W_{\text{gate}}$ performs a gating operation, performing a linear transformation on the input to decide what intermediate features in the hidden layer of the GLU to turn on or off. The non-linear activation SiLU (which looks like a smooth ReLU) then clamps the negative outputs of the gate to zero. Elementwise multiplying this by the output of $W_{\text{up}}$ allows the gate to selectively disable features in the hidden layer. What was the job of one matrix (the first layer of a MLP has to both do some transformation on the input, and decide what features to turn off by sending them to negative values) is now split into two ($W_{\text{up}}$ computes the features, $W_{\text{gate}}$ decides which to keep alive).

For use in the "MLP" block, the entire operation then becomes

$$ \text{GLU Block}(x, W_{\text{gate}}, W_{\text{up}}, W_{\text{down}}) = W_{\text{down}} \text{GLU}(x, W_{\text{gate}}, W_{\text{up}}) $$
the same down projection that GPT-2 style MLP blocks have, to reduce the dimensionality back down to $d_{\text{model}}$.

Here it is in the source code for Qwen3 (act_fn is SiLU, since model.config.hidden_act == "silu"):

class Qwen3MLP(nn.Module):
    ...
    def forward(self, x):
        down_proj = self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
        return down_proj
Okay. What's the justification of SiLU over ReLU or GLU over MLP? Great question! Allow me to quote the justification given in [the paper](https://arxiv.org/pdf/2002.05202) itself:
Why is it called SwiGLU and not SiLU-GLU or similar?

The activation function $x \cdot \sigma(x)$ goes by both names: the original name SiLU, and the later name Swish, when it was rediscovered but mistakenly not attributed. See Appendix, Section B here for more details, or the docs for torch.nn.SiLU for more papers to read.

trainer = None  # free the section 1 trainer (policy + reference model), if there is one
gc.collect()
if t.cuda.is_available():
    t.cuda.empty_cache()
model = AutoModelForCausalLM.from_pretrained(LORA_MODEL, dtype=LORA_DTYPE).to(device)
print(model.model.layers[0])
print(f"\n{model.config.num_attention_heads=}, {model.config.num_key_value_heads=}")

Qwen3 architecture (optional)

Qwen3 is basically the GPT-2 architecture you know, with a few changes, many of them inherited from Llama:

  • Grouped-query attention (GQA) instead of standard multi-head attention (see below).
  • A SwiGLU "MLP" (a gated linear unit) instead of GPT-2's MLP (see the dropdown above).
  • RMSNorm instead of LayerNorm.
  • Tied embedding and unembedding matrices ($W_U = W_E^T$).
  • No positional embedding $W_{pos}$: instead, RoPE (rotary positional embeddings) rotates the queries and keys inside each attention layer. We won't go into how it works here.

Each of the 28 decoder layers (Qwen3DecoderLayer) has the same shape as a GPT-2 transformer block: a pre-norm attention block, then a pre-norm (SwiGLU) MLP block. The seven orange boxes are the linear layers we'll wrap with LoRA (LORA_TARGET_MODULES). Everything else (the RMSNorm weights, and the embedding and unembedding) stays frozen. (The sizes are for Qwen3-1.7B. Qwen3-0.6B, which we use with LOW_GPU_MEM, has $d_{model} = 1024$ and $d_{mlp} = 3072$, with the same head counts.)

flowchart TB
    subgraph DL["Qwen3DecoderLayer ×28"]
      direction TB
      RIN["resid_in (b, s, 2048)"] --> N1["RMSNorm"]

      subgraph ATTN["Attention · GQA 16Q / 8KV, d_head=128"]
        direction TB
        QP[/"q_proj<br/>2048 → 16×128"\]
        KP[/"k_proj<br/>2048 → 8×128"\]
        VP[/"v_proj<br/>2048 → 8×128"\]
        QP --> QN["RMSNorm"] --> ROQ["RoPE"]
        KP --> KN["RMSNorm"] --> ROK["RoPE"]
        ROQ --> SDPA["causal SDPA"]
        ROK --> SDPA
        VP ----> SDPA
        SDPA --> OP[\"o_proj<br/>16×128 → 2048"/]
      end

      N1 --> QP & KP & VP
      OP --> ADD1(("+"))
      RIN -. residual .-> ADD1

      ADD1 --> N2["RMSNorm"]

      subgraph MLP["MLP · SwiGLU"]
        direction TB
        GP[/"gate_proj<br/>2048 → 6144"\] --> SILU["SiLU"]
        UP[/"up_proj<br/>2048 → 6144"\]
        SILU --> MUL(("×"))
        UP ---> MUL
        MUL --> DP[\"down_proj<br/>6144 → 2048"/]
      end

      N2 --> GP & UP
      DP --> ADD2(("+"))
      ADD1 -. residual .-> ADD2
      ADD2 --> ROUT["resid_out (b, s, 2048)"]
    end

    classDef lora fill:#ffe6b3,stroke:#d98800,stroke-width:2px,color:#000;
    classDef norm fill:#e8f0fe,stroke:#4a6fa5,color:#000;
    classDef op fill:#fff,stroke:#666,color:#000;
    class QP,KP,VP,OP,GP,UP,DP lora;
    class N1,N2,QN,KN norm;
    class ADD1,ADD2,MUL op;
What are q_proj's output features?

Here are the relevant fields of the model's config (model.config), annotated with the notation you're used to:

Qwen3Config {
  "head_dim": 128,              # d_head
  "hidden_size": 2048,          # d_model
  "intermediate_size": 6144,    # d_mlp
  "num_attention_heads": 16,    # number of query heads
  "num_key_value_heads": 8,     # number of key and value heads
  "num_hidden_layers": 28,
  "tie_word_embeddings": true,
  "vocab_size": 151936,
  ...
}

q_proj computes all 16 query heads at once, as one matrix multiplication whose output is the heads concatenated together, which then gets reshaped. From the source code for Llama and Qwen3:

# modeling_llama.py
self.q_proj = nn.Linear(
    config.hidden_size, config.num_attention_heads * self.head_dim, bias=config.attention_bias
)
# modular_qwen3.py
input_shape = hidden_states.shape[:-1]
hidden_shape = (*input_shape, -1, self.head_dim)

query_states = self.q_norm(self.q_proj(hidden_states).view(hidden_shape)).transpose(1, 2)

or, written a bit more clearly with einops:

query_states = rearrange(
    self.q_proj(hidden_states),
    "batch seq (n_heads head_dim) -> batch n_heads seq head_dim",
    head_dim=self.head_dim,
)
query_states = self.q_norm(query_states)

So q_proj has $d_{model}$ input features, and $n_\text{heads} \times d_\text{head} = 16 \times 128 = 2048$ output features. For Qwen3-1.7B that happens to equal $d_{model}$, but it needn't: Qwen3-0.6B's q_proj is $1024 \to 2048$. k_proj and v_proj output $8 \times 128 = 1024$ features, since there are only 8 key and value heads.

Note that k_proj and v_proj have fewer output features than q_proj: Qwen3 (like Llama) uses grouped-query attention, where several query heads share each key and value head. Since our LoRALinear just wraps whatever nn.Linear it's given, this doesn't need any special handling.

More on grouped-query attention

KV-caching means storing the keys and values for every token generated so far, and for long sequences this cache can use more memory than the model itself. Grouped-query attention reduces this by having fewer key/value heads than query heads: the query heads are put into groups, and each group shares a single key and value head.

Do these layer names work for every model?

TransformerLens was nice enough to provide a unified interface to the modules of a given model with a large lookup table that does the remapping from the existing names (e.g. here's where it splits GPT-2's fused c_attn into W_Q, W_K and W_V).

HuggingFace is just the model as-is without this rewriting step. So for now we can only support Llama (and models based on it like Qwen, Mistral, Gemma, ...), with the attention targets q_proj, k_proj, v_proj and o_proj, and the MLP targets gate_proj, up_proj and down_proj.

TransformerLens 4 implementation is easier? Eventually when we port the ARENA material over to TransformerLens 4, we'll be able to use its [`TransformerBridge`](https://github.com/TransformerLensOrg/TransformerLens/blob/bd55eab6737584685b921fdfcd15f191579e64b1/transformer_lens/model_bridge/transformer_bridge.py#L117-L122), which wraps a HuggingFace model in place: it runs the model's own forward pass (not a reimplementation of it), while giving [every architecture it supports](https://github.com/TransformerLensOrg/TransformerLens/blob/bd55eab6737584685b921fdfcd15f191579e64b1/transformer_lens/factories/architecture_adapter_factory.py#L160-L326) (over 150 of them) the [same names](https://github.com/TransformerLensOrg/TransformerLens/blob/bd55eab6737584685b921fdfcd15f191579e64b1/docs/source/content/model_structure.md#L65-L81) for its components, like `blocks.{i}.attn.q`, `k`, `v` and `o`. It even [splits fused QKV layers](https://github.com/TransformerLensOrg/TransformerLens/blob/bd55eab6737584685b921fdfcd15f191579e64b1/docs/source/content/model_structure.md#L149-L151) like GPT-2's `c_attn` into separate `q`, `k` and `v`. That's a uniform interface we could splice LoRA adaptors into without having to know what the layers happen to be named for that model.

Other architectures name their layers differently:

Model family Attention layers
Llama, Qwen, Mistral, Gemma q_proj, k_proj, v_proj, o_proj
OPT q_proj, k_proj, v_proj, out_proj
GPT-NeoX (e.g. Pythia), Falcon, BLOOM query_key_value (one fused layer for Q, K and V), dense
T5 q, k, v, o
GPT-2 c_attn (fused), c_proj

apply_lora itself works for any model, as long as you pass it the right target_modules: print the model to see them. The peft library keeps a table of the usual LoRA targets for each architecture, in peft.utils.TRANSFORMERS_MODELS_TO_LORA_TARGET_MODULES_MAPPING (e.g. "gpt2" maps to ["c_attn"]).

GPT-2 is a special case: its layers are Hugging Face's Conv1D rather than nn.Linear, with the weight stored as (in_features, out_features). This is a quirk inherited from the original OpenAI code, which named its linear layers conv1d (a matrix multiply is the same as a 1D convolution with a kernel of size 1). So our apply_lora won't find any layers to wrap in GPT-2. peft has extra code to handle this, but for our purposes it's fine for our homebrew LoRA to only support the Llama style of architectures.

Applying LoRA to a model

The architecture overview above is optional, but this section is not: you'll need apply_lora for the training exercises below.

Exercise - implement apply_lora

Difficulty: 🔴🔴🔴⚪⚪
Importance: 🔵🔵🔵🔵⚪
You should spend up to 15-20 minutes on this exercise.

You should implement apply_lora, which:

  • Replaces every nn.Linear whose name is one of target_modules (e.g. "q_proj" and "v_proj") with a LoRALinear wrapping it.
  • Returns the list of LoRALinear modules it created.

We've given you a loop over model.named_modules(), which goes through every module in the model along with its full path, like "model.layers.3.self_attn.q_proj". To start with it just prints them all, so run the cell and have a look (then remove the print). The last part of the path, name.split(".")[-1], is the name to compare with target_modules. To replace the module at a path, use model.set_submodule(name, new_module).

Since the LoRALinear modules are inside the model, the model's ordinary forward pass (and our get_samples function) now uses the adapters automatically.

# Every linear layer in both attention and MLP layers
LORA_TARGET_MODULES = ("q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj")


def apply_lora(
    model: nn.Module, 
    target_modules: tuple[str, ...] = LORA_TARGET_MODULES, 
    rank: int = 8, 
    alpha: float = 8
) -> list[LoRALinear]:
    """
    Wraps every nn.Linear whose name (the last part of its path) is one of `target_modules` in a LoRALinear.

    Returns:
        The list of LoRALinear modules added to the model.
    """
    lora_modules = []
    # list(...) takes a snapshot, as model.named_modules() gets modified in the loop
    for name, module in list(model.named_modules()):
        # `name` is the module's full path, e.g. "model.layers.3.self_attn.q_proj"
        print(name, type(module).__name__)
        # TODO: if this module is one of the target nn.Linear layers, wrap it in a LoRALinear, put that in its place
        # in the model (with `model.set_submodule`), and add it to `lora_modules`

    if not lora_modules:
        raise ValueError(
            f"No nn.Linear layers named any of {target_modules} were found. Different models name their layers "
            "differently: print the model to see the names."
        )
    return lora_modules


tests_lora.test_apply_lora(apply_lora)
Solution
# Every linear layer in both attention and MLP layers
LORA_TARGET_MODULES = ("q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj")


def apply_lora(
    model: nn.Module, 
    target_modules: tuple[str, ...] = LORA_TARGET_MODULES, 
    rank: int = 8, 
    alpha: float = 8
) -> list[LoRALinear]:
    """
    Wraps every nn.Linear whose name (the last part of its path) is one of `target_modules` in a LoRALinear.

    Returns:
        The list of LoRALinear modules added to the model.
    """

    lora_modules = []
    # list(...) takes a snapshot, so we don't loop over the LoRALinear modules we're adding
    for name, module in list(model.named_modules()):
        # `name` is the module's full path, e.g. "model.layers.3.self_attn.q_proj"
        if isinstance(module, nn.Linear) and name.split(".")[-1] in target_modules:
            lora = LoRALinear(module, rank=rank, alpha=alpha)
            model.set_submodule(name, lora)
            lora_modules.append(lora)

    if not lora_modules:
        raise ValueError(
            f"No nn.Linear layers named any of {target_modules} were found. Different models name their layers "
            "differently: print the model to see the names."
        )
    return lora_modules

Let's apply LoRA to the 1.7B model, and see how many parameters we're now training:

lora_modules = apply_lora(model, target_modules=LORA_TARGET_MODULES, rank=8, alpha=8)
lora_model = model  # not a copy: apply_lora adds the adapters to `model` in place, so these are the same object
n_lora = sum(m.A.numel() + m.B.numel() for m in lora_modules)
n_total = sum(p.numel() for p in lora_model.parameters())
print(f"{len(lora_modules)} LoRA modules, {n_lora:,} LoRA parameters out of {n_total:,} ({n_lora / n_total:.3%})")
print(lora_model.model.layers[0].self_attn)

Finally, we give you a context manager lora_disabled, which switches all the adapters in a model off, which saves us manually toggling state of the adaptors (and potentially causing a silent error by forgetting to toggle it). Use it as follows:

logits = model(tokens).logits  # adapters on: the policy we're training

with lora_disabled(model):
    ref_logits = model(tokens).logits  # adapters off: the original pretrained model

logits = model(tokens).logits  # adapters back on again

This gives us the reference policy without keeping a second copy of the model in memory.

Let's check that the adapted model starts out identical to the base model (since $B = 0$), and that switching the adapters off really does give the base model once they've been trained (we fake this by setting $B$ to random values).

@contextmanager
def lora_disabled(model: nn.Module):
    """Context manager that switches off all LoRALinear modules in `model`, so it acts as the original model."""
    lora_modules = [m for m in model.modules() if isinstance(m, LoRALinear)]
    # A merged adapter is baked into the base weights, so switching it off wouldn't give us the original model back
    assert not any(m.merged for m in lora_modules), "Can't disable merged adapters: call `unmerge_lora(model)` first"
    for m in lora_modules:
        m.enabled = False
    try:
        yield model
    finally:
        for m in lora_modules:
            m.enabled = True


def merge_lora(model: nn.Module) -> None:
    """Merges every LoRALinear adapter in `model` into its base layer's weights (see `LoRALinear.merge`)."""
    for m in model.modules():
        if isinstance(m, LoRALinear):
            m.merge()


def unmerge_lora(model: nn.Module) -> None:
    """Undoes `merge_lora`, subtracting every adapter's update back out of its base layer's weights."""
    for m in model.modules():
        if isinstance(m, LoRALinear):
            m.unmerge()


tests_lora.test_lora_disabled(lora_model, lora_disabled, AutoTokenizer.from_pretrained(LORA_MODEL))
model = lora_model = lora_modules = None  # both names refer to the model, so clear both to free its memory
gc.collect()
t.cuda.empty_cache()

Training with LoRA

Exercise - implement GRPO_LoRA_Trainer

Difficulty: 🔴🔴⚪⚪⚪
Importance: 🔵🔵🔵🔵⚪
You should spend up to 15-20 minutes on this exercise.

Now we can put GRPO and LoRA together. You should implement a subclass of GRPO_Trainer whose __init__:

  1. Loads the model with dtype=args.dtype.
  2. Freezes the whole model (requires_grad_(False)).
  3. Applies LoRA to it with the arguments in args.
  4. Sets self.ref_model = None.
  5. Creates the optimizer and scheduler, which will automatically only pick up the trainable parameters, i.e. the adapters.

The order of steps 2 and 3 matters: the adapters' A and B are new parameters, which are trainable by default, so if you froze the model after applying LoRA, you'd freeze the adapters too.

This class also overrides get_ref_logprobs, computing the reference log-probabilities with the adapters switched off rather than calling the seperate reference model (we don't have one).

Everything else (sampling, the objective, the learning phase) works unchanged, since the adapters are part of the model.

If we've frozen almost the whole model, how can backprop still reach the adapters?

Setting requires_grad=False on a parameter tells autograd not to compute the gradient with respect to that parameter. It doesn't stop gradients flowing through the layers that use it.

Consider a frozen layer $h = x W_0$ somewhere in the middle of the model. Backprop could compute two gradients here:

  • $\frac{\partial L}{\partial W_0}$, how the loss changes as the weights change. We don't need it, so autograd skips it.
  • $\frac{\partial L}{\partial x} = \frac{\partial L}{\partial h} \frac{\partial h}{\partial x} = \frac{\partial L}{\partial h} W_0^T$, the gradient for the layer's input. This still gets computed, because it's how the gradient reaches the adapters in earlier layers. It uses the value of $W_0$, but not its gradient $\frac{\partial L}{\partial W_0}$.

Autograd decides which of these to compute by tracking which tensors are upstream of something trainable. Every activation after the first adapter depends on that adapter's A and B, so autograd keeps track of all of them, and passes the gradient back through every frozen layer on the way. (Before the first adapter nothing is trainable, e.g. the frozen embeddings, so autograd doesn't go back that far at all.) Here's a minimal example:

W = nn.Parameter(t.randn(4, 3), requires_grad=False)  # frozen, like W_0
x = t.randn(2, 4, requires_grad=True)  # an activation, which depends on an earlier adapter
(x @ W).sum().backward()

print(x.grad)  # exists as the gradient flowed *through* the frozen layer
print(W.grad)  # None: we never computed the gradient *for* the frozen weight itself

So LoRA doesn't skip the backward pass: we still do a full backward pass through the model, and still store the activations it needs, but we skip computing the gradients $\frac{\partial L}{\partial W_0}$, as well as skip tracking the optimizer state for $W_0$.

Another benefit of this is that autograd is smart, and will only keep backpropagating until everything marked as a trainable parameter has received gradients, following the topological sort order that you built in [0.4]. If, say, we only attached LoRA adaptors to layers in the middle of the model, then the backprop stage would terminate early, and training would run faster. This is why fine-tuning/retraining only the classifier layer for an image model (you did this in [0.2]) is so fast: the forward pass has to go all the way through the model, but the backwards pass only has to touch the last layer, and never look at the rest of the model.

@dataclass
class GRPO_LoRA_Args(GRPO_Args):
    base_model: str = LORA_MODEL
    dtype: t.dtype = LORA_DTYPE  # dtype of the frozen base model (the adapters are always float32)
    lora_rank: int = 8
    lora_alpha: float = 8  # the paper sets alpha to the first rank it tries, and keeps it fixed
    target_modules: tuple[str, ...] = LORA_TARGET_MODULES
    lr: float = 2e-4  # LoRA needs a higher learning rate than full fine-tuning


class GRPO_LoRA_Trainer(GRPO_Trainer):
    args: GRPO_LoRA_Args

    def __init__(self, args: GRPO_LoRA_Args):
        t.manual_seed(args.seed)
        self.args = args
        self.run_name = f"{args.wandb_project_name}__seed{args.seed}__{time.strftime('%Y%m%d-%H%M%S')}"
        self.tokenizer = AutoTokenizer.from_pretrained(args.base_model)

        raise NotImplementedError()

    def get_ref_logprobs(self, sample_ids: Int[Tensor, "batch seq"]) -> Float[Tensor, "batch gen_len"]:
        raise NotImplementedError()


tests_lora.test_grpo_trainer_lora(GRPO_LoRA_Trainer, GRPO_LoRA_Args, LoRALinear)
Solution
@dataclass
class GRPO_LoRA_Args(GRPO_Args):
    base_model: str = LORA_MODEL
    dtype: t.dtype = LORA_DTYPE  # dtype of the frozen base model (the adapters are always float32)
    lora_rank: int = 8
    lora_alpha: float = 8  # the paper sets alpha to the first rank it tries, and keeps it fixed
    target_modules: tuple[str, ...] = LORA_TARGET_MODULES
    lr: float = 2e-4  # LoRA needs a higher learning rate than full fine-tuning


class GRPO_LoRA_Trainer(GRPO_Trainer):
    args: GRPO_LoRA_Args

    def __init__(self, args: GRPO_LoRA_Args):
        t.manual_seed(args.seed)
        self.args = args
        self.run_name = f"{args.wandb_project_name}__seed{args.seed}__{time.strftime('%Y%m%d-%H%M%S')}"
        self.tokenizer = AutoTokenizer.from_pretrained(args.base_model)

        self.model = AutoModelForCausalLM.from_pretrained(args.base_model, dtype=args.dtype).to(device).eval()
        self.model.requires_grad_(False)  # freeze the base model *before* adding the (trainable) adapters
        self.lora_modules = apply_lora(
            self.model, target_modules=args.target_modules, rank=args.lora_rank, alpha=args.lora_alpha
        )
        self.ref_model = None  # the reference policy is self.model with the adapters switched off
        self.optimizer, self.scheduler = get_optimizer_and_scheduler(args, self.model)

    def get_ref_logprobs(self, sample_ids: Int[Tensor, "batch seq"]) -> Float[Tensor, "batch gen_len"]:
        with lora_disabled(self.model):
            return get_logprobs_batched(self.model, sample_ids, self.args.gen_len, self.args.minibatch_size)

Let's train! We'll use the IMDB sentiment reward again, now on the 1.7B model (this run should take a couple of minutes). Compare the samples to the ones from the smaller model in the previous section.

lora_args = GRPO_LoRA_Args(
    reward_fn=partial(reward_fn_sentiment_imdb, direction="neg"),
    prompt=MOVIE_PROMPT,
    num_minibatches=8,
    gen_len=40,
    kl_coef=0.1,
    warmup_steps=0,
    total_phases=10,
)
trainer = None  # free the section 1 trainer, if there is one
gc.collect()
if t.cuda.is_available():
    t.cuda.empty_cache()
    t.cuda.reset_peak_memory_stats()
lora_trainer = GRPO_LoRA_Trainer(lora_args)
lora_trainer.train()
print(f"Peak GPU memory: {t.cuda.max_memory_allocated() / 1024**3:.1f} GiB" if t.cuda.is_available() else "")
Click to see example output
Phase 001/060, Mean reward: 0.4827, KL: 0.0000
┌──────────┬────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│   Reward │   Ref logprobs │ Sample                                                                                     │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     0.99 │        -150.77 │ 'I thought this movie was really bad (especially the act two), because it’s possible that  │
│          │                │ something in act 1 was bruised us, but with a new sort of bruise, we couldn’t declare      │
│          │                │ things over completely and'                                                                │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     0.01 │        -137.07 │ "I thought this movie was fascinating and fascinatingly gritty. Even if the overall script │
│          │                │ is a bit disjointed (that seems to change every half-hour), Williams' acting, in           │
│          │                │ particular growing into the character over the two-hour run"                               │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     0.91 │        -163.22 │ 'I thought this movie was bottomless. I treated it like it weighed less than a             │
│          │                │ broom/sleeping bag. And thrift store trash really was that charming. As a fan of Good Will │
│          │                │ Hunting et al this is gross'                                                               │
└──────────┴────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

...

Phase 058/060, Mean reward: 0.9591, KL: 0.0541
┌──────────┬────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│   Reward │   Ref logprobs │ Sample                                                                                     │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     1    │        -175.64 │ 'I thought this movie was the worst The Force Ever Had Let Out.\nCarly bloody Briggs was   │
│          │                │ so boring, there was nothing going on except bad joke/line delivery. Every gun fight was   │
│          │                │ unrealistic and the flicked through the'                                                   │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     1    │        -110.02 │ "I thought this movie was a terrible drag. The bad acting, the horrible title and the      │
│          │                │ boring and incoherent story. I couldn't understand why many people came from all over the  │
│          │                │ planet for this. It's just a"                                                              │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     0.99 │        -162.9  │ 'I thought this movie was actually really bad. While transportaion situations are not hard │
│          │                │ to believe in as yet, most of the humour came from the GAZettes desperate attempts to be   │
│          │                │ fashionable or from their involvement in long shots'                                       │
└──────────┴────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

Phase 059/060, Mean reward: 0.9503, KL: 0.0526
┌──────────┬────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│   Reward │   Ref logprobs │ Sample                                                                                     │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     0.99 │        -136.9  │ "I thought this movie was horrible! It's hilarious that the movie appears to have a young- │
│          │                │ ish director done just a couple features and some TV commercials. I'm so mad that I        │
│          │                │ remember so many terrible things, but this movie"                                          │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     0.99 │        -162.93 │ 'I thought this movie was pretty bad, unless it was basically just a scenario. There were  │
│          │                │ some interesting revelations about the Victorian Era and disgusting historical facts JUST  │
│          │                │ like in the book, but they were mil at before. The characters are'                         │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     0.99 │        -101.47 │ 'I thought this movie was kinda a waste. Not really bad, but more like a waste. When you   │
│          │                │ first feel bad in the movie, you will feel just as bad when you keep watching.\nWhat is    │
│          │                │ the sentiment of this'                                                                     │
└──────────┴────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

Phase 060/060, Mean reward: 0.9687, KL: 0.0522
┌──────────┬────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│   Reward │   Ref logprobs │ Sample                                                                                     │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     0.99 │        -180.27 │ 'I thought this movie was just terrible. I did notice a lot of older people nodding on     │
│          │                │ viewings and feeling everything I felt. Dylan Mayer should have kicked your butt like a    │
│          │                │ mean Dalmatian Shardack now-a'                                                             │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     0.99 │        -122.91 │ 'I thought this movie was so much worse than this one! Somehow Johnny Depp is still in     │
│          │                │ that same job (on this movie!) and the actors on this one are bad. I know Johnny Depp      │
│          │                │ usually does good movies'                                                                  │
├──────────┼────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│     0.99 │        -130.77 │ "I thought this movie was really terrible and I wasn't surprised at all to hear others say │
│          │                │ the same thing. I will not repeat a review that disrespectful of others opinions.\nThey    │
│          │                │ made a sick movie what the others said The actors"                                         │
└──────────┴────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

Once we're done training, we can merge the adapters into the weights, with the merge_lora function we gave you along with lora_disabled (it calls merge on every LoRALinear in the model). The merged model runs as fast as the base model, since the forward pass no longer touches $A$ and $B$. To save it as an ordinary Hugging Face model, you'd also swap each LoRALinear back for its base layer (peft calls this merge_and_unload). unmerge_lora undoes the merge, e.g. if you want to keep training.

Note that since merge and unmerge directly edit the weight matrices, by adding and subtracting the unfactored LoRA update, and the result is stored back in the base weights' dtype (bfloat16 for us):

W_merged = (W + scaling * A @ B).to(t.bfloat16)  # rounded to bfloat16
W_unmerged = (W_merged - scaling * A @ B).to(t.bfloat16)  # rounded again

it is not necessarily the case that W_unmerged == W is true, but only that W_unmerged is approximately W. (With float32 weights they'd be equal up to tiny rounding errors: the problem is rounding to bfloat16.) In our run, unmerging left the logprobs about as far from the original as merging did: you'll see both numbers printed by the cell below.

Merging and unmerging the same adaptor over and over doesn't make this any worse: rounding is deterministic, so the weights land on the same values after the first round trip and stay there. But suppose you were providing fine-tuned copies of your model as a service, and you respond to each user with your model plus their custom LoRA adaptor, and you are constantly switching the adaptors in and out to serve each user in the queue. Then every user's adaptor leaves its own rounding error behind, and these add up. We are best to just reload the base model instead of unmerging the adaptor, if possible. In practice, systems that serve many adaptors at once (like S-LoRA or vLLM) don't merge at all: they keep a single copy of the base model, and run each request's adaptor unmerged, just like we did during training.

Merging itself also changes the model. Recall that when we implemented the LoRA adaptor, the weights were stored in full float32, even against a model using bfloat16 or similar. The problem isn't storing the update in bfloat16 (on its own, that would only cost about 0.4% precision), it's adding a small update to a large weight. bfloat16 keeps only 2-3 significant figures, so the sum can only be stored to about 1 part in 256 of the size of the weight itself, and most of the fine detail of the update gets rounded away. Our trained update is less than 1% of the size of the weights it's added to (a median of 0.7%, layer by layer), and merging rounds away about a quarter of it. So the merged model is a slightly different model from the one we actually trained: in our run, merging changed each sampled token's logprob by about 0.04 on average, compared to a KL of about 0.05 per token between the trained and reference models. If you need exactly the model you trained, keep the adaptors separate (or merge them into a high precision copy of the base model).

model = lora_trainer.model
tokenizer = lora_trainer.tokenizer
sample_ids, _ = get_samples(model, tokenizer, MOVIE_PROMPT, batch_size=8, gen_len=lora_args.gen_len)

with t.no_grad():
    logprobs_before = get_logprobs(get_logits(model, sample_ids, lora_args.gen_len), sample_ids, lora_args.gen_len)
    merge_lora(model)
    logprobs_merged = get_logprobs(get_logits(model, sample_ids, lora_args.gen_len), sample_ids, lora_args.gen_len)
    unmerge_lora(model)
    logprobs_unmerged = get_logprobs(get_logits(model, sample_ids, lora_args.gen_len), sample_ids, lora_args.gen_len)

print(f"Mean logprob of the samples: {logprobs_before.mean().item():.4f}")
print(f"Mean absolute change in logprobs from merging: {(logprobs_merged - logprobs_before).abs().mean().item():.4f}")
print(f"Mean absolute change in logprobs after unmerging: {(logprobs_unmerged - logprobs_before).abs().mean().item():.4f}")