A Python toolkit for extracting activations, computing steering vectors, and analyzing effective layers for modifying LLM refusal behavior.
This project implements the methodology from the paper "Refusal Steering: Fine-grained Control over LLM Refusal Behaviour for Sensitive Topics".
Get started in 5 steps:
# Clone repository
git clone --recurse-submodules https://github.com/ElSnacko/llm-steering
cd llm-steering
# If already cloned without submodules, initialize them
git submodule update --init --recursive
# Install dependencies
pip install -e .
pip install uvcd LLM-Refusal-Evaluation
uv run python -m src.compute_refusal_score --config configs/your_model.yaml
cd ..python scripts/extract_activations.py \
--model Qwen/Qwen2.5-7B-Instruct \
--results-dir LLM-Refusal-Evaluation/results/baseline \
--refusal-threshold 0.1 \
--compliance-threshold -0.1# Compute WRMD steering vectors
python scripts/compute_wrmd.py \
--activations outputs/.../activations_*.pt \
--method wrmd \
--lambda-ridge 0.1
# Identify effective layers
python scripts/find_best_layers.py \
--activations outputs/.../activations_*.pt \
--steering-vectors outputs/.../steering_vectors_wrmd.pt \
--top-k 5# Find optimal alpha automatically (recommended)
python scripts/optimize_alpha.py \
--model Qwen/Qwen2.5-7B-Instruct \
--steering-vectors outputs/.../steering_vectors_wrmd.pt \
--correlations outputs/.../layer_correlations.json \
--baseline-results LLM-Refusal-Evaluation/results/baseline \
--top-k 3 \
--num-prompts 100
# Test interactively with optimal alpha
python scripts/test_steering.py \
--model Qwen/Qwen2.5-7B-Instruct \
--steering-vectors outputs/.../steering_vectors_wrmd.pt \
--correlations outputs/.../layer_correlations.json \
--top-k 3 \
--alpha -2.5 # Use optimal alpha from previous stepThat's it! You now have a steered model. For permanent steering, see Merging Into Weights)
- Python ≥ 3.9
- PyTorch ≥ 2.0.0 with CUDA support
- Transformers ≥ 4.30.0
- 16GB+ GPU memory (for 7B models)
# Clone with submodules
git clone --recurse-submodules https://github.com/ELSnacko/activation-steering.git
cd activation-steeringThis project depends on LLM-Refusal-Evaluation for generating judge scores.
If you already cloned without --recurse-submodules:
git submodule update --init --recursiveAlternatively, clone separately:
git clone https://github.com/CompactifAI/LLM-Refusal-Evaluation.gitUsing pip:
pip install -r requirements.txt
pip install uvOr install as editable package:
pip install -e .
pip install uvThe codebase is organized as a Python package:
activation_steering/
├── src/activation_steering/ # Main package
│ ├── extraction.py # ActivationExtractor class and helpers
│ ├── computation.py # WRMDCalculator and computation methods
│ ├── analysis.py # Layer correlation analysis
│ ├── steering.py # SteeringHook for runtime steering
│ └── utils.py # Output utilities
├── scripts/ # CLI entry points
│ ├── extract_activations.py # Step 1: Extract activations
│ ├── compute_wrmd.py # Step 2: Compute steering vectors
│ ├── find_best_layers.py # Step 3: Find effective layers
│ ├── optimize_alpha.py # Step 4: Find optimal alpha (recommended)
│ ├── test_steering.py # Step 5: Test steering interactively
│ ├── merge_steering.py # Optional: Merge into weights
│ └── export_to_gguf.py # Optional: Export to GGUF format
├── docs/ # Additional documentation
├── LLM-Refusal-Evaluation/ # External submodule (judge scores)
└── outputs/ # Results directory (auto-created)
All scripts use a consistent output hierarchy:
outputs/
└── {model_name}/ # Auto-extracted (e.g., qwen2.5-7b-instruct)
└── {run_id}/ # Auto-generated timestamp (YYYYMMDD-HHMMSS)
├── extract_activations/
│ └── activations_*.pt
├── compute_wrmd/
│ ├── steering_vectors_*.pt
│ ├── steering_vector_norms.png
│ └── method_comparison.png
├── find_best_layers/
│ ├── layer_correlations.json
│ ├── layer_correlations.png
│ └── layer_*_projection_scatter.png
└── optimize_alpha/
├── optimization_summary.json
├── alpha_optimization.png
└── alpha_*/responses.json
Tip: Use --output-dir and --run-id to override defaults.
The codebase follows a sequential pipeline:
1. extract_activations.py → activations_*.pt
2. compute_wrmd.py → steering_vectors_*.pt
3. find_best_layers.py → layer_correlations.json
4. optimize_alpha.py → optimal alpha value (recommended)
5. test_steering.py → interactive testing
6. merge_steering.py → permanent model (optional)
Each stage consumes outputs from previous stages and produces inputs for the next.
Extract internal activations labeled by judge scores:
python scripts/extract_activations.py \
--model Qwen/Qwen2.5-7B-Instruct \
--results-dir LLM-Refusal-Evaluation/results/qwen3_8b_baseline \
--output activations_qwen_8b_judged.pt \
--refusal-threshold 0.1 \
--compliance-threshold -0.1What it does:
- Loads judge scores from LLM-Refusal-Evaluation results
- Extracts model activations at the last token position for all layers
- Filters samples: only includes clear refusal (score > 0.1) or compliance (score < -0.1)
- Saves metadata with judge scores for downstream use
Arguments:
--model: HuggingFace model name or path--results-dir: Path to LLM-Refusal-Evaluation baseline results--refusal-threshold: Minimum score to label as refusal (default: 0.1)--compliance-threshold: Maximum score to label as compliance (default: -0.1)--output-dir: Custom output directory (optional)--run-id: Custom run ID (optional)--max-samples: Limit samples for testing (optional)
Compute steering vectors using weighted methods:
# Single method (WRMD recommended)
python scripts/compute_wrmd.py \
--activations outputs/qwen2.5-7b-instruct/20231227-035148/extract_activations/activations_qwen_8b_judged.pt \
--output steering_vectors_wrmd.pt \
--method wrmd \
--lambda-ridge 0.1
# Compare all methods (MD, RMD, WRMD)
python scripts/compute_wrmd.py \
--activations outputs/.../activations_qwen_8b_judged.pt \
--output steering_vectors_wrmd.pt \
--compare-methodsMethods:
- MD (Mean Difference): Simple difference of means
- RMD (Ridge Mean Difference): Adds ridge regularization
- WRMD (Weighted Ridge Mean Difference): Weights samples by judge confidence scores (recommended)
Arguments:
--activations: Path to activations .pt file--method: Choose 'md', 'rmd', or 'wrmd' (default: wrmd)--lambda-ridge: Ridge regularization parameter (default: 0.1)--normalize: Normalize vectors to unit length--no-score-weighting: Disable judge score weighting (uniform weights)--compare-methods: Generate comparison plots for all methods--output-dir: Custom output directory (optional)--run-id: Custom run ID (optional)
Identify which layers are most effective:
python scripts/find_best_layers.py \
--activations outputs/qwen2.5-7b-instruct/20231227-035148/extract_activations/activations_qwen_8b_judged.pt \
--steering-vectors outputs/qwen2.5-7b-instruct/20231227-035148/compute_wrmd/steering_vectors_wrmd.pt \
--top-k 5What it does:
- Computes Pearson correlation between activation projections and judge scores for each layer
- Higher absolute correlation = better steering effectiveness
- Generates visualizations showing layer effectiveness
Arguments:
--activations: Path to activations .pt file--steering-vectors: Path to steering vectors .pt file--top-k: Number of best layers to identify (default: 5)--output-dir: Custom output directory (optional)--run-id: Custom run ID (optional)
Apply steering vectors at runtime and compare outputs:
# Test top-3 layers individually
python scripts/test_steering.py \
--model Qwen/Qwen2.5-7B-Instruct \
--steering-vectors steering_vectors_wrmd.pt \
--correlations layer_correlations.json \
--top-k 3 \
--alpha -2.0 \
--num-prompts 3
# Test specific layers together
python scripts/test_steering.py \
--model Qwen/Qwen2.5-7B-Instruct \
--steering-vectors steering_vectors_wrmd.pt \
--layers 9 10 11 \
--alpha -2.0Alpha parameter controls steering strength:
- Negative alpha (e.g., -2.0): Reduce refusal behavior
- Positive alpha (e.g., +2.0): Increase refusal behavior
Arguments:
--model: HuggingFace model name or path--steering-vectors: Path to steering vectors .pt file--correlations: Path to layer correlations JSON (for --top-k)--layers: Specific layers to test (alternative to --top-k)--top-k: Use top K layers from correlations--alpha: Steering strength (negative=reduce refusal)--num-prompts: Number of prompts to test (default: 5)
Automatically find the optimal alpha value using judge scores with early stopping:
# Recommended: automatic optimization with early stopping
python scripts/optimize_alpha.py \
--model Qwen/Qwen2.5-7B-Instruct \
--steering-vectors outputs/.../steering_vectors_wrmd.pt \
--correlations outputs/.../layer_correlations.json \
--baseline-results LLM-Refusal-Evaluation/results/qwen3_8b_baseline \
--top-k 3 \
--alpha-start -2.0 \
--alpha-min -5.0 \
--alpha-max 0.0 \
--alpha-step 0.5 \
--num-prompts 100 \
--stopping-metric mean_score \
--stopping-tolerance 0.1 \
--judge-model openai/gpt-oss-20b
# Fine-grained search around specific alpha
python scripts/optimize_alpha.py \
--model Qwen/Qwen2.5-7B-Instruct \
--steering-vectors steering_vectors_wrmd.pt \
--baseline-results LLM-Refusal-Evaluation/results/qwen3_8b_baseline \
--layers 10 11 12 \
--alpha-start -2.0 \
--alpha-min -2.5 \
--alpha-max -1.5 \
--alpha-step 0.1 \
--num-prompts 200Key Features:
- Loads baseline (alpha=0) metrics from existing evaluation results (no re-run needed)
- Tests alphas starting from
--alpha-start(default: -2.0), sweeping outward - Early stopping when performance drops below best result + tolerance
- Tracks best performing alpha during sweep and compares against it
- Can continue testing alphas closer to zero even if far alphas fail
- Generates visualizations and summary with optimal alpha recommendation
Early Stopping Behavior:
- Baseline loaded from
--baseline-results(no inference needed) - Tests alphas in order: -2.0 → -2.5 → -3.0 → ... (away from zero)
- Tracks the best performing alpha throughout the sweep
- Stops if performance degrades beyond tolerance from EITHER baseline OR best prior result
- Example: If alpha=-2.5 achieves mean_score=-0.5 (best so far), stops at alpha=-3.0 if it scores worse than -0.5 + tolerance
- If far alphas fail, continues testing -1.5 → -1.0 → -0.5 (toward zero)
- Skips alpha=0.0 entirely (already have baseline metrics)
Outputs:
optimization_summary.json: Full results with optimal alpha and best alpha during sweepalpha_optimization.png: 4-panel visualization (scores, rates, distribution, uncertainty)alpha_*/responses.json: Generated responses for each alpha tested
The summary includes both the optimal alpha (based on objective function) and the best alpha found during the sweep (for early stopping reference).
Arguments:
--model: HuggingFace model name or path--steering-vectors: Path to steering vectors .pt file--baseline-results: Path to baseline evaluation results--correlations: Path to layer correlations JSON (for --top-k)--layers: Specific layers to test (alternative to --top-k)--top-k: Use top K layers from correlations--alpha-start: Starting alpha value (default: -2.0)--alpha-min: Minimum alpha to test (default: -5.0)--alpha-max: Maximum alpha to test (default: 0.0)--alpha-step: Alpha step size (default: 0.5)--num-prompts: Number of test prompts (default: 50)--stopping-metric: Metric for early stopping (default: mean_score)--stopping-tolerance: Tolerance before stopping (default: 0.1)--objective: Optimization objective (default: minimize_refusal)--judge-model: Judge model for scoring (default: openai/gpt-oss-20b)--output-dir: Custom output directory (optional)--run-id: Custom run ID (optional)
Permanently merge steering vectors into model weights to create a standalone steered model:
# Merge using best layers from correlation analysis
python scripts/merge_steering.py \
--model Qwen/Qwen2.5-7B-Instruct \
--steering-vectors steering_vectors_wrmd.pt \
--correlations layer_correlations.json \
--top-k 3 \
--alpha -2.0 \
--output-dir Qwen2.5-7B-Steered
# Merge and export to GGUF format in one step
python scripts/merge_steering.py \
--model Qwen/Qwen2.5-7B-Instruct \
--steering-vectors steering_vectors_wrmd.pt \
--correlations layer_correlations.json \
--top-k 3 \
--alpha -2.0 \
--output-dir Qwen2.5-7B-Steered \
--export-gguf \
--gguf-quantization q4_0
# Verify the merged model
python scripts/merge_steering.py \
--verify \
--merged-model Qwen2.5-7B-Steered \
--original-model Qwen/Qwen2.5-7B-Instruct \
--layers 10 11 12What it does:
- Modifies the model's MLP bias terms to include steering vectors
- Creates a standalone model with built-in steering behavior
- No runtime hooks required for the merged model
- Optionally exports to GGUF format for llama.cpp
WARNING: This permanently modifies the model weights!
Arguments:
--model: HuggingFace model name or path--steering-vectors: Path to steering vectors .pt file--correlations: Path to layer correlations JSON (for --top-k)--layers: Specific layers to merge (alternative to --top-k)--top-k: Merge top K layers from correlations--alpha: Steering strength to merge--output-dir: Directory to save merged model--export-gguf: Also export to GGUF format--gguf-quantization: Quantization type (f16, q4_0, q8_0, etc.)--verify: Verify merged model against original--merged-model: Path to merged model (for verification)--original-model: Path to original model (for verification)
Export any HuggingFace model (including merged models) to GGUF format for llama.cpp:
# Export with default FP16 quantization
python scripts/export_to_gguf.py \
--model-dir Qwen2.5-7B-Steered \
--quantization f16
# Export with 4-bit quantization for smaller size
python scripts/export_to_gguf.py \
--model-dir Qwen2.5-7B-Steered \
--quantization q4_0 \
--output model-q4_0.ggufQuantization Options:
f32: Full 32-bit precision (largest, highest quality)f16: Half precision (recommended default)q8_0: 8-bit quantization (good quality, smaller)q5_0/q5_1: 5-bit quantization (balanced)q4_0/q4_1: 4-bit quantization (smallest, lowest quality)
Requirements:
- llama.cpp with
convert-hf-to-gguf.pyscript available in PATH or common locations - Or install:
pip install llama-cpp-python
Arguments:
--model-dir: Directory containing HuggingFace model--output: Output path for GGUF file (optional)--quantization: Quantization type (default: f16)--verbose: Print detailed conversion output
Generate baseline judge scores needed for activation extraction:
cd LLM-Refusal-Evaluation
uv run python -m src.compute_refusal_score --config configs/Qwen3-4B-Instruct-2507.yamlSee LLM-Refusal-Evaluation/README.md for configuration details.
The project uses judge scores from LLM-Refusal-Evaluation to train steering vectors:
- LLM-Refusal-Evaluation/ generates judge scores for model responses
- extract_activations.py uses judge scores to label activations as refusal/compliant
- compute_wrmd.py computes steering vectors weighted by judge confidence scores
- find_best_layers.py correlates activation projections with judge scores to identify effective steering layers
- optimize_alpha.py automatically finds optimal alpha using judge scores with early stopping
- test_steering.py applies steering vectors at runtime via forward hooks with optimal alpha
Simple difference of refusal and compliant activation means:
v = mean(activations_refusal) - mean(activations_compliant)
Adds ridge regularization to stabilize computation:
v = (C + λI)^(-1) * (mean_refusal - mean_compliant)
Weights samples by judge confidence scores and accounts for covariance structure:
v = (C_weighted + λI)^(-1) * (weighted_mean_refusal - weighted_mean_compliant)
WRMD is the recommended method as it leverages judge confidence scores to weight examples by certainty.
Judge scores range from -1 to 1:
- score > 0.1: Model refused (higher = stronger refusal)
- score < -0.1: Model complied (more negative = stronger compliance)
- -0.1 ≤ score ≤ 0.1: Uncertain/mixed behavior
Scores are used to:
- Filter samples during activation extraction (only clear behavior)
- Weight samples during WRMD computation (higher confidence = higher weight)
- Evaluate steering during alpha optimization (measure effectiveness)
- Extracts activations at the last token position for all layers
- Uses judge scores from LLM-Refusal-Evaluation metadata to filter samples
- Only includes samples with clear behavior (score > refusal_threshold OR score < compliance_threshold)
- Skips uncertain samples (scores near zero)
- Saves metadata alongside activations for downstream use
- Converts judge scores to weights: higher magnitude = higher confidence = higher weight
- For refusals (label=1): weight = score (already positive)
- For compliances (label=0): weight = -score (convert negative to positive)
- Computes weighted covariance matrix from compliant distribution
- Uses float32 for matrix inversion, then casts back to original dtype (bfloat16)
- Ridge regularization (lambda) stabilizes inversion of covariance matrix
- Normalizes steering vectors to unit length before projection
- Computes projection = activations @ steering_vector for each layer
- Correlates projections with judge scores using Pearson correlation
- High positive/negative correlation = layer is effective for steering
- Top layers by absolute correlation are recommended for steering
- Uses PyTorch forward hooks to modify activations at runtime
- Hook modifies hidden states:
h' = h + alpha * steering_vector - Can apply to single layer or multiple layers simultaneously
- Loads "actual refusal prompts" from baseline evaluation results
- Compares baseline vs steered outputs side-by-side
{
'activations': Tensor[N, num_layers, hidden_size],
'labels': Tensor[N], # 0=compliant, 1=refusal
'prompts': List[str],
'num_layers': int,
'hidden_size': int,
'metadata': List[dict] # Contains judge scores and split info
}{
'steering_vectors': Tensor[num_layers, hidden_size],
'num_layers': int,
'hidden_size': int,
'method': str, # 'md', 'rmd', or 'wrmd'
'lambda_ridge': float,
'use_score_weighting': bool,
'num_refusal_samples': int,
'num_compliant_samples': int
}{
'best_layers': [int, ...], # Top K layers by absolute correlation
'all_correlations': [
{
'layer': int,
'correlation': float,
'abs_correlation': float,
'p_value': float,
'projection_mean': float,
'projection_std': float
},
...
]
}All scripts use this pattern for efficient loading:
model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.bfloat16, # Reduces memory usage
device_map="auto" # Automatic multi-GPU distribution
)extract_model_name(model_string): Converts "Qwen/Qwen2.5-7B-Instruct" → "qwen2.5-7b-instruct"generate_run_id(): Creates timestamp-based ID (YYYYMMDD-HHMMSS)setup_model_run_dirs(model_name, run_id): Creates full output directory structureensure_dir(path): Creates directory if it doesn't exist
All scripts use these utilities for consistent output organization.
- Evaluate baseline model using LLM-Refusal-Evaluation to generate judge scores
- Extract activations from the baseline model, filtering by judge scores
- Compute steering vectors using WRMD with judge score weighting
- Find best layers by correlating projections with judge scores
- Optimize alpha parameter using automated search with early stopping (recommended)
- Test steering interactively with optimal alpha on actual refusal prompts
This workflow ensures that steering is trained on actual model behavior (not dataset assumptions), alpha is optimized using judge scores, and the final configuration is tested on prompts that the model actually refused in baseline evaluation.
The package can be imported directly in Python code:
from activation_steering import (
ActivationExtractor,
WRMDCalculator,
SteeringHook,
load_prompts_from_judge_scores
)
# Extract activations
extractor = ActivationExtractor("Qwen/Qwen2.5-7B-Instruct")
prompts, labels, metadata = load_prompts_from_judge_scores("results/baseline")
extractor.extract_dataset(prompts, labels, "activations.pt", metadata)
# Compute steering vectors
calculator = WRMDCalculator("activations.pt")
vectors = calculator.compute_steering_vectors(method='wrmd', use_score_weighting=True)
calculator.save_vectors(vectors, "steering_vectors.pt")
# Apply steering at runtime
import torch
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct", ...)
steerer = SteeringHook(model, vectors, target_layers=[10, 11], alpha=-2.0)
steerer.register_hooks()
# ... run generation ...
steerer.remove_hooks()from activation_steering import merge_steering_into_model
# Merge steering permanently into model weights
metadata = merge_steering_into_model(
base_model_path="Qwen/Qwen2.5-7B-Instruct",
steering_vectors_file="steering_vectors.pt",
target_layers=[10, 11],
alpha=-2.0,
output_dir="Qwen2.5-7B-Steered"
)
# Load and use the merged model
from transformers import AutoModelForCausalLM
steered_model = AutoModelForCausalLM.from_pretrained("Qwen2.5-7B-Steered")
# No hooks needed - steering is built-in!- PyTorch ≥ 2.0.0 (with bfloat16 support)
- Transformers ≥ 4.30.0 (HuggingFace models)
- NumPy ≥ 1.24.0 (numerical operations)
- SciPy ≥ 1.10.0 (statistical computations)
- Matplotlib ≥ 3.7.0 (visualization)
- tqdm ≥ 4.65.0 (progress bars)
- vLLM (for LLM-Refusal-Evaluation batch inference)
- See
requirements.txtfor full list
This toolkit implements the methodology from:
@misc{garciaferrero2025Refusal,
title={Refusal Steering: Fine-grained Control over LLM Refusal Behaviour for Sensitive Topics},
author={Iker García-Ferrero and David Montero and Roman Orus},
year={2025},
eprint={2512.16602},
archivePrefix={arXiv},
primaryClass={cs.CL},
url={https://arxiv.org/abs/2512.16602},
}If you use this implementation in your research, please cite both the original paper above and this repository.
MIT License
- LLM-Refusal-Evaluation: Inference-time evaluation framework for measuring refusal behavior
- Refusal Steering Paper: Original research paper
GPU Memory Errors:
- Use smaller batch sizes or models
- Enable
device_map="auto"for multi-GPU distribution - Use
torch_dtype=torch.bfloat16for memory efficiency
Judge Score Files Not Found:
- Ensure LLM-Refusal-Evaluation ran successfully
- Check that
--results-dirpoints to the correct baseline results - Verify
judge_scores.jsonoraggregated_results.jsonexists
Import Errors:
- Install package in editable mode:
pip install -e . - Ensure all dependencies are installed:
pip install -r requirements.txt
Correlation Analysis Shows Weak Correlations:
- Try different steering vector methods (MD vs RMD vs WRMD)
- Adjust ridge regularization parameter
--lambda-ridge - Ensure sufficient samples with clear refusal/compliance behavior
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Make your changes
- Submit a pull request
For questions or issues:
- Open an issue on GitHub
- See the paper for research details