Activation Steering: Steering Vectors and Representation Engineering › Code Implementation

Michael BrenndoerferJanuary 29, 20268 min read
Language AI HandbookAll chapters
Part 59 of 67Interpretability
Chapter 456Activation Steering: Steering Vectors and Representation Engineering › Code Implementation
Reading section 7 of 13 · Code Implementation · 8 minSection 7 of 13 · 8 min

Book contents

Section 7 of 13 · 8 min read

Activation Steering: Steering Vectors and Representation Engineering › Code ImplementationOne section · Sections in this chapter7 / 13 ↓
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 activation steering from scratch using a small transformer model via Hugging Face's Transformers library. We will build a steering vector for a simple concept, diagnose its geometry, and demonstrate applying it during generation.

We begin with library imports and model setup.

In[3]:
Code
from transformers import AutoModelForCausalLM, AutoTokenizer

# Load a small model for demonstration
model_name = "gpt2"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
    model_name, output_hidden_states=True
)
model.eval()

# Set padding token
tokenizer.pad_token = tokenizer.eos_token

n_layers = model.config.n_layer
hidden_dim = model.config.n_embd
Out[4]:
Console
Model: gpt2
Layers: 12
Hidden dim: 768

GPT-2 has 12 transformer layers and a hidden dimension of 768. We will target layer 8, approximately two-thirds through the network, for our steering vector. Middle-to-late layers tend to hold the richest semantic representations because they have processed enough context to form abstract concepts but still have downstream layers that can act on the modified representation.

Extracting Activations

We write a helper function to extract residual stream activations at a specified layer and token position.

In[5]:
Code
import numpy as np
import torch


def get_activations(prompts, layer_idx, token_position=-1):
    """
    Extract residual stream activations at a specific layer and token position.

    Args:
        prompts: list of text strings
        layer_idx: which transformer layer to extract from (0-indexed)
        token_position: which token position to use (-1 = last token)

    Returns:
        activations: numpy array of shape (n_prompts, hidden_dim)
    """
    activations = []

    for prompt in prompts:
        inputs = tokenizer(
            prompt, return_tensors="pt", padding=True, truncation=True
        )

        with torch.no_grad():
            outputs = model(**inputs)

        # hidden_states is a tuple: (embedding_output, layer_1_output, ..., layer_N_output)
        # Index layer_idx + 1 because index 0 is the embedding layer
        hidden_states = outputs.hidden_states[layer_idx + 1]

        # Extract the activation at the specified token position
        # hidden_states shape: (batch, seq_len, hidden_dim)
        act = hidden_states[0, token_position, :].cpu().numpy()
        activations.append(act)

    return np.array(activations)

The function runs the model in inference mode with no gradient computation and retrieves the hidden states tensor from the output. Because the output_hidden_states=True flag was set during model loading, the model returns a tuple of activation tensors: one for the embedding layer and one for each transformer block. We index layer_idx + 1 to skip past the embedding layer output.

Notice that we extract from token_position=-1, the last token. In a causal language model, the last token has attended to all prior tokens through causal self-attention, so its residual stream carries a compressed representation of the entire context up to that point. This is the richest single position from which to extract a concept-level signal.

Building the Steering Vector

With activation extraction in place, we construct a steering vector using contrasting prompts.

In[6]:
Code
# Define contrasting prompt sets for a "confident" vs "uncertain" concept
positive_prompts = [
    "I am absolutely certain that",
    "Without any doubt, the answer is",
    "I know for a fact that",
    "It is definitively the case that",
    "The clear and undeniable truth is that",
]

negative_prompts = [
    "I'm not really sure, but maybe",
    "I think it might possibly be",
    "I'm uncertain, but perhaps",
    "It could be, I suppose, that",
    "I'm not confident, but it seems like",
]

TARGET_LAYER = 8  # Middle-late layer in GPT-2

# Extract activations for both sets
pos_activations = get_activations(positive_prompts, layer_idx=TARGET_LAYER)
neg_activations = get_activations(negative_prompts, layer_idx=TARGET_LAYER)

# Compute steering vector as mean difference
steering_vector = pos_activations.mean(axis=0) - neg_activations.mean(axis=0)

# Normalize to unit length
steering_vector_norm = steering_vector / np.linalg.norm(steering_vector)
Out[7]:
Console
Positive activations shape: (5, 768)
Negative activations shape: (5, 768)
Steering vector shape: (768,)
Vector norm (unnormalized): 58.6258
Vector norm (normalized): 1.000000

The steering vector has shape (768,), one value per hidden dimension. The raw (unnormalized) norm reflects the distance in activation space between the average "confident" activation and the average "uncertain" activation. Normalization ensures that the coefficient α\alpha controls the intervention strength in a consistent unit, regardless of the raw vector magnitude, so that the same value of α\alpha means the same geometric displacement for every concept vector.

Applying the Steering Vector

To apply the steering vector during a forward pass, we hook into the model and modify activations at the target layer. We compare next-token probabilities with and without the intervention to verify the steering effect quickly, without the computational cost of full text generation.

In[8]:
Code
import torch.nn.functional as F


def get_next_token_probs(prompt, steering_vec, alpha, layer_idx=8, top_k=5):
    """
    Run a single forward pass and return the top-k next-token probabilities.
    With alpha=0 this is the baseline; non-zero alpha applies the steering vector.
    """
    inputs = tokenizer(prompt, return_tensors="pt")
    steering_tensor = torch.tensor(steering_vec, dtype=torch.float32)

    def hook(module, input, output):
        if isinstance(output, tuple):
            hidden = output[0]
            modified = hidden + alpha * steering_tensor.unsqueeze(0).unsqueeze(
                0
            )
            return (modified,) + output[1:]
        return output + alpha * steering_tensor.unsqueeze(0).unsqueeze(0)

    target_block = model.transformer.h[layer_idx]
    hook_handle = target_block.register_forward_hook(hook)

    try:
        with torch.no_grad():
            outputs = model(**inputs)
    finally:
        hook_handle.remove()

    # logits shape: (batch=1, seq_len, vocab_size)
    # Take the last token's logits to predict the next token
    logits = outputs.logits[0, -1, :]
    probs = F.softmax(logits, dim=-1)
    top_probs, top_ids = torch.topk(probs, top_k)

    top_tokens = [tokenizer.decode([tid.item()]) for tid in top_ids]
    top_prob_values = top_probs.cpu().numpy().tolist()
    return list(zip(top_tokens, top_prob_values))


# Compare next-token distributions under baseline and steering
test_prompt = "I am absolutely"

baseline_top5 = get_next_token_probs(
    test_prompt, steering_vector_norm, alpha=0.0
)
confident_top5 = get_next_token_probs(
    test_prompt, steering_vector_norm, alpha=20.0
)
uncertain_top5 = get_next_token_probs(
    test_prompt, steering_vector_norm, alpha=-20.0
)

The hook function intercepts the output of the target transformer block and adds the scaled steering vector to every token position. We use try/finally to guarantee the hook is removed even if an error occurs, preventing any accidental persistent modification to the model.

Out[9]:
Console
=== Next-token probabilities for: 'I am absolutely' ===

Token                  Baseline    Confident    Uncertain
--------------------------------------------------------
  ' convinced'           0.0705       0.0820       0.0507
  ' certain'             0.0696       0.0574       0.0641
  ' sure'                0.0598       0.0395       0.0772
  ' delighted'           0.0571       0.0662       0.0421
  ' thrilled'            0.0473       0.0362       0.0508

The table shows how the steering vector shifts the probability distribution over next tokens. Confident steering (positive alpha) raises the probability of assertive continuations while uncertain steering (negative alpha) raises the probability of hedged ones, confirming that the vector captures a real semantic direction in the model's activation space. This rapid token-probability check is a standard first-pass diagnostic before investing in full generation experiments.

Measuring the Cosine Similarity Between Steering Directions

A useful diagnostic is to check how similar different steering vectors are to each other. If a vector constructed for "confident" is nearly identical to one for "positive tone," the two concepts may be entangled in the model's representation space, meaning steering one will also steer the other.

In[10]:
Code
# Build a second vector for "positive emotional tone" as a comparison
positive_tone_prompts = [
    "This is a wonderful day and I feel great",
    "Everything is going perfectly and I am happy",
    "I love this topic and find it fascinating",
]

negative_tone_prompts = [
    "This is a terrible day and I feel awful",
    "Everything is going wrong and I am miserable",
    "I dislike this topic and find it boring",
]

pos_tone_acts = get_activations(positive_tone_prompts, layer_idx=TARGET_LAYER)
neg_tone_acts = get_activations(negative_tone_prompts, layer_idx=TARGET_LAYER)

tone_vector = pos_tone_acts.mean(axis=0) - neg_tone_acts.mean(axis=0)
tone_vector_norm = tone_vector / np.linalg.norm(tone_vector)

# Cosine similarity between confidence and tone vectors
cosine_sim = float(np.dot(steering_vector_norm, tone_vector_norm))
Out[11]:
Console
Cosine similarity between 'confident' and 'positive tone' vectors: -0.0457
Low similarity: these concepts occupy largely orthogonal directions.

Cosine similarity near zero means the two vectors are pointing in largely unrelated directions, suggesting they capture distinct aspects of the model's representation space. Values close to 1.0 indicate that steering for one concept will also strongly steer for the other. This diagnostic is essential before deploying a steering vector: if your "harmlessness" vector is highly similar to your "passivity" vector, you may be inadvertently making the model passive rather than harmless.

Key Parameters

The key parameters for activation steering are:

  • target_layer: The layer where the steering vector is extracted and applied. Middle-to-late layers (roughly 60-75% through the network) typically work best for semantic concepts.
  • alpha: The scaling coefficient controlling intervention strength. Too small and the effect is negligible; too large and outputs become incoherent.
  • token_position: The token position from which activations are extracted when building the vector. The last token (-1) is the standard choice for causal language models.
  • n_prompts: The number of positive and negative prompts used to estimate the steering direction. More examples reduce noise in the direction estimate.