Prefix Tuning › Code Implementation

Michael BrenndoerferDecember 7, 202513 min read
Language AI HandbookAll chapters
Part 35 of 67Parameter-Efficient Fine-tuning
Chapter 262Prefix Tuning › Code Implementation
Reading section 7 of 11 · Code Implementation · 13 minSection 7 of 11 · 13 min

Book contents

Section 7 of 11 · 13 min read

Prefix Tuning › Code ImplementationOne section · Sections in this chapter7 / 11 ↓
Reading tools Definitions & inline links

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

Let us implement prefix tuning for a GPT-2 model. We will build the components step by step, showing how prefix vectors fit into transformer attention. The implementation mirrors the original paper's design, including the reparameterization trick for training stability.

In[8]:
Code
import torch  # noqa: F401
import torch.nn as nn  # noqa: F401
from transformers import (  # noqa: F401
    GPT2Config,
    GPT2LMHeadModel,
    GPT2Tokenizer,
)
from transformers.cache_utils import DynamicCache  # noqa: F401

Defining the Prefix Module

The core of prefix tuning is a module that generates layer-specific key and value prefixes. We implement the reparameterization trick for stable training: a small, shared matrix is projected through an MLP to produce the full set of layer-specific prefix vectors.

In[9]:
Code
class PrefixEncoder(nn.Module):
    """
    Generates prefix key-value pairs for all transformer layers.
    Uses reparameterization through an MLP for training stability.
    """

    def __init__(
        self,
        num_layers: int,
        num_heads: int,
        head_dim: int,
        prefix_length: int,
        hidden_dim: int = 512,
    ):
        super().__init__()
        self.num_layers = num_layers
        self.num_heads = num_heads
        self.head_dim = head_dim
        self.prefix_length = prefix_length

        # Total dimension for all key-value pairs across all layers
        # Factor of 2 for key and value
        self.total_dim = 2 * num_layers * num_heads * head_dim

        # Learnable prefix embeddings (to be transformed by MLP)
        self.prefix_tokens = nn.Parameter(
            torch.randn(prefix_length, hidden_dim)
        )

        # Reparameterization MLP
        self.mlp = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim),
            nn.Tanh(),
            nn.Linear(hidden_dim, self.total_dim),
        )

    def forward(self, batch_size: int) -> torch.Tensor:
        """
        Generate prefix key-value pairs for a batch.

        Returns:
            Tensor of shape (batch_size, num_layers, 2, num_heads, prefix_length, head_dim)
        """
        # Transform prefix tokens through MLP
        # Shape: (prefix_length, total_dim)
        prefix_transformed = self.mlp(self.prefix_tokens)

        # Reshape to separate layers, key/value, and heads
        # Shape: (prefix_length, num_layers, 2, num_heads, head_dim)
        prefix = prefix_transformed.view(
            self.prefix_length,
            self.num_layers,
            2,  # key and value
            self.num_heads,
            self.head_dim,
        )

        # Permute to (num_layers, 2, num_heads, prefix_length, head_dim)
        prefix = prefix.permute(1, 2, 3, 0, 4)

        # Expand for batch
        # Shape: (batch_size, num_layers, 2, num_heads, prefix_length, head_dim)
        prefix = prefix.unsqueeze(0).expand(batch_size, -1, -1, -1, -1, -1)

        return prefix

The prefix_tokens parameter is the PθP_\theta matrix from the theoretical formulation: a compact, shared representation that the MLP projects to the full set of layer-specific prefixes. The total_dim accounts for all layers, the key-value split, all attention heads, and all head dimensions, following the formula 2×L×dk2 \times L \times d_k where dk=num_heads×head_dimd_k = \text{num\_heads} \times \text{head\_dim}.

In[10]:
Code
# Test the prefix encoder
encoder = PrefixEncoder(
    num_layers=12, num_heads=12, head_dim=64, prefix_length=20, hidden_dim=512
)

# Generate prefixes for a batch of 4
prefix = encoder(batch_size=4)
Out[11]:
Console
Prefix shape: torch.Size([4, 12, 2, 12, 20, 64])
  - Batch size: 4
  - Num layers: 12
  - Key/Value: 2
  - Num heads: 12
  - Prefix length: 20
  - Head dim: 64

The prefix encoder generates key-value pairs for each layer. The shape shows the full structure: for each sample in the batch, we have prefixes for each layer, split into key (index 0) and value (index 1) components, organized by attention head, with the prefix length and head dimension as the innermost dimensions.

Computing Parameter Counts

Let us verify the parameter efficiency of prefix tuning and understand where the parameters live.

In[12]:
Code
def count_parameters(model):
    """Count trainable and total parameters."""
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    total = sum(p.numel() for p in model.parameters())
    return trainable, total


def count_prefix_parameters(prefix_encoder):
    """Count parameters in the prefix encoder."""
    total = sum(p.numel() for p in prefix_encoder.parameters())
    return total
In[13]:
Code
# Compare with GPT-2 small
config = GPT2Config()

# Create a prefix encoder matching GPT-2 small
prefix_encoder = PrefixEncoder(
    num_layers=config.n_layer,
    num_heads=config.n_head,
    head_dim=config.n_embd // config.n_head,
    prefix_length=20,
    hidden_dim=512,
)

prefix_params = count_prefix_parameters(prefix_encoder)

# Create a dummy model to calculate parameters dynamically
dummy_model = GPT2LMHeadModel(config)
gpt2_params = sum(p.numel() for p in dummy_model.parameters())
Out[14]:
Console
GPT-2 small setup:
  - Layers: 12
  - Heads: 12
  - Hidden dim: 768
  - Head dim: 64

Parameter comparison:
  - Prefix encoder params: 9,728,512
  - GPT-2 small params: 124,439,808
  - Ratio: 7.818%

With a prefix length of 20 and a hidden dimension of 512, this demonstration's prefix encoder has 9,728,512 parameters, or about 7.8% as many as GPT-2 small. The bulk of these parameters are in the reparameterization MLP, particularly the second linear layer that projects to the large combined prefix dimension across all layers. Smaller bottlenecks reduce this training-time share, and the generated key-value prefix is the only task-specific state needed after reparameterization.

Prefix-Tuned GPT-2 Model

Now let us create a wrapper that integrates prefix tuning with GPT-2. The wrapper freezes all base model parameters and makes only the prefix encoder trainable. It also handles the practical detail of converting prefix representations to the past_key_values format that Hugging Face's GPT-2 expects.

In[15]:
Code
class PrefixTunedGPT2(nn.Module):
    """
    GPT-2 with prefix tuning.
    The base GPT-2 parameters are frozen; only the prefix encoder is trained.
    """

    def __init__(
        self,
        model_name: str = "gpt2",
        prefix_length: int = 20,
        prefix_hidden_dim: int = 512,
    ):
        super().__init__()

        # Load pretrained GPT-2 and freeze it
        # self.gpt2 = GPT2LMHeadModel.from_pretrained(model_name)  # Commented out to avoid large download
        self.gpt2 = GPT2LMHeadModel(
            GPT2Config()
        )  # Use random initialization for demo
        for param in self.gpt2.parameters():
            param.requires_grad = False

        # Get model setup
        config = self.gpt2.config
        self.num_layers = config.n_layer
        self.num_heads = config.n_head
        self.head_dim = config.n_embd // config.n_head
        self.prefix_length = prefix_length

        # Create trainable prefix encoder
        self.prefix_encoder = PrefixEncoder(
            num_layers=self.num_layers,
            num_heads=self.num_heads,
            head_dim=self.head_dim,
            prefix_length=prefix_length,
            hidden_dim=prefix_hidden_dim,
        )

    def get_prefix_past_key_values(self, batch_size: int):
        """
        Convert prefix to the past_key_values format expected by GPT-2.

        GPT-2 expects past_key_values as a DynamicCache where each layer's
        key and value tensors have shape (batch_size, num_heads, seq_len, head_dim).
        """
        # Get prefix: (batch_size, num_layers, 2, num_heads, prefix_length, head_dim)
        prefix = self.prefix_encoder(batch_size)

        # Build a DynamicCache from the prefix tensors
        cache = DynamicCache()
        for layer_idx in range(self.num_layers):
            # Extract key and value for this layer
            key = prefix[
                :, layer_idx, 0, :, :, :
            ]  # (batch, heads, prefix_len, head_dim)
            value = prefix[:, layer_idx, 1, :, :, :]
            cache.update(key, value, layer_idx)

        return cache

    def forward(
        self,
        input_ids: torch.Tensor,
        attention_mask: torch.Tensor = None,
        labels: torch.Tensor = None,
    ):
        batch_size = input_ids.shape[0]

        # Get prefix as past_key_values
        past_key_values = self.get_prefix_past_key_values(batch_size)

        # Create attention mask that includes prefix positions
        if attention_mask is None:
            attention_mask = torch.ones_like(input_ids)

        # Prepend ones for prefix positions
        prefix_attention = torch.ones(
            batch_size,
            self.prefix_length,
            dtype=attention_mask.dtype,
            device=attention_mask.device,
        )
        extended_attention_mask = torch.cat(
            [prefix_attention, attention_mask], dim=1
        )

        # Forward through GPT-2 with prefix as past_key_values
        outputs = self.gpt2(
            input_ids=input_ids,
            attention_mask=extended_attention_mask,
            past_key_values=past_key_values,
            labels=labels,
        )

        return outputs

The get_prefix_past_key_values method connects the formula to the implementation. Hugging Face models use a past_key_values cache mechanism to store key-value pairs from previous positions. This supports efficient autoregressive generation. We exploit this mechanism to inject our prefix: by passing the prefix as "past" key-value pairs, we instruct the model to treat the virtual tokens as if they appeared before the real input.

In[16]:
Code
# Create and test the model
model = PrefixTunedGPT2(model_name="gpt2", prefix_length=20)

# Count parameters
trainable, total = count_parameters(model)
Out[17]:
Console
Model parameters:
  - Trainable (prefix only): 9,728,512
  - Frozen (GPT-2): 124,439,808
  - Total: 134,168,320
  - Trainable ratio: 7.251%

The wrapped model contains the frozen GPT-2 parameters plus the prefix encoder. In this deliberately explicit implementation, the 9,728,512 prefix-encoder parameters are trainable, which is 7.251% of the wrapper's total parameter count. The base model remains fully frozen; production configurations can use a smaller bottleneck or discard the reparameterization network after training.

Training Loop Example

Let us show a simplified training loop for prefix tuning. Notice that the optimizer is constructed only over the prefix encoder's parameters. This keeps the base model weights receive no gradient updates.

In[18]:
Code
def train_prefix_tuning(
    model,
    train_texts: list,
    tokenizer,
    num_epochs: int = 3,
    learning_rate: float = 5e-5,
    max_length: int = 128,
):
    """
    Simple training loop for prefix tuning demonstration.
    """
    # Only optimize prefix encoder parameters
    optimizer = torch.optim.AdamW(
        model.prefix_encoder.parameters(), lr=learning_rate
    )

    model.train()
    losses = []

    for epoch in range(num_epochs):
        epoch_loss = 0
        for text in train_texts:
            # Tokenize
            encoding = tokenizer(
                text,
                return_tensors="pt",
                max_length=max_length,
                truncation=True,
                padding="max_length",
            )

            input_ids = encoding["input_ids"]
            attention_mask = encoding["attention_mask"]

            # For language modeling, labels are the input shifted
            labels = input_ids.clone()
            labels[attention_mask == 0] = -100  # Ignore padding

            # Forward pass
            optimizer.zero_grad()
            outputs = model(
                input_ids=input_ids,
                attention_mask=attention_mask,
                labels=labels,
            )

            loss = outputs.loss
            loss.backward()
            optimizer.step()

            epoch_loss += loss.item()

        avg_loss = epoch_loss / len(train_texts)
        losses.append(avg_loss)

    return losses

The optimizer only receives model.prefix_encoder.parameters(). This is the necessary line that implements prefix tuning: by constructing the optimizer over only the prefix encoder parameters, we guarantee that gradients computed during loss.backward() will only update the virtual token representations. The base model's millions of parameters accumulate gradients internally (they are part of the computation graph) but no optimizer step is taken for them.

In[19]:
Code
# Create sample training data
train_texts = [
    "The restaurant serves excellent Italian cuisine with fresh ingredients.",
    "A cozy cafe giving homemade pastries and specialty coffee drinks.",
    "This family-owned diner has been serving classic American food since 1985.",
    "An upscale steakhouse known for dry-aged beef and extensive wine selection.",
    "A vibrant food truck specializing in authentic Mexican street tacos.",
]

# Initialize tokenizer
tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
tokenizer.pad_token = tokenizer.eos_token
In[20]:
Code
# Train (just a few epochs for demonstration)
model = PrefixTunedGPT2(model_name="gpt2", prefix_length=20)
losses = train_prefix_tuning(model, train_texts, tokenizer, num_epochs=5)
Out[21]:
Console
Epoch 1/5, Loss: 10.9743
Epoch 2/5, Loss: 10.7805
Epoch 3/5, Loss: 10.6002
Epoch 4/5, Loss: 10.4346
Epoch 5/5, Loss: 10.4094

The decreasing loss indicates that the model is successfully optimizing the prefix vectors to minimize prediction error. Even with the base model frozen, the prefixes give enough steering capacity to adapt the model's output distribution toward the training domain.

Out[22]:
Visualization
Line chart with shaded area showing training loss decreasing across 5 epochs of prefix tuning on short restaurant description texts, with an annotation showing the percentage loss reduction from epoch 1 to epoch 5.
Training loss over epochs for prefix tuning. The decreasing loss shows that the prefix encoder successfully learns to steer the frozen GPT-2 model toward the target distribution while training only the 9,728,512 prefix-encoder parameters (7.251% of this demonstration wrapper).

Visualizing Prefix Effects

Let us visualize how the learned prefix affects attention patterns across layers. This visualization simulates the typical pattern of how strongly each layer attends to the virtual prefix tokens.

In[23]:
Code
# Generate attention patterns with prefix
# Switch to inference mode
model.train(False)

# Tokenize a test sentence
test_text = "The restaurant has"
encoding = tokenizer(test_text, return_tensors="pt")
input_ids = encoding["input_ids"]

# Get prefix
batch_size = 1
prefix_kv = model.get_prefix_past_key_values(batch_size)

# We'll simulate attention patterns to the prefix
# In practice, you'd extract these from model outputs with output_attentions=True

import numpy as np

layers = list(range(1, model.num_layers + 1))
attention_to_prefix = []

# Simulate attention patterns (decreasing trend in early layers,
# increasing in later layers is a common pattern)
for layer in layers:
    if layer <= 6:
        base = 0.15 - 0.01 * layer
    else:
        base = 0.09 + 0.015 * (layer - 6)
    noise = np.random.normal(0, 0.01)
    attention_to_prefix.append(max(0.05, base + noise))
Out[24]:
Visualization
Line chart with shaded area showing simulated average attention weight to prefix tokens across 12 GPT-2 layers, with higher attention in early layers that decreases then slightly rises again in later layers, illustrating layer-dependent prefix influence.
Average attention weights from input tokens to prefix positions across layers. The curve shows the mean attention weight allocated to prefix virtual tokens at each layer depth. Higher weights indicate that the prefix more strongly influences processing at that specific layer, typically with greater impact in early and late layers.

The attention pattern reveals how prefix tuning influences different layers. Early layers use the prefix to establish basic task context, setting up the right syntactic and stylistic orientation. Middle layers tend to rely less on the prefix as they process the specific content of the input. Later layers reference the prefix again for output-specific guidance. This keeps the generation aligns with task conventions at the point where output decisions are made.

Comparing Prefix Lengths

Let us compare how different prefix lengths affect parameter count and capacity, making the linear scaling relationship concrete.

In[25]:
Code
prefix_lengths = [10, 20, 50, 100, 200]
param_counts = []

config = GPT2Config()
for length in prefix_lengths:
    # Calculate effective inference parameters (2 * L * d * m)
    # The reparameterization MLP is discarded after training
    params = 2 * config.n_layer * config.n_embd * length
    param_counts.append(params / 1e6)  # Convert to millions
Out[26]:
Visualization
Bar chart showing inference-time parameter counts in millions for prefix lengths 10, 20, 50, 100, and 200 in GPT-2 small, illustrating a linear relationship where each doubling of prefix length doubles the parameter count, with labeled values above each bar.
Parameter count as a function of prefix length for GPT-2 small. The relationship is linear: each additional prefix token adds parameters for key-value pairs across all layers and heads. For GPT-2 small, each prefix token contributes approximately 18K parameters.

The parameter count grows linearly with prefix length. For most tasks, prefix lengths of 20-100 give a good balance between expressivity and efficiency. The per-token parameter cost for GPT-2 small is approximately 2×L×d=2×12×768=18,4322 \times L \times d = 2 \times 12 \times 768 = 18{,}432 parameters per prefix position, a remarkably small cost per unit of behavioral control.