liveBook · Manning

Low-rank adaptation

As explained in LLMs in Production

Low-rank adaptation is a parameter-efficient fine-tuning method that adapts a pretrained model through trainable low-rank updates while leaving most original parameters fixed.

Suppose a deployed model starts missing a task it needs to handle, but retraining it from scratch is too expensive. Low-rank adaptation (LoRA) offers a smaller update: train an adapter for the task while leaving the original model weights unchanged. The adapter can be saved separately, making task-specific versions easier to maintain. section 9.3.2 section 5.3.5

Ordinary weight updating can be pictured as W = W + ΔW. LoRA represents the update as the product of two narrower matrices: ΔW = Wa × Wb. For a 100 × 100 update with rank 2, those matrices contain 400 trainable elements rather than 10,000. This constrains the update; it does not make every possible 10,000-element update available. Rank is a practical trade-off: increasing it allows a more complex update but saves less memory. section 5.3.5

LoRA belongs to parameter-efficient fine-tuning (PEFT). It is especially useful when a base model already does useful work and needs a focused change for a new task or domain. The approach is not a guarantee of good output: training data still matters, and a worked model update produced a noticeable change without yet producing sufficiently coherent text. section 5.3.5 section 5.5 section 9.3.2

A small update alongside a linear layer

A PyTorch implementation creates two trainable matrices, A and B, with shapes input × rank and rank × output. Its forward pass adds a scaled update to the existing layer’s output: linear(x) + alpha × (x @ A @ B). The example initializes B to zero, so the adapter initially contributes no change. section 9.3.2

After training, keep the model and LoRA as separate state dictionaries. For inference, either load the adapter over the base model or merge its layers into the model. A separate adapter is useful when maintaining distinct task updates; merging produces a model that can be used without loading the adapter separately. section 9.3.2 section 5.3.5

Listing 9.6 Low-rank adaptation
class LoRALayer(nn.Module):                            #1
    def __init__(self, in_dim, out_dim, rank, alpha):
        super().__init__()
        standard_deviation = 1 / torch.sqrt(torch.tensor(rank).float())
        self.A = nn.Parameter(
            torch.randn(in_dim, rank) * standard_deviation
        )
        self.B = nn.Parameter(torch.zeros(rank, out_dim))
        self.alpha = alpha

    def forward(self, x):
        x = self.alpha * (x @ self.A @ self.B)
        return x


class LinearWithLoRA(nn.Module):
    def __init__(self, linear, rank, alpha):
        super().__init__()
        self.linear = linear
        self.lora = LoRALayer(
            linear.in_features, linear.out_features, rank, alpha
        )
    def forward(self, x):
        return self.linear(x) + self.lora(x)


class LlamaBlock(nn.Module):    #2
    def __init__(self, config):
        super().__init__()
        self.config = config

        self.rms = RMSNormalization(
            (config["d_model"], config["d_model"])
        ).to(device)

        self.attention = RoPEMaskedMultiheadAttention(config).to(device)
        self.feedforward = nn.Sequential(
            LinearWithLoRA(config["d_model"], config["d_model"]),   #3
            SwiGLU(config["d_model"]),
        ).to(device)

    def forward(self, x):
        x = self.rms(x)
        x = x + self.attention(x)

        x = self.rms(x)
        x = x + self.feedforward(x)
        return x


class SimpleLlama(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.config = config

        self.embedding = nn.Embedding(
            config["vocab_size"], config["d_model"]
        )
        self.llama_blocks = nn.Sequential(
            OrderedDict(
                [
                    (f"llama_{i}", LlamaBlock(config))
                    for i in range(config["n_layers"])
                ]
            )
        )

        self.ffn = nn.Sequential(
            LinearWithLoRA(config["d_model"], config["d_model"]),        #4
            SwiGLU(config["d_model"]),
            LinearWithLoRA(config["d_model"], config["vocab_size"]),     #5
        )

        print(
            f"model params: {sum([m.numel() for m in self.parameters()])}"
        )

    def forward(self, idx, targets=None):
        x = self.embedding(idx)
        x = self.llama_blocks(x)
        logits = self.ffn(x)

        if targets is None:
            return logits

        else:
            loss = F.cross_entropy(
                logits.view(-1, self.config["vocab_size"]),
                targets.view(-1),
                ignore_index=tokenizer.pad_token_id,
                reduction="sum",
            )
            return logits, loss

dataset = load_dataset(     #6
    "text",
    data_files={
        "train": ["../../data/Lima-train.csv"],
        "val": ["../../data/Lima-test.csv"],
    },
    streaming=True,
)

encoded_dataset = dataset.map(
    lambda examples: tokenizer(
        examples["text"],
        padding=True,
        max_length=128,
        truncation=True,
       \]] return_tensors="pt",
    ),
    batched=True,
)
train_data = iter(encoded_dataset["train"].shuffle())
val_data = iter(encoded_dataset["val"].shuffle())
train_data = cycle(train_data)
val_data = cycle(val_data)

llama.to("cpu")          #7
add_lora(llama)
llama.to(device)

parameters = [{"params": list(get_lora_params(llama))}]     #8
lora_optimizer = torch.optim.AdamW(parameters, lr=1e-3)     #9

train(      #10
    llama,
    lora_optimizer,
    scheduler,
    data=train_data,
    config=MASTER_CONFIG,
    lora=True,
    print_logs=True,
)

state_dict = llama.state_dict()        #11
lora_state_dict = {k: v for k, v in state_dict.items() if name_is_lora(k)}
torch.save(llama.state_dict(), "./llama.pth")
torch.save(lora_state_dict, "./lora.pth")

Inspect the PyTorch LoRA layer and wrapper to see exactly where the trainable update joins an existing linear layer. section 9.3.2

How LLMs in Production covers it

How deep it goes
Works through low-rank update dimensions, rank trade-offs, PyTorch layer integration, training, and inference; defers a full backpropagation explanation.
Examples
Uses a 100 × 100 update factored at rank 2 and a PyTorch LoRA layer added to a linear layer.
What it assumes
Assumes familiarity with model weights and linear layers; introduces the update equation but does not teach backpropagation from scratch.
What you can do after
Build it from scratch as a PyTorch layer, then load or merge its saved update for inference.
How it connects
Connects LoRA to parameter-efficient fine-tuning, singular value decomposition, adapters, and quantization.

Also explained in

Compare how 9 books explain low-rank adaptation →

Related concepts

This explainer was generated by Manning's knowledge graph from the text of LLMs in Production. The section links point to the parts of the book it draws on.