drrik
Drrik: A Framework for Monosemantic Feature Extraction from Language Models
This framework is inspired by the Towards Monosemanticity paper from
Anthropic, which applies dictionary learning to extract activations as
features from transformer-based large language models and trains sparse
autoencoders to linearize those activations.
Key Components:
- Model loading from HuggingFace Hub (with gated model support)
- Dataset loading and inference pipeline
- MLP activation collection using nnsight
- Sparse Autoencoder training for feature extraction
- SAE-based activation steering to bias model outputs
- Visualization of feature-specific activation vectors
- Optional Weights & Biases integration for experiment tracking
Public API:
ActivationExtractorLoads models and datasets, extracts MLP activations via nnsight.SparseAutoencoderOvercomplete SAE with L1 regularization and dead neuron resampling.FeatureVisualizerGenerates density histograms, training curves, and feature dashboards.SAESteeringSteers language model generation by adding SAE feature directions to MLP activations during inference.ConfigTop-level Pydantic settings model aggregating all sub-configurations.EnvironmentSettingsLoads API keys and environment variables from.env.WandbConfigContext manager for wandb experiment tracking.get_settingsReturns the globalEnvironmentSettingssingleton.
Example Usage:
from drrik import ActivationExtractor, SparseAutoencoder, FeatureVisualizer # Extract activations extractor = ActivationExtractor( model_name="google/gemma-2b", dataset_name="wikitext", dataset_split="train", mlp_layers=[0, 1, 2], num_samples=1000 ) activations = extractor.extract() # Train sparse autoencoder (with optional wandb logging) sae = SparseAutoencoder( activation_dim=activations.shape[-1], hidden_dim=activations.shape[-1] * 8, # 8x expansion l1_coefficient=0.01 ) sae.fit(activations, wandb_enabled=True) # Visualize features (with optional wandb logging) visualizer = FeatureVisualizer(sae, activations) visualizer.plot_feature_density() visualizer.plot_top_activating_examples()
1""" 2Drrik: A Framework for Monosemantic Feature Extraction from Language Models 3 4This framework is inspired by the ``Towards Monosemanticity`` paper from 5Anthropic, which applies dictionary learning to extract activations as 6features from transformer-based large language models and trains sparse 7autoencoders to linearize those activations. 8 9Key Components: 10 - **Model loading** from HuggingFace Hub (with gated model support) 11 - **Dataset loading** and inference pipeline 12 - **MLP activation collection** using nnsight 13 - **Sparse Autoencoder training** for feature extraction 14 - **SAE-based activation steering** to bias model outputs 15 - **Visualization** of feature-specific activation vectors 16 - Optional **Weights & Biases** integration for experiment tracking 17 18Public API: 19 ``ActivationExtractor`` 20 Loads models and datasets, extracts MLP activations via nnsight. 21 ``SparseAutoencoder`` 22 Overcomplete SAE with L1 regularization and dead neuron resampling. 23 ``FeatureVisualizer`` 24 Generates density histograms, training curves, and feature dashboards. 25 ``SAESteering`` 26 Steers language model generation by adding SAE feature directions 27 to MLP activations during inference. 28 ``Config`` 29 Top-level Pydantic settings model aggregating all sub-configurations. 30 ``EnvironmentSettings`` 31 Loads API keys and environment variables from ``.env``. 32 ``WandbConfig`` 33 Context manager for wandb experiment tracking. 34 ``get_settings`` 35 Returns the global ``EnvironmentSettings`` singleton. 36 37Example Usage: 38 ```python 39 from drrik import ActivationExtractor, SparseAutoencoder, FeatureVisualizer 40 41 # Extract activations 42 extractor = ActivationExtractor( 43 model_name="google/gemma-2b", 44 dataset_name="wikitext", 45 dataset_split="train", 46 mlp_layers=[0, 1, 2], 47 num_samples=1000 48 ) 49 activations = extractor.extract() 50 51 # Train sparse autoencoder (with optional wandb logging) 52 sae = SparseAutoencoder( 53 activation_dim=activations.shape[-1], 54 hidden_dim=activations.shape[-1] * 8, # 8x expansion 55 l1_coefficient=0.01 56 ) 57 sae.fit(activations, wandb_enabled=True) 58 59 # Visualize features (with optional wandb logging) 60 visualizer = FeatureVisualizer(sae, activations) 61 visualizer.plot_feature_density() 62 visualizer.plot_top_activating_examples() 63 ``` 64""" 65 66__version__ = "0.1.0" 67 68from drrik.models import ActivationExtractor 69from drrik.autoencoder import SparseAutoencoder 70from drrik.visualization import FeatureVisualizer 71from drrik.steering import SAESteering, SteeringVectors 72from drrik.config import Config 73from drrik.settings import EnvironmentSettings, WandbConfig, get_settings 74 75__all__ = [ 76 "ActivationExtractor", 77 "SparseAutoencoder", 78 "FeatureVisualizer", 79 "SAESteering", 80 "SteeringVectors", 81 "Config", 82 "EnvironmentSettings", 83 "WandbConfig", 84 "get_settings", 85]
47class ActivationExtractor: 48 """ 49 Extract MLP activations from language models using nnsight. 50 51 Handles model and dataset loading from HuggingFace Hub, runs 52 inference with nnsight's tracing context to capture MLP outputs, 53 and provides persistence via ``.npy`` / ``.pkl`` files. 54 55 The HuggingFace auth token is read automatically from the 56 ``HUGGINGFACE_HUB_TOKEN`` environment variable (or ``.env`` file) 57 via :func:`~drrik.settings.get_settings`. 58 59 Attributes: 60 config: The resolved :class:`~drrik.config.ActivationExtractorConfig`. 61 model: The loaded nnsight ``LanguageModel`` (``None`` until 62 :meth:`load_model` is called). 63 tokenizer: The HuggingFace tokenizer (``None`` until 64 :meth:`load_model` is called). 65 dataset: The loaded HuggingFace ``Dataset`` (``None`` until 66 :meth:`load_dataset` is called). 67 68 Example: 69 ```python 70 extractor = ActivationExtractor( 71 model_name="google/gemma-2b", 72 dataset_name="wikitext", 73 dataset_config="wikitext-2-raw-v1", 74 mlp_layers=[0], 75 num_samples=1000, 76 ) 77 activations, metadata = extractor.extract() 78 extractor.save_activations(activations, metadata, "./output") 79 ``` 80 """ 81 82 def __init__(self, config: Optional[ActivationExtractorConfig] = None, **kwargs): 83 """ 84 Initialize the ActivationExtractor. 85 86 Accepts either a pre-built config object or flat keyword 87 arguments that are automatically sorted into 88 :class:`~drrik.config.ModelConfig`, 89 :class:`~drrik.config.DatasetConfig`, and the remaining 90 extraction-level settings. 91 92 Args: 93 config: A fully constructed 94 :class:`~drrik.config.ActivationExtractorConfig`. 95 If ``None``, the config is built from ``**kwargs``. 96 **kwargs: Flat config overrides. Recognised keys are 97 distributed to the appropriate sub-config: 98 99 - *Model*: ``model_name``, ``revision``, 100 ``torch_dtype``, ``device_map``, 101 ``trust_remote_code`` 102 - *Dataset*: ``dataset_name``, ``dataset_config``, 103 ``split``, ``num_samples``, ``text_column``, 104 ``max_length`` 105 - *Extraction*: ``mlp_layers``, ``batch_size``, 106 ``output_dir`` 107 """ 108 if config is None: 109 model_kwargs = { 110 k: v 111 for k, v in kwargs.items() 112 if k 113 in ( 114 "model_name", 115 "revision", 116 "torch_dtype", 117 "device_map", 118 "trust_remote_code", 119 ) 120 } 121 dataset_kwargs = { 122 k: v 123 for k, v in kwargs.items() 124 if k 125 in ( 126 "dataset_name", 127 "dataset_config", 128 "split", 129 "num_samples", 130 "text_column", 131 "max_length", 132 ) 133 } 134 remaining_kwargs = { 135 k: v 136 for k, v in kwargs.items() 137 if k not in model_kwargs and k not in dataset_kwargs 138 } 139 140 model = ModelConfig(**model_kwargs) if model_kwargs else None 141 dataset = DatasetConfig(**dataset_kwargs) if dataset_kwargs else None 142 143 config = ActivationExtractorConfig( 144 model=model, 145 dataset=dataset, 146 **remaining_kwargs, 147 ) 148 149 self.config = config 150 self.model = None 151 self.tokenizer = None 152 self.dataset = None 153 self._activations = [] 154 self._metadata = [] 155 156 def load_model(self) -> LanguageModel: 157 """ 158 Load the model from HuggingFace Hub using nnsight. 159 160 If the model is already loaded, returns the cached instance. 161 The HuggingFace token is read from ``HUGGINGFACE_HUB_TOKEN`` 162 via :func:`~drrik.settings.get_settings`. 163 164 Returns: 165 The loaded nnsight :class:`~nnsight.LanguageModel` wrapper. 166 167 Raises: 168 RuntimeError: If model loading fails. 169 """ 170 if self.model is not None: 171 return self.model 172 173 try: 174 logger.info(f"Loading model: {self.config.model.model_name}") 175 176 # Get HF token from settings 177 settings = get_settings() 178 hf_token = settings.huggingface_hub_token 179 180 if hf_token: 181 logger.info("Using HuggingFace Hub token for authentication") 182 183 # Load tokenizer with token 184 tokenizer_kwargs = { 185 "revision": self.config.model.revision, 186 "trust_remote_code": self.config.model.trust_remote_code, 187 } 188 if hf_token: 189 tokenizer_kwargs["token"] = hf_token 190 191 self.tokenizer = AutoTokenizer.from_pretrained( 192 self.config.model.model_name, **tokenizer_kwargs 193 ) 194 195 if self.tokenizer.pad_token is None: 196 self.tokenizer.pad_token = self.tokenizer.eos_token 197 198 # Determine dtype 199 dtype_map = { 200 "float16": torch.float16, 201 "bfloat16": torch.bfloat16, 202 "float32": torch.float32, 203 } 204 torch_dtype = dtype_map.get(self.config.model.torch_dtype, torch.float16) 205 206 # Load model with nnsight (with token if available) 207 nnsight_kwargs = { 208 "revision": self.config.model.revision, 209 "torch_dtype": torch_dtype, 210 "trust_remote_code": self.config.model.trust_remote_code, 211 "device_map": self.config.model.device_map, 212 } 213 if hf_token: 214 nnsight_kwargs["token"] = hf_token 215 216 self.model = LanguageModel(self.config.model.model_name, **nnsight_kwargs) 217 218 logger.info(f"Model loaded successfully on {self.model.device}") 219 return self.model 220 221 except Exception as e: 222 logger.error(f"Failed to load model: {e}") 223 raise RuntimeError(f"Model loading failed: {e}") from e 224 225 def load_dataset(self) -> Dataset: 226 """ 227 Load the dataset from HuggingFace Hub. 228 229 If the dataset is already loaded, returns the cached instance. 230 The HuggingFace token is forwarded for gated datasets when 231 available. 232 233 Returns: 234 The loaded HuggingFace :class:`~datasets.Dataset`. 235 236 Raises: 237 RuntimeError: If dataset loading fails. 238 """ 239 if self.dataset is not None: 240 return self.dataset 241 242 try: 243 logger.info( 244 f"Loading dataset: {self.config.dataset.dataset_name} " 245 f"({self.config.dataset.split} split)" 246 ) 247 248 # Get HF token from settings (for gated datasets) 249 settings = get_settings() 250 hf_token = settings.huggingface_hub_token 251 252 # Load dataset with token if available 253 load_kwargs = { 254 "path": self.config.dataset.dataset_name, 255 "name": self.config.dataset.dataset_config, 256 "split": self.config.dataset.split, 257 } 258 if hf_token: 259 load_kwargs["token"] = hf_token 260 261 self.dataset = load_dataset(**load_kwargs) 262 263 logger.info(f"Dataset loaded with {len(self.dataset)} examples") 264 return self.dataset 265 266 except Exception as e: 267 logger.error(f"Failed to load dataset: {e}") 268 raise RuntimeError(f"Dataset loading failed: {e}") from e 269 270 def _get_mlp_layer_name(self, layer_idx: int) -> str: 271 """ 272 Get the nnsight module path to an MLP layer. 273 274 Auto-detects the correct path pattern based on the model name 275 in the config. For unknown architectures a warning is logged 276 and the default Gemma/Llama pattern is returned. 277 278 Args: 279 layer_idx: Zero-indexed transformer layer number. 280 281 Returns: 282 A dotted/bracket module path string, e.g. 283 ``"model.layers[3].mlp"``. 284 """ 285 model_name_lower = self.config.model.model_name.lower() 286 287 # Gemma/GPT-style: model.layers.N.mlp 288 if any(name in model_name_lower for name in ["gemma", "gpt-2", "pythia"]): 289 return f"model.layers[{layer_idx}].mlp" 290 291 # Llama-style: model.layers.N.mlp 292 if "llama" in model_name_lower: 293 return f"model.layers[{layer_idx}].mlp" 294 295 # Phi-style: model.layers.N.mlp 296 if "phi" in model_name_lower: 297 return f"model.layers[{layer_idx}].mllp" # Note: mlp can be 'mlp' or 'mlp' 298 299 # BERT-style: bert.encoder.layer.N.output.dense 300 if "bert" in model_name_lower: 301 return f"bert.encoder.layer[{layer_idx}].output" 302 303 # Default: try common patterns 304 common_patterns = [ 305 f"model.layers[{layer_idx}].mlp", 306 f"model.layers[{layer_idx}].ffn", 307 f"transformer.h[{layer_idx}].mlp", 308 f"layers[{layer_idx}].mlp", 309 ] 310 311 logger.warning( 312 f"Unknown model architecture '{self.config.model.model_name}', " 313 f"trying common patterns" 314 ) 315 return common_patterns[0] 316 317 def _resolve_layer_path(self, path: str): 318 """Resolve a dotted/bracket path to the actual nnsight module. 319 320 Delegates to the shared :func:`~drrik.steering.resolve_module_path` 321 utility so that path-resolution logic is maintained in one place. 322 323 Args: 324 path: A dot-separated module path with optional bracket 325 indexing, e.g., ``model.layers[2].mlp``. 326 327 Returns: 328 The resolved nnsight module object corresponding to the 329 given path. 330 """ 331 return resolve_module_path(self.model, path) 332 333 def extract( 334 self, 335 num_samples: Optional[int] = None, 336 ) -> Tuple[np.ndarray, Dict[str, Any]]: 337 """ 338 Extract MLP activations from the model. 339 340 Loads the model and dataset (if not already loaded), tokenizes 341 the data, and runs batched forward passes using nnsight's 342 tracing context. For each sample, the **last-token** activation 343 from each requested MLP layer is collected and concatenated. 344 345 Args: 346 num_samples: Override for the number of samples to process. 347 If ``None``, uses ``config.dataset.num_samples``. 348 349 Returns: 350 A tuple of: 351 352 - **activations** (:class:`numpy.ndarray`) — shape 353 ``(n_samples, activation_dim * n_layers)``. When a 354 single layer is requested the shape simplifies to 355 ``(n_samples, activation_dim)``. 356 - **metadata** (:class:`dict`) — contains the serialised 357 config, sample count, activation dimension, layer paths, 358 and per-sample metadata (index, truncated text, input 359 ids). 360 361 Raises: 362 RuntimeError: If extraction fails at any stage. 363 """ 364 try: 365 # Load model and dataset 366 self.load_model() 367 self.load_dataset() 368 369 n_samples = num_samples or self.config.dataset.num_samples 370 logger.info( 371 f"Extracting activations from {len(self.config.mlp_layers)} MLP layers " 372 f"for {n_samples} samples" 373 ) 374 375 # Prepare dataset 376 dataset = self.dataset.select(range(min(n_samples, len(self.dataset)))) 377 378 # Tokenize dataset 379 def tokenize_function(examples): 380 return self.tokenizer( 381 examples[self.config.dataset.text_column], 382 padding="max_length", 383 truncation=True, 384 max_length=self.config.dataset.max_length, 385 return_tensors="pt", 386 ) 387 388 tokenized = dataset.map( 389 tokenize_function, 390 batched=True, 391 remove_columns=dataset.column_names, 392 desc="Tokenizing", 393 ) 394 395 # Get MLP layer names 396 layer_paths = [ 397 self._get_mlp_layer_name(layer_idx) 398 for layer_idx in self.config.mlp_layers 399 ] 400 401 logger.info(f"Extracting from layers: {layer_paths}") 402 403 # Collect activations using nnsight 404 self._activations = [] 405 self._metadata = [] 406 407 batch_size = self.config.batch_size 408 n_batches = (len(tokenized) + batch_size - 1) // batch_size 409 410 with torch.no_grad(): 411 for batch_idx in tqdm(range(n_batches), desc="Extracting activations"): 412 start_idx = batch_idx * batch_size 413 end_idx = min(start_idx + batch_size, len(tokenized)) 414 415 batch = tokenized[start_idx:end_idx] 416 input_ids = torch.tensor(batch["input_ids"]) 417 attention_mask = torch.tensor(batch["attention_mask"]) 418 419 layer_outputs = [] 420 421 # Use nnsight to extract activations 422 with self.model.trace(input_ids, attention_mask=attention_mask): 423 for layer_path in layer_paths: 424 module = self._resolve_layer_path(layer_path) 425 output = module.output.save() 426 layer_outputs.append(output) 427 428 # Process outputs 429 batch_input_ids = input_ids.cpu().numpy() 430 for sample_idx in range(len(input_ids)): 431 sample_activations = [] 432 for layer_output in layer_outputs: 433 # Get activation after non-linearity (if applicable) 434 # Shape: (seq_len, hidden_dim) or (batch, seq_len, hidden_dim) 435 act = layer_output 436 437 if act.dim() == 3: 438 act = act[sample_idx] # (seq_len, hidden_dim) 439 elif act.dim() == 2: 440 act = act # Already (seq_len, hidden_dim) 441 442 # Use the last token's activation (common practice) 443 act = act[-1] # (hidden_dim,) 444 445 sample_activations.append(act.cpu().numpy()) 446 447 # Concatenate activations from all layers 448 self._activations.append(np.concatenate(sample_activations)) 449 450 # Store metadata 451 self._metadata.append( 452 { 453 "sample_idx": start_idx + sample_idx, 454 "text": dataset[start_idx + sample_idx][ 455 self.config.dataset.text_column 456 ][:200], 457 "input_ids": batch_input_ids[sample_idx], 458 } 459 ) 460 461 activations = np.array(self._activations) 462 logger.info(f"Extracted activations shape: {activations.shape}") 463 464 metadata = { 465 "config": self.config.model_dump(), 466 "n_samples": len(self._activations), 467 "activation_dim": activations.shape[-1], 468 "layer_paths": layer_paths, 469 "samples_metadata": self._metadata, 470 } 471 472 return activations, metadata 473 474 except Exception as e: 475 logger.error(f"Failed to extract activations: {e}") 476 raise RuntimeError(f"Activation extraction failed: {e}") from e 477 478 def save_activations( 479 self, 480 activations: np.ndarray, 481 metadata: Dict[str, Any], 482 output_dir: Optional[Union[str, Path]] = None, 483 ) -> Path: 484 """ 485 Save extracted activations to disk. 486 487 Writes two files to *output_dir*: 488 489 - ``activations.npy`` — the activation array. 490 - ``metadata.pkl`` — the metadata dictionary. 491 492 Args: 493 activations: Activation array of shape 494 ``(n_samples, dim)``. 495 metadata: Metadata dictionary (as returned by 496 :meth:`extract`). 497 output_dir: Target directory. If ``None``, uses 498 ``config.output_dir``. 499 500 Returns: 501 Path to the saved ``activations.npy`` file. 502 503 Raises: 504 ValueError: If *output_dir* is ``None`` and no output 505 directory is configured. 506 """ 507 if output_dir is None: 508 output_dir = self.config.output_dir 509 510 output_dir = Path(output_dir) 511 output_dir.mkdir(parents=True, exist_ok=True) 512 513 activations_path = output_dir / "activations.npy" 514 np.save(str(activations_path), activations) 515 516 metadata_path = output_dir / "metadata.pkl" 517 with open(metadata_path, "wb") as f: 518 pickle.dump(metadata, f) 519 520 logger.info(f"Saved activations to {activations_path}") 521 return activations_path 522 523 def load_activations( 524 self, 525 filepath: Union[str, Path], 526 ) -> Tuple[np.ndarray, Dict[str, Any]]: 527 """ 528 Load saved activations from disk. 529 530 Supports two formats: 531 532 - ``.npy`` — loads the array directly and looks for a 533 companion ``metadata.pkl`` in the same directory. 534 - ``.pkl`` (legacy) — loads a dict with ``"activations"`` 535 and ``"metadata"`` keys. 536 537 Args: 538 filepath: Path to an ``.npy`` or ``.pkl`` file. 539 540 Returns: 541 A tuple of (activations array, metadata dict). 542 543 Raises: 544 FileNotFoundError: If the file (or companion metadata) does 545 not exist. 546 """ 547 filepath = Path(filepath) 548 549 if filepath.suffix == ".npy": 550 activations = np.load(str(filepath)) 551 metadata_path = filepath.parent / "metadata.pkl" 552 with open(metadata_path, "rb") as f: 553 metadata = pickle.load(f) 554 else: 555 with open(filepath, "rb") as f: 556 data = pickle.load(f) 557 activations = data["activations"] 558 metadata = data["metadata"] 559 560 logger.info(f"Loaded activations from {filepath}") 561 return activations, metadata
Extract MLP activations from language models using nnsight.
Handles model and dataset loading from HuggingFace Hub, runs
inference with nnsight's tracing context to capture MLP outputs,
and provides persistence via .npy / .pkl files.
The HuggingFace auth token is read automatically from the
HUGGINGFACE_HUB_TOKEN environment variable (or .env file)
via ~drrik.settings.get_settings().
Attributes:
- config: The resolved
~drrik.config.ActivationExtractorConfig. - model: The loaded nnsight
LanguageModel(Noneuntilload_model()is called). - tokenizer: The HuggingFace tokenizer (
Noneuntilload_model()is called). - dataset: The loaded HuggingFace
Dataset(Noneuntilload_dataset()is called).
Example:
extractor = ActivationExtractor( model_name="google/gemma-2b", dataset_name="wikitext", dataset_config="wikitext-2-raw-v1", mlp_layers=[0], num_samples=1000, ) activations, metadata = extractor.extract() extractor.save_activations(activations, metadata, "./output")
82 def __init__(self, config: Optional[ActivationExtractorConfig] = None, **kwargs): 83 """ 84 Initialize the ActivationExtractor. 85 86 Accepts either a pre-built config object or flat keyword 87 arguments that are automatically sorted into 88 :class:`~drrik.config.ModelConfig`, 89 :class:`~drrik.config.DatasetConfig`, and the remaining 90 extraction-level settings. 91 92 Args: 93 config: A fully constructed 94 :class:`~drrik.config.ActivationExtractorConfig`. 95 If ``None``, the config is built from ``**kwargs``. 96 **kwargs: Flat config overrides. Recognised keys are 97 distributed to the appropriate sub-config: 98 99 - *Model*: ``model_name``, ``revision``, 100 ``torch_dtype``, ``device_map``, 101 ``trust_remote_code`` 102 - *Dataset*: ``dataset_name``, ``dataset_config``, 103 ``split``, ``num_samples``, ``text_column``, 104 ``max_length`` 105 - *Extraction*: ``mlp_layers``, ``batch_size``, 106 ``output_dir`` 107 """ 108 if config is None: 109 model_kwargs = { 110 k: v 111 for k, v in kwargs.items() 112 if k 113 in ( 114 "model_name", 115 "revision", 116 "torch_dtype", 117 "device_map", 118 "trust_remote_code", 119 ) 120 } 121 dataset_kwargs = { 122 k: v 123 for k, v in kwargs.items() 124 if k 125 in ( 126 "dataset_name", 127 "dataset_config", 128 "split", 129 "num_samples", 130 "text_column", 131 "max_length", 132 ) 133 } 134 remaining_kwargs = { 135 k: v 136 for k, v in kwargs.items() 137 if k not in model_kwargs and k not in dataset_kwargs 138 } 139 140 model = ModelConfig(**model_kwargs) if model_kwargs else None 141 dataset = DatasetConfig(**dataset_kwargs) if dataset_kwargs else None 142 143 config = ActivationExtractorConfig( 144 model=model, 145 dataset=dataset, 146 **remaining_kwargs, 147 ) 148 149 self.config = config 150 self.model = None 151 self.tokenizer = None 152 self.dataset = None 153 self._activations = [] 154 self._metadata = []
Initialize the ActivationExtractor.
Accepts either a pre-built config object or flat keyword
arguments that are automatically sorted into
~drrik.config.ModelConfig,
~drrik.config.DatasetConfig, and the remaining
extraction-level settings.
Arguments:
- config: A fully constructed
~drrik.config.ActivationExtractorConfig. IfNone, the config is built from**kwargs. **kwargs: Flat config overrides. Recognised keys are distributed to the appropriate sub-config:
- Model:
model_name,revision,torch_dtype,device_map,trust_remote_code - Dataset:
dataset_name,dataset_config,split,num_samples,text_column,max_length - Extraction:
mlp_layers,batch_size,output_dir
- Model:
156 def load_model(self) -> LanguageModel: 157 """ 158 Load the model from HuggingFace Hub using nnsight. 159 160 If the model is already loaded, returns the cached instance. 161 The HuggingFace token is read from ``HUGGINGFACE_HUB_TOKEN`` 162 via :func:`~drrik.settings.get_settings`. 163 164 Returns: 165 The loaded nnsight :class:`~nnsight.LanguageModel` wrapper. 166 167 Raises: 168 RuntimeError: If model loading fails. 169 """ 170 if self.model is not None: 171 return self.model 172 173 try: 174 logger.info(f"Loading model: {self.config.model.model_name}") 175 176 # Get HF token from settings 177 settings = get_settings() 178 hf_token = settings.huggingface_hub_token 179 180 if hf_token: 181 logger.info("Using HuggingFace Hub token for authentication") 182 183 # Load tokenizer with token 184 tokenizer_kwargs = { 185 "revision": self.config.model.revision, 186 "trust_remote_code": self.config.model.trust_remote_code, 187 } 188 if hf_token: 189 tokenizer_kwargs["token"] = hf_token 190 191 self.tokenizer = AutoTokenizer.from_pretrained( 192 self.config.model.model_name, **tokenizer_kwargs 193 ) 194 195 if self.tokenizer.pad_token is None: 196 self.tokenizer.pad_token = self.tokenizer.eos_token 197 198 # Determine dtype 199 dtype_map = { 200 "float16": torch.float16, 201 "bfloat16": torch.bfloat16, 202 "float32": torch.float32, 203 } 204 torch_dtype = dtype_map.get(self.config.model.torch_dtype, torch.float16) 205 206 # Load model with nnsight (with token if available) 207 nnsight_kwargs = { 208 "revision": self.config.model.revision, 209 "torch_dtype": torch_dtype, 210 "trust_remote_code": self.config.model.trust_remote_code, 211 "device_map": self.config.model.device_map, 212 } 213 if hf_token: 214 nnsight_kwargs["token"] = hf_token 215 216 self.model = LanguageModel(self.config.model.model_name, **nnsight_kwargs) 217 218 logger.info(f"Model loaded successfully on {self.model.device}") 219 return self.model 220 221 except Exception as e: 222 logger.error(f"Failed to load model: {e}") 223 raise RuntimeError(f"Model loading failed: {e}") from e
Load the model from HuggingFace Hub using nnsight.
If the model is already loaded, returns the cached instance.
The HuggingFace token is read from HUGGINGFACE_HUB_TOKEN
via ~drrik.settings.get_settings().
Returns:
The loaded nnsight
~nnsight.LanguageModelwrapper.
Raises:
- RuntimeError: If model loading fails.
225 def load_dataset(self) -> Dataset: 226 """ 227 Load the dataset from HuggingFace Hub. 228 229 If the dataset is already loaded, returns the cached instance. 230 The HuggingFace token is forwarded for gated datasets when 231 available. 232 233 Returns: 234 The loaded HuggingFace :class:`~datasets.Dataset`. 235 236 Raises: 237 RuntimeError: If dataset loading fails. 238 """ 239 if self.dataset is not None: 240 return self.dataset 241 242 try: 243 logger.info( 244 f"Loading dataset: {self.config.dataset.dataset_name} " 245 f"({self.config.dataset.split} split)" 246 ) 247 248 # Get HF token from settings (for gated datasets) 249 settings = get_settings() 250 hf_token = settings.huggingface_hub_token 251 252 # Load dataset with token if available 253 load_kwargs = { 254 "path": self.config.dataset.dataset_name, 255 "name": self.config.dataset.dataset_config, 256 "split": self.config.dataset.split, 257 } 258 if hf_token: 259 load_kwargs["token"] = hf_token 260 261 self.dataset = load_dataset(**load_kwargs) 262 263 logger.info(f"Dataset loaded with {len(self.dataset)} examples") 264 return self.dataset 265 266 except Exception as e: 267 logger.error(f"Failed to load dataset: {e}") 268 raise RuntimeError(f"Dataset loading failed: {e}") from e
Load the dataset from HuggingFace Hub.
If the dataset is already loaded, returns the cached instance. The HuggingFace token is forwarded for gated datasets when available.
Returns:
The loaded HuggingFace
~datasets.Dataset.
Raises:
- RuntimeError: If dataset loading fails.
333 def extract( 334 self, 335 num_samples: Optional[int] = None, 336 ) -> Tuple[np.ndarray, Dict[str, Any]]: 337 """ 338 Extract MLP activations from the model. 339 340 Loads the model and dataset (if not already loaded), tokenizes 341 the data, and runs batched forward passes using nnsight's 342 tracing context. For each sample, the **last-token** activation 343 from each requested MLP layer is collected and concatenated. 344 345 Args: 346 num_samples: Override for the number of samples to process. 347 If ``None``, uses ``config.dataset.num_samples``. 348 349 Returns: 350 A tuple of: 351 352 - **activations** (:class:`numpy.ndarray`) — shape 353 ``(n_samples, activation_dim * n_layers)``. When a 354 single layer is requested the shape simplifies to 355 ``(n_samples, activation_dim)``. 356 - **metadata** (:class:`dict`) — contains the serialised 357 config, sample count, activation dimension, layer paths, 358 and per-sample metadata (index, truncated text, input 359 ids). 360 361 Raises: 362 RuntimeError: If extraction fails at any stage. 363 """ 364 try: 365 # Load model and dataset 366 self.load_model() 367 self.load_dataset() 368 369 n_samples = num_samples or self.config.dataset.num_samples 370 logger.info( 371 f"Extracting activations from {len(self.config.mlp_layers)} MLP layers " 372 f"for {n_samples} samples" 373 ) 374 375 # Prepare dataset 376 dataset = self.dataset.select(range(min(n_samples, len(self.dataset)))) 377 378 # Tokenize dataset 379 def tokenize_function(examples): 380 return self.tokenizer( 381 examples[self.config.dataset.text_column], 382 padding="max_length", 383 truncation=True, 384 max_length=self.config.dataset.max_length, 385 return_tensors="pt", 386 ) 387 388 tokenized = dataset.map( 389 tokenize_function, 390 batched=True, 391 remove_columns=dataset.column_names, 392 desc="Tokenizing", 393 ) 394 395 # Get MLP layer names 396 layer_paths = [ 397 self._get_mlp_layer_name(layer_idx) 398 for layer_idx in self.config.mlp_layers 399 ] 400 401 logger.info(f"Extracting from layers: {layer_paths}") 402 403 # Collect activations using nnsight 404 self._activations = [] 405 self._metadata = [] 406 407 batch_size = self.config.batch_size 408 n_batches = (len(tokenized) + batch_size - 1) // batch_size 409 410 with torch.no_grad(): 411 for batch_idx in tqdm(range(n_batches), desc="Extracting activations"): 412 start_idx = batch_idx * batch_size 413 end_idx = min(start_idx + batch_size, len(tokenized)) 414 415 batch = tokenized[start_idx:end_idx] 416 input_ids = torch.tensor(batch["input_ids"]) 417 attention_mask = torch.tensor(batch["attention_mask"]) 418 419 layer_outputs = [] 420 421 # Use nnsight to extract activations 422 with self.model.trace(input_ids, attention_mask=attention_mask): 423 for layer_path in layer_paths: 424 module = self._resolve_layer_path(layer_path) 425 output = module.output.save() 426 layer_outputs.append(output) 427 428 # Process outputs 429 batch_input_ids = input_ids.cpu().numpy() 430 for sample_idx in range(len(input_ids)): 431 sample_activations = [] 432 for layer_output in layer_outputs: 433 # Get activation after non-linearity (if applicable) 434 # Shape: (seq_len, hidden_dim) or (batch, seq_len, hidden_dim) 435 act = layer_output 436 437 if act.dim() == 3: 438 act = act[sample_idx] # (seq_len, hidden_dim) 439 elif act.dim() == 2: 440 act = act # Already (seq_len, hidden_dim) 441 442 # Use the last token's activation (common practice) 443 act = act[-1] # (hidden_dim,) 444 445 sample_activations.append(act.cpu().numpy()) 446 447 # Concatenate activations from all layers 448 self._activations.append(np.concatenate(sample_activations)) 449 450 # Store metadata 451 self._metadata.append( 452 { 453 "sample_idx": start_idx + sample_idx, 454 "text": dataset[start_idx + sample_idx][ 455 self.config.dataset.text_column 456 ][:200], 457 "input_ids": batch_input_ids[sample_idx], 458 } 459 ) 460 461 activations = np.array(self._activations) 462 logger.info(f"Extracted activations shape: {activations.shape}") 463 464 metadata = { 465 "config": self.config.model_dump(), 466 "n_samples": len(self._activations), 467 "activation_dim": activations.shape[-1], 468 "layer_paths": layer_paths, 469 "samples_metadata": self._metadata, 470 } 471 472 return activations, metadata 473 474 except Exception as e: 475 logger.error(f"Failed to extract activations: {e}") 476 raise RuntimeError(f"Activation extraction failed: {e}") from e
Extract MLP activations from the model.
Loads the model and dataset (if not already loaded), tokenizes the data, and runs batched forward passes using nnsight's tracing context. For each sample, the last-token activation from each requested MLP layer is collected and concatenated.
Arguments:
- num_samples: Override for the number of samples to process.
If
None, usesconfig.dataset.num_samples.
Returns:
A tuple of:
- activations (
numpy.ndarray) — shape(n_samples, activation_dim * n_layers). When a single layer is requested the shape simplifies to(n_samples, activation_dim).- metadata (
dict) — contains the serialised config, sample count, activation dimension, layer paths, and per-sample metadata (index, truncated text, input ids).
Raises:
- RuntimeError: If extraction fails at any stage.
478 def save_activations( 479 self, 480 activations: np.ndarray, 481 metadata: Dict[str, Any], 482 output_dir: Optional[Union[str, Path]] = None, 483 ) -> Path: 484 """ 485 Save extracted activations to disk. 486 487 Writes two files to *output_dir*: 488 489 - ``activations.npy`` — the activation array. 490 - ``metadata.pkl`` — the metadata dictionary. 491 492 Args: 493 activations: Activation array of shape 494 ``(n_samples, dim)``. 495 metadata: Metadata dictionary (as returned by 496 :meth:`extract`). 497 output_dir: Target directory. If ``None``, uses 498 ``config.output_dir``. 499 500 Returns: 501 Path to the saved ``activations.npy`` file. 502 503 Raises: 504 ValueError: If *output_dir* is ``None`` and no output 505 directory is configured. 506 """ 507 if output_dir is None: 508 output_dir = self.config.output_dir 509 510 output_dir = Path(output_dir) 511 output_dir.mkdir(parents=True, exist_ok=True) 512 513 activations_path = output_dir / "activations.npy" 514 np.save(str(activations_path), activations) 515 516 metadata_path = output_dir / "metadata.pkl" 517 with open(metadata_path, "wb") as f: 518 pickle.dump(metadata, f) 519 520 logger.info(f"Saved activations to {activations_path}") 521 return activations_path
Save extracted activations to disk.
Writes two files to output_dir:
activations.npy— the activation array.metadata.pkl— the metadata dictionary.
Arguments:
- activations: Activation array of shape
(n_samples, dim). - metadata: Metadata dictionary (as returned by
extract()). - output_dir: Target directory. If
None, usesconfig.output_dir.
Returns:
Path to the saved
activations.npyfile.
Raises:
- ValueError: If output_dir is
Noneand no output directory is configured.
523 def load_activations( 524 self, 525 filepath: Union[str, Path], 526 ) -> Tuple[np.ndarray, Dict[str, Any]]: 527 """ 528 Load saved activations from disk. 529 530 Supports two formats: 531 532 - ``.npy`` — loads the array directly and looks for a 533 companion ``metadata.pkl`` in the same directory. 534 - ``.pkl`` (legacy) — loads a dict with ``"activations"`` 535 and ``"metadata"`` keys. 536 537 Args: 538 filepath: Path to an ``.npy`` or ``.pkl`` file. 539 540 Returns: 541 A tuple of (activations array, metadata dict). 542 543 Raises: 544 FileNotFoundError: If the file (or companion metadata) does 545 not exist. 546 """ 547 filepath = Path(filepath) 548 549 if filepath.suffix == ".npy": 550 activations = np.load(str(filepath)) 551 metadata_path = filepath.parent / "metadata.pkl" 552 with open(metadata_path, "rb") as f: 553 metadata = pickle.load(f) 554 else: 555 with open(filepath, "rb") as f: 556 data = pickle.load(f) 557 activations = data["activations"] 558 metadata = data["metadata"] 559 560 logger.info(f"Loaded activations from {filepath}") 561 return activations, metadata
Load saved activations from disk.
Supports two formats:
.npy— loads the array directly and looks for a companionmetadata.pklin the same directory..pkl(legacy) — loads a dict with"activations"and"metadata"keys.
Arguments:
- filepath: Path to an
.npyor.pklfile.
Returns:
A tuple of (activations array, metadata dict).
Raises:
- FileNotFoundError: If the file (or companion metadata) does not exist.
47class SparseAutoencoder(nn.Module): 48 """ 49 Sparse Autoencoder for extracting interpretable features from MLP activations. 50 51 Architecture: 52 Input (activation_dim) -> [bias] -> Encoder (hidden_dim) -> ReLU -> [bias] 53 -> Decoder (activation_dim) -> Output 54 55 The decoder weights are normalized to unit norm to prevent scaling collapse. 56 L1 regularization on hidden activations encourages sparsity. 57 58 Based on the architecture described in: 59 "Towards Monosemanticity: Decomposing Language Models With Dictionary Learning" 60 https://transformer-circuits.pub/2023/monosemantic-features/index.html 61 62 Attributes: 63 encoder: Linear layer from activation_dim to hidden_dim 64 decoder: Linear layer from hidden_dim to activation_dim 65 pre_encoder_bias: Learnable bias subtracted from input 66 post_decoder_bias: Learnable bias added to output (tied to pre_encoder_bias) 67 68 Example: 69 ```python 70 sae = SparseAutoencoder( 71 activation_dim=2048, 72 hidden_dim=4096, # 2x expansion 73 l1_coefficient=0.01 74 ) 75 sae.fit(activations, num_epochs=100) 76 features = sae.encode(activations) 77 reconstructed = sae.decode(features) 78 ``` 79 """ 80 81 def __init__( 82 self, 83 activation_dim: int, 84 hidden_dim: int, 85 l1_coefficient: float = 0.01, 86 normalize_decoder: bool = True, 87 pre_encoder_bias: bool = True, 88 ): 89 """ 90 Initialize the Sparse Autoencoder. 91 92 Args: 93 activation_dim: Dimension of input MLP activations 94 hidden_dim: Dimension of sparse hidden layer (overcomplete) 95 l1_coefficient: L1 regularization strength 96 normalize_decoder: Whether to normalize decoder columns to unit norm 97 pre_encoder_bias: Whether to use pre-encoder bias (as in the paper) 98 """ 99 super().__init__() 100 101 self.activation_dim = activation_dim 102 self.hidden_dim = hidden_dim 103 self.l1_coefficient = l1_coefficient 104 self.normalize_decoder = normalize_decoder 105 self.pre_encoder_bias = pre_encoder_bias 106 107 # Encoder: input -> hidden 108 self.encoder = nn.Linear(activation_dim, hidden_dim, bias=False) 109 110 # Decoder: hidden -> output 111 # Initialize with small random weights 112 self.decoder = nn.Linear(hidden_dim, activation_dim, bias=False) 113 114 # Initialize decoder weights to unit norm (Kaiming uniform) 115 nn.init.kaiming_uniform_(self.decoder.weight, a=np.sqrt(5)) 116 with torch.no_grad(): 117 self.decoder.weight.copy_( 118 self.decoder.weight / self.decoder.weight.norm(dim=0, keepdim=True) 119 ) 120 121 # Initialize encoder weights 122 nn.init.kaiming_uniform_(self.encoder.weight, a=np.sqrt(5)) 123 124 # Biases 125 if pre_encoder_bias: 126 # Pre-encoder bias (subtracted from input, added to output) 127 # Initialize to geometric median of data (will be set during fit) 128 self.bias = nn.Parameter(torch.zeros(activation_dim)) 129 else: 130 self.register_parameter("bias", None) 131 132 # Encoder bias 133 self.encoder_bias = nn.Parameter(torch.zeros(hidden_dim)) 134 135 # Training metrics 136 self.training_losses = [] 137 self.training_l0_norms = [] 138 139 def encode(self, x: torch.Tensor) -> torch.Tensor: 140 """ 141 Encode input activations to sparse features. 142 143 Args: 144 x: Input tensor of shape (batch_size, activation_dim) 145 146 Returns: 147 Sparse feature activations of shape (batch_size, hidden_dim) 148 """ 149 # Subtract bias if using pre-encoder bias 150 if self.bias is not None: 151 x = x - self.bias 152 153 # Apply encoder 154 hidden = F.linear(x, self.encoder.weight, self.encoder_bias) 155 156 # ReLU activation for sparsity 157 features = F.relu(hidden) 158 159 return features 160 161 def decode(self, features: torch.Tensor) -> torch.Tensor: 162 """ 163 Decode sparse features back to activation space. 164 165 Args: 166 features: Sparse feature tensor of shape (batch_size, hidden_dim) 167 168 Returns: 169 Reconstructed activations of shape (batch_size, activation_dim) 170 """ 171 # Apply decoder (weights are normalized to unit norm) 172 output = F.linear(features, self.decoder.weight) 173 174 # Add bias if using 175 if self.bias is not None: 176 output = output + self.bias 177 178 return output 179 180 def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: 181 """ 182 Forward pass through the autoencoder. 183 184 Args: 185 x: Input tensor of shape (batch_size, activation_dim) 186 187 Returns: 188 Tuple of (reconstructed, features) 189 """ 190 features = self.encode(x) 191 reconstructed = self.decode(features) 192 return reconstructed, features 193 194 def loss( 195 self, x: torch.Tensor, reconstructed: torch.Tensor, features: torch.Tensor 196 ) -> torch.Tensor: 197 """ 198 Compute the loss with L1 regularization. 199 200 Loss = MSE(reconstructed, x) + lambda * L1(features) 201 202 Args: 203 x: Input tensor 204 reconstructed: Reconstructed tensor 205 features: Sparse feature activations 206 207 Returns: 208 Total loss 209 """ 210 mse_loss = F.mse_loss(reconstructed, x) 211 l1_loss = self.l1_coefficient * features.abs().sum(dim=1).mean() 212 return mse_loss + l1_loss 213 214 def normalize_decoder_weights(self) -> None: 215 """Normalize decoder weight columns to unit L2 norm. 216 217 This prevents scaling collapse where the optimizer could 218 trivially reduce the loss by shrinking decoder weights and 219 scaling up encoder weights. Applied after each optimizer step 220 when ``normalize_decoder`` is ``True``. 221 """ 222 if self.normalize_decoder: 223 with torch.no_grad(): 224 self.decoder.weight.copy_( 225 self.decoder.weight 226 / self.decoder.weight.norm(dim=0, keepdim=True).clamp(min=1e-8) 227 ) 228 229 def resample_dead_neurons( 230 self, 231 activations: torch.Tensor, 232 dead_threshold: float = 1e-8, 233 dead_mask: Optional[torch.Tensor] = None, 234 ) -> int: 235 """ 236 Resample dead neurons that haven't fired recently. 237 238 This follows the procedure from the paper: 239 1. Identify neurons that haven't fired above threshold 240 2. Compute loss on a batch of data 241 3. Resample dead neurons to fit poorly reconstructed examples 242 243 Args: 244 activations: Batch of activations to use for resampling 245 dead_threshold: Activation threshold below which a neuron is considered dead 246 dead_mask: Pre-computed boolean mask of dead neurons. If None, computed 247 from the current batch. When provided, uses a sliding window 248 approach for more robust dead neuron detection. 249 250 Returns: 251 Number of neurons resampled 252 """ 253 with torch.no_grad(): 254 # Get feature activations 255 features = self.encode(activations) 256 257 # Find dead neurons 258 if dead_mask is None: 259 neuron_activity = features.abs().sum(dim=0) 260 dead_mask = neuron_activity < dead_threshold 261 n_dead = dead_mask.sum().item() 262 263 if n_dead == 0: 264 return 0 265 266 logger.info(f"Resampling {n_dead} dead neurons") 267 268 # Compute reconstruction loss for each sample 269 reconstructed, _ = self.forward(activations) 270 sample_losses = F.mse_loss( 271 reconstructed, activations, reduction="none" 272 ).sum(dim=1) 273 274 # Sample based on loss (higher loss = more likely to be resampled) 275 probs = sample_losses**2 276 probs = probs / probs.sum() 277 278 # For each dead neuron, resample from a high-loss example 279 for dead_idx in torch.where(dead_mask)[0]: 280 # Sample an example 281 sample_idx = torch.multinomial(probs, 1).item() 282 example = activations[sample_idx] 283 284 # Normalize example 285 example_normalized = example / (example.norm() + 1e-8) 286 287 # Set decoder weight 288 self.decoder.weight[:, dead_idx] = example_normalized 289 290 # Set encoder weight (smaller scale to prevent immediate firing) 291 avg_encoder_norm = ( 292 self.encoder.weight[~dead_mask].norm(dim=1).mean().item() 293 if (~dead_mask).any() > 0 294 else 0.1 295 ) 296 self.encoder.weight[dead_idx, :] = ( 297 example_normalized * avg_encoder_norm * 0.2 298 ) 299 300 # Reset encoder bias 301 self.encoder_bias[dead_idx] = 0 302 303 # Re-normalize decoder 304 self.normalize_decoder_weights() 305 306 return n_dead 307 308 def fit( 309 self, 310 activations: np.ndarray, 311 batch_size: int = 256, 312 num_epochs: int = 100, 313 learning_rate: float = 1e-4, 314 validation_split: float = 0.1, 315 resample_dead_neurons: bool = True, 316 resample_interval: int = 10000, 317 dead_threshold: float = 1e-8, 318 window_size: int = 100, 319 device: Optional[str] = None, 320 verbose: bool = True, 321 wandb_config: Optional[WandbConfig] = None, 322 wandb_enabled: bool = True, 323 ) -> "SparseAutoencoder": 324 """ 325 Train the sparse autoencoder on MLP activations. 326 327 Args: 328 activations: Training data of shape (n_samples, activation_dim) 329 batch_size: Batch size for training 330 num_epochs: Number of training epochs 331 learning_rate: Learning rate for Adam optimizer 332 validation_split: Fraction of data to use for validation 333 resample_dead_neurons: Whether to resample dead neurons during training 334 resample_interval: Steps between resampling checks 335 dead_threshold: Activation threshold below which a neuron is considered dead 336 window_size: Number of recent batches to track for dead neuron detection 337 device: Device to train on (cuda/cpu). If None, auto-detect 338 verbose: Whether to show progress bars 339 wandb_config: Optional WandbConfig for experiment tracking 340 wandb_enabled: If True and wandb_config is None, create default WandbConfig 341 342 Returns: 343 self (trained model) 344 """ 345 # Setup wandb if enabled 346 wandb_logger = None 347 _owns_wandb = False 348 if wandb_enabled: 349 if wandb_config is None: 350 wandb_logger = WandbConfig( 351 config={ 352 "activation_dim": self.activation_dim, 353 "hidden_dim": self.hidden_dim, 354 "expansion_factor": self.hidden_dim / self.activation_dim, 355 "l1_coefficient": self.l1_coefficient, 356 "batch_size": batch_size, 357 "learning_rate": learning_rate, 358 "num_epochs": num_epochs, 359 } 360 ) 361 wandb_logger.initialize() 362 _owns_wandb = True 363 elif not wandb_config._initialized: 364 wandb_logger = wandb_config 365 wandb_logger.initialize() 366 _owns_wandb = True 367 else: 368 wandb_logger = wandb_config 369 370 # Determine device 371 if device is None: 372 device = "cuda" if torch.cuda.is_available() else "cpu" 373 374 self.to(device) 375 376 # Convert to tensor 377 if isinstance(activations, np.ndarray): 378 activations = torch.from_numpy(activations).float() 379 380 n_samples = len(activations) 381 n_val = int(n_samples * validation_split) 382 n_train = n_samples - n_val 383 384 # Split data 385 train_data = activations[:n_train] 386 val_data = activations[n_train:] 387 388 # Create data loaders 389 train_dataset = TensorDataset(train_data) 390 train_loader = DataLoader( 391 train_dataset, 392 batch_size=batch_size, 393 shuffle=True, 394 ) 395 396 # Initialize bias to median if using 397 if self.bias is not None and self.pre_encoder_bias: 398 with torch.no_grad(): 399 # Approximate geometric median using median for simplicity 400 median = train_data.to(device).median(dim=0).values 401 self.bias.data = median 402 403 # Optimizer 404 optimizer = torch.optim.Adam( 405 self.parameters(), 406 lr=learning_rate, 407 ) 408 409 # Training loop 410 self.training_losses = [] 411 self.training_l0_norms = [] 412 413 n_steps_since_resample = 0 414 global_step = 0 415 416 # Sliding window for tracking neuron activity across batches 417 neuron_activity_window = [] 418 419 if verbose: 420 pbar = tqdm(total=num_epochs, desc="Training SAE") 421 else: 422 pbar = None 423 424 for epoch in range(num_epochs): 425 epoch_loss = 0.0 426 epoch_l0 = 0.0 427 428 for batch in train_loader: 429 batch = batch[0].to(device) 430 431 # Forward pass 432 reconstructed, features = self.forward(batch) 433 434 # Track neuron activity for sliding window dead detection 435 neuron_activity = features.abs().sum(dim=0) 436 neuron_activity_window.append(neuron_activity) 437 if len(neuron_activity_window) > window_size: 438 neuron_activity_window.pop(0) 439 440 # Compute loss 441 loss = self.loss(batch, reconstructed, features) 442 443 # Backward pass 444 optimizer.zero_grad() 445 loss.backward() 446 447 # Remove gradients parallel to decoder weights (Adam optimization trick from paper) 448 if self.normalize_decoder: 449 with torch.no_grad(): 450 # Project gradients to be orthogonal to decoder weights 451 decoder_w = self.decoder.weight 452 decoder_grad = self.decoder.weight.grad 453 454 # Compute projection 455 projection = (decoder_grad * decoder_w).sum( 456 dim=0, keepdim=True 457 ) * decoder_w 458 self.decoder.weight.grad = decoder_grad - projection 459 460 optimizer.step() 461 462 # Normalize decoder weights 463 self.normalize_decoder_weights() 464 465 # Track metrics 466 epoch_loss += loss.item() 467 epoch_l0 += (features > 0).sum(dim=1).float().mean().item() 468 469 n_steps_since_resample += 1 470 global_step += 1 471 472 # Resample dead neurons using sliding window 473 if ( 474 resample_dead_neurons 475 and n_steps_since_resample >= resample_interval 476 ): 477 if len(neuron_activity_window) > 0: 478 avg_activity = torch.stack(neuron_activity_window).mean(dim=0) 479 window_dead_mask = avg_activity < dead_threshold 480 else: 481 window_dead_mask = None 482 483 n_resampled = self.resample_dead_neurons( 484 batch, dead_threshold=dead_threshold, dead_mask=window_dead_mask 485 ) 486 if n_resampled > 0: 487 neuron_activity_window.clear() 488 n_steps_since_resample = 0 489 490 # Average metrics 491 avg_loss = epoch_loss / len(train_loader) 492 avg_l0 = epoch_l0 / len(train_loader) 493 494 self.training_losses.append(avg_loss) 495 self.training_l0_norms.append(avg_l0) 496 497 # Validation 498 with torch.no_grad(): 499 val_data_tensor = val_data.to(device) 500 val_reconstructed, val_features = self.forward(val_data_tensor) 501 val_loss = F.mse_loss(val_reconstructed, val_data_tensor).item() 502 val_l0 = (val_features > 0).sum(dim=1).float().mean().item() 503 504 # Log to wandb 505 if wandb_logger: 506 wandb_logger.log_metrics( 507 { 508 "epoch": epoch + 1, 509 "train_loss": avg_loss, 510 "train_l0_norm": avg_l0, 511 "val_loss": val_loss, 512 "val_l0_norm": val_l0, 513 "learning_rate": learning_rate, 514 }, 515 step=epoch + 1, 516 ) 517 518 if verbose: 519 if pbar: 520 pbar.update(1) 521 pbar.set_postfix( 522 { 523 "loss": f"{avg_loss:.6f}", 524 "val_loss": f"{val_loss:.6f}", 525 "L0": f"{avg_l0:.2f}", 526 "val_L0": f"{val_l0:.2f}", 527 } 528 ) 529 else: 530 logger.info( 531 f"Epoch {epoch + 1}/{num_epochs}: " 532 f"loss={avg_loss:.6f}, val_loss={val_loss:.6f}, " 533 f"L0={avg_l0:.2f}, val_L0={val_l0:.2f}" 534 ) 535 536 if pbar: 537 pbar.close() 538 539 # Finalize wandb and log summary metrics 540 if wandb_logger: 541 # Log final feature densities 542 densities = self.get_feature_density(activations.cpu().numpy()) 543 n_dead = (densities == 0).sum() 544 n_active = (densities > 0).sum() 545 546 wandb_logger.log_metrics( 547 { 548 "final_train_loss": self.training_losses[-1], 549 "final_val_loss": val_loss, 550 "final_l0_norm": self.training_l0_norms[-1], 551 "final_val_l0_norm": val_l0, 552 "n_dead_features": int(n_dead), 553 "n_active_features": int(n_active), 554 "feature_sparsity": float(n_active / len(densities)), 555 } 556 ) 557 558 # Log feature density histogram 559 wandb_logger.log_histogram(densities, "feature_density_histogram") 560 561 logger.info(f"wandb run URL: {wandb_logger.get_run_url()}") 562 563 # Finalize wandb (close the run) only if we created it 564 if _owns_wandb: 565 wandb_logger.finalize() 566 567 logger.info("Training complete!") 568 return self 569 570 def get_feature_density(self, activations: np.ndarray) -> np.ndarray: 571 """ 572 Compute the density (fraction of non-zero activations) for each feature. 573 574 Args: 575 activations: Data to compute density on 576 577 Returns: 578 Array of feature densities of shape (hidden_dim,) 579 """ 580 self.eval() 581 device = next(self.parameters()).device 582 with torch.no_grad(): 583 if isinstance(activations, np.ndarray): 584 activations = torch.from_numpy(activations).float().to(device) 585 else: 586 activations = activations.to(device) 587 588 features = self.encode(activations) 589 density = (features > 0).float().mean(dim=0).cpu().numpy() 590 591 return density 592 593 def get_top_activating_examples( 594 self, 595 activations: np.ndarray, 596 feature_idx: int, 597 k: int = 10, 598 ) -> Tuple[np.ndarray, np.ndarray]: 599 """ 600 Get the top k examples that activate a given feature the most. 601 602 Args: 603 activations: Data to search through 604 feature_idx: Index of the feature to analyze 605 k: Number of top examples to return 606 607 Returns: 608 Tuple of (top activations values, top indices) 609 """ 610 self.eval() 611 device = next(self.parameters()).device 612 with torch.no_grad(): 613 if isinstance(activations, np.ndarray): 614 activations = torch.from_numpy(activations).float().to(device) 615 else: 616 activations = activations.to(device) 617 618 features = self.encode(activations) 619 feature_activations = features[:, feature_idx].cpu().numpy() 620 621 top_k_indices = np.argsort(feature_activations)[-k:][::-1] 622 top_k_values = feature_activations[top_k_indices] 623 624 return top_k_values, top_k_indices 625 626 def save(self, filepath: Union[str, Path]) -> None: 627 """Save model state, hyperparameters, and training history to disk. 628 629 Saves a dictionary containing the model architecture parameters, 630 state dict, and training loss/L0 history as a PyTorch ``.pt`` file. 631 The file can be loaded with ``SparseAutoencoder.load()``. 632 633 Args: 634 filepath: Path where the model file will be saved. Parent 635 directories are created automatically if they don't exist. 636 """ 637 filepath = Path(filepath) 638 filepath.parent.mkdir(parents=True, exist_ok=True) 639 640 state = { 641 "activation_dim": self.activation_dim, 642 "hidden_dim": self.hidden_dim, 643 "l1_coefficient": self.l1_coefficient, 644 "state_dict": self.state_dict(), 645 "training_losses": self.training_losses, 646 "training_l0_norms": self.training_l0_norms, 647 } 648 649 torch.save(state, filepath) 650 logger.info(f"Saved model to {filepath}") 651 652 @classmethod 653 def load(cls, filepath: Union[str, Path]) -> "SparseAutoencoder": 654 """Load a saved SparseAutoencoder from disk. 655 656 Reconstructs the model from a ``.pt`` file saved by ``save()``, 657 restoring the architecture, learned weights, and training 658 history (losses and L0 norms). 659 660 Args: 661 filepath: Path to the saved ``.pt`` model file. 662 663 Returns: 664 A ``SparseAutoencoder`` instance with restored weights and 665 training metrics. 666 """ 667 filepath = Path(filepath) 668 state = torch.load(filepath, weights_only=False) 669 670 model = cls( 671 activation_dim=state["activation_dim"], 672 hidden_dim=state["hidden_dim"], 673 l1_coefficient=state["l1_coefficient"], 674 ) 675 model.load_state_dict(state["state_dict"]) 676 model.training_losses = state.get("training_losses", []) 677 model.training_l0_norms = state.get("training_l0_norms", []) 678 679 logger.info(f"Loaded model from {filepath}") 680 return model
Sparse Autoencoder for extracting interpretable features from MLP activations.
Architecture:
Input (activation_dim) -> [bias] -> Encoder (hidden_dim) -> ReLU -> [bias] -> Decoder (activation_dim) -> Output
The decoder weights are normalized to unit norm to prevent scaling collapse. L1 regularization on hidden activations encourages sparsity.
Based on the architecture described in: "Towards Monosemanticity: Decomposing Language Models With Dictionary Learning" https://transformer-circuits.pub/2023/monosemantic-features/index.html
Attributes:
- encoder: Linear layer from activation_dim to hidden_dim
- decoder: Linear layer from hidden_dim to activation_dim
- pre_encoder_bias: Learnable bias subtracted from input
- post_decoder_bias: Learnable bias added to output (tied to pre_encoder_bias)
Example:
sae = SparseAutoencoder( activation_dim=2048, hidden_dim=4096, # 2x expansion l1_coefficient=0.01 ) sae.fit(activations, num_epochs=100) features = sae.encode(activations) reconstructed = sae.decode(features)
81 def __init__( 82 self, 83 activation_dim: int, 84 hidden_dim: int, 85 l1_coefficient: float = 0.01, 86 normalize_decoder: bool = True, 87 pre_encoder_bias: bool = True, 88 ): 89 """ 90 Initialize the Sparse Autoencoder. 91 92 Args: 93 activation_dim: Dimension of input MLP activations 94 hidden_dim: Dimension of sparse hidden layer (overcomplete) 95 l1_coefficient: L1 regularization strength 96 normalize_decoder: Whether to normalize decoder columns to unit norm 97 pre_encoder_bias: Whether to use pre-encoder bias (as in the paper) 98 """ 99 super().__init__() 100 101 self.activation_dim = activation_dim 102 self.hidden_dim = hidden_dim 103 self.l1_coefficient = l1_coefficient 104 self.normalize_decoder = normalize_decoder 105 self.pre_encoder_bias = pre_encoder_bias 106 107 # Encoder: input -> hidden 108 self.encoder = nn.Linear(activation_dim, hidden_dim, bias=False) 109 110 # Decoder: hidden -> output 111 # Initialize with small random weights 112 self.decoder = nn.Linear(hidden_dim, activation_dim, bias=False) 113 114 # Initialize decoder weights to unit norm (Kaiming uniform) 115 nn.init.kaiming_uniform_(self.decoder.weight, a=np.sqrt(5)) 116 with torch.no_grad(): 117 self.decoder.weight.copy_( 118 self.decoder.weight / self.decoder.weight.norm(dim=0, keepdim=True) 119 ) 120 121 # Initialize encoder weights 122 nn.init.kaiming_uniform_(self.encoder.weight, a=np.sqrt(5)) 123 124 # Biases 125 if pre_encoder_bias: 126 # Pre-encoder bias (subtracted from input, added to output) 127 # Initialize to geometric median of data (will be set during fit) 128 self.bias = nn.Parameter(torch.zeros(activation_dim)) 129 else: 130 self.register_parameter("bias", None) 131 132 # Encoder bias 133 self.encoder_bias = nn.Parameter(torch.zeros(hidden_dim)) 134 135 # Training metrics 136 self.training_losses = [] 137 self.training_l0_norms = []
Initialize the Sparse Autoencoder.
Arguments:
- activation_dim: Dimension of input MLP activations
- hidden_dim: Dimension of sparse hidden layer (overcomplete)
- l1_coefficient: L1 regularization strength
- normalize_decoder: Whether to normalize decoder columns to unit norm
- pre_encoder_bias: Whether to use pre-encoder bias (as in the paper)
139 def encode(self, x: torch.Tensor) -> torch.Tensor: 140 """ 141 Encode input activations to sparse features. 142 143 Args: 144 x: Input tensor of shape (batch_size, activation_dim) 145 146 Returns: 147 Sparse feature activations of shape (batch_size, hidden_dim) 148 """ 149 # Subtract bias if using pre-encoder bias 150 if self.bias is not None: 151 x = x - self.bias 152 153 # Apply encoder 154 hidden = F.linear(x, self.encoder.weight, self.encoder_bias) 155 156 # ReLU activation for sparsity 157 features = F.relu(hidden) 158 159 return features
Encode input activations to sparse features.
Arguments:
- x: Input tensor of shape (batch_size, activation_dim)
Returns:
Sparse feature activations of shape (batch_size, hidden_dim)
161 def decode(self, features: torch.Tensor) -> torch.Tensor: 162 """ 163 Decode sparse features back to activation space. 164 165 Args: 166 features: Sparse feature tensor of shape (batch_size, hidden_dim) 167 168 Returns: 169 Reconstructed activations of shape (batch_size, activation_dim) 170 """ 171 # Apply decoder (weights are normalized to unit norm) 172 output = F.linear(features, self.decoder.weight) 173 174 # Add bias if using 175 if self.bias is not None: 176 output = output + self.bias 177 178 return output
Decode sparse features back to activation space.
Arguments:
- features: Sparse feature tensor of shape (batch_size, hidden_dim)
Returns:
Reconstructed activations of shape (batch_size, activation_dim)
180 def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: 181 """ 182 Forward pass through the autoencoder. 183 184 Args: 185 x: Input tensor of shape (batch_size, activation_dim) 186 187 Returns: 188 Tuple of (reconstructed, features) 189 """ 190 features = self.encode(x) 191 reconstructed = self.decode(features) 192 return reconstructed, features
Forward pass through the autoencoder.
Arguments:
- x: Input tensor of shape (batch_size, activation_dim)
Returns:
Tuple of (reconstructed, features)
194 def loss( 195 self, x: torch.Tensor, reconstructed: torch.Tensor, features: torch.Tensor 196 ) -> torch.Tensor: 197 """ 198 Compute the loss with L1 regularization. 199 200 Loss = MSE(reconstructed, x) + lambda * L1(features) 201 202 Args: 203 x: Input tensor 204 reconstructed: Reconstructed tensor 205 features: Sparse feature activations 206 207 Returns: 208 Total loss 209 """ 210 mse_loss = F.mse_loss(reconstructed, x) 211 l1_loss = self.l1_coefficient * features.abs().sum(dim=1).mean() 212 return mse_loss + l1_loss
Compute the loss with L1 regularization.
Loss = MSE(reconstructed, x) + lambda * L1(features)
Arguments:
- x: Input tensor
- reconstructed: Reconstructed tensor
- features: Sparse feature activations
Returns:
Total loss
214 def normalize_decoder_weights(self) -> None: 215 """Normalize decoder weight columns to unit L2 norm. 216 217 This prevents scaling collapse where the optimizer could 218 trivially reduce the loss by shrinking decoder weights and 219 scaling up encoder weights. Applied after each optimizer step 220 when ``normalize_decoder`` is ``True``. 221 """ 222 if self.normalize_decoder: 223 with torch.no_grad(): 224 self.decoder.weight.copy_( 225 self.decoder.weight 226 / self.decoder.weight.norm(dim=0, keepdim=True).clamp(min=1e-8) 227 )
Normalize decoder weight columns to unit L2 norm.
This prevents scaling collapse where the optimizer could
trivially reduce the loss by shrinking decoder weights and
scaling up encoder weights. Applied after each optimizer step
when normalize_decoder is True.
229 def resample_dead_neurons( 230 self, 231 activations: torch.Tensor, 232 dead_threshold: float = 1e-8, 233 dead_mask: Optional[torch.Tensor] = None, 234 ) -> int: 235 """ 236 Resample dead neurons that haven't fired recently. 237 238 This follows the procedure from the paper: 239 1. Identify neurons that haven't fired above threshold 240 2. Compute loss on a batch of data 241 3. Resample dead neurons to fit poorly reconstructed examples 242 243 Args: 244 activations: Batch of activations to use for resampling 245 dead_threshold: Activation threshold below which a neuron is considered dead 246 dead_mask: Pre-computed boolean mask of dead neurons. If None, computed 247 from the current batch. When provided, uses a sliding window 248 approach for more robust dead neuron detection. 249 250 Returns: 251 Number of neurons resampled 252 """ 253 with torch.no_grad(): 254 # Get feature activations 255 features = self.encode(activations) 256 257 # Find dead neurons 258 if dead_mask is None: 259 neuron_activity = features.abs().sum(dim=0) 260 dead_mask = neuron_activity < dead_threshold 261 n_dead = dead_mask.sum().item() 262 263 if n_dead == 0: 264 return 0 265 266 logger.info(f"Resampling {n_dead} dead neurons") 267 268 # Compute reconstruction loss for each sample 269 reconstructed, _ = self.forward(activations) 270 sample_losses = F.mse_loss( 271 reconstructed, activations, reduction="none" 272 ).sum(dim=1) 273 274 # Sample based on loss (higher loss = more likely to be resampled) 275 probs = sample_losses**2 276 probs = probs / probs.sum() 277 278 # For each dead neuron, resample from a high-loss example 279 for dead_idx in torch.where(dead_mask)[0]: 280 # Sample an example 281 sample_idx = torch.multinomial(probs, 1).item() 282 example = activations[sample_idx] 283 284 # Normalize example 285 example_normalized = example / (example.norm() + 1e-8) 286 287 # Set decoder weight 288 self.decoder.weight[:, dead_idx] = example_normalized 289 290 # Set encoder weight (smaller scale to prevent immediate firing) 291 avg_encoder_norm = ( 292 self.encoder.weight[~dead_mask].norm(dim=1).mean().item() 293 if (~dead_mask).any() > 0 294 else 0.1 295 ) 296 self.encoder.weight[dead_idx, :] = ( 297 example_normalized * avg_encoder_norm * 0.2 298 ) 299 300 # Reset encoder bias 301 self.encoder_bias[dead_idx] = 0 302 303 # Re-normalize decoder 304 self.normalize_decoder_weights() 305 306 return n_dead
Resample dead neurons that haven't fired recently.
This follows the procedure from the paper:
- Identify neurons that haven't fired above threshold
- Compute loss on a batch of data
- Resample dead neurons to fit poorly reconstructed examples
Arguments:
- activations: Batch of activations to use for resampling
- dead_threshold: Activation threshold below which a neuron is considered dead
- dead_mask: Pre-computed boolean mask of dead neurons. If None, computed from the current batch. When provided, uses a sliding window approach for more robust dead neuron detection.
Returns:
Number of neurons resampled
308 def fit( 309 self, 310 activations: np.ndarray, 311 batch_size: int = 256, 312 num_epochs: int = 100, 313 learning_rate: float = 1e-4, 314 validation_split: float = 0.1, 315 resample_dead_neurons: bool = True, 316 resample_interval: int = 10000, 317 dead_threshold: float = 1e-8, 318 window_size: int = 100, 319 device: Optional[str] = None, 320 verbose: bool = True, 321 wandb_config: Optional[WandbConfig] = None, 322 wandb_enabled: bool = True, 323 ) -> "SparseAutoencoder": 324 """ 325 Train the sparse autoencoder on MLP activations. 326 327 Args: 328 activations: Training data of shape (n_samples, activation_dim) 329 batch_size: Batch size for training 330 num_epochs: Number of training epochs 331 learning_rate: Learning rate for Adam optimizer 332 validation_split: Fraction of data to use for validation 333 resample_dead_neurons: Whether to resample dead neurons during training 334 resample_interval: Steps between resampling checks 335 dead_threshold: Activation threshold below which a neuron is considered dead 336 window_size: Number of recent batches to track for dead neuron detection 337 device: Device to train on (cuda/cpu). If None, auto-detect 338 verbose: Whether to show progress bars 339 wandb_config: Optional WandbConfig for experiment tracking 340 wandb_enabled: If True and wandb_config is None, create default WandbConfig 341 342 Returns: 343 self (trained model) 344 """ 345 # Setup wandb if enabled 346 wandb_logger = None 347 _owns_wandb = False 348 if wandb_enabled: 349 if wandb_config is None: 350 wandb_logger = WandbConfig( 351 config={ 352 "activation_dim": self.activation_dim, 353 "hidden_dim": self.hidden_dim, 354 "expansion_factor": self.hidden_dim / self.activation_dim, 355 "l1_coefficient": self.l1_coefficient, 356 "batch_size": batch_size, 357 "learning_rate": learning_rate, 358 "num_epochs": num_epochs, 359 } 360 ) 361 wandb_logger.initialize() 362 _owns_wandb = True 363 elif not wandb_config._initialized: 364 wandb_logger = wandb_config 365 wandb_logger.initialize() 366 _owns_wandb = True 367 else: 368 wandb_logger = wandb_config 369 370 # Determine device 371 if device is None: 372 device = "cuda" if torch.cuda.is_available() else "cpu" 373 374 self.to(device) 375 376 # Convert to tensor 377 if isinstance(activations, np.ndarray): 378 activations = torch.from_numpy(activations).float() 379 380 n_samples = len(activations) 381 n_val = int(n_samples * validation_split) 382 n_train = n_samples - n_val 383 384 # Split data 385 train_data = activations[:n_train] 386 val_data = activations[n_train:] 387 388 # Create data loaders 389 train_dataset = TensorDataset(train_data) 390 train_loader = DataLoader( 391 train_dataset, 392 batch_size=batch_size, 393 shuffle=True, 394 ) 395 396 # Initialize bias to median if using 397 if self.bias is not None and self.pre_encoder_bias: 398 with torch.no_grad(): 399 # Approximate geometric median using median for simplicity 400 median = train_data.to(device).median(dim=0).values 401 self.bias.data = median 402 403 # Optimizer 404 optimizer = torch.optim.Adam( 405 self.parameters(), 406 lr=learning_rate, 407 ) 408 409 # Training loop 410 self.training_losses = [] 411 self.training_l0_norms = [] 412 413 n_steps_since_resample = 0 414 global_step = 0 415 416 # Sliding window for tracking neuron activity across batches 417 neuron_activity_window = [] 418 419 if verbose: 420 pbar = tqdm(total=num_epochs, desc="Training SAE") 421 else: 422 pbar = None 423 424 for epoch in range(num_epochs): 425 epoch_loss = 0.0 426 epoch_l0 = 0.0 427 428 for batch in train_loader: 429 batch = batch[0].to(device) 430 431 # Forward pass 432 reconstructed, features = self.forward(batch) 433 434 # Track neuron activity for sliding window dead detection 435 neuron_activity = features.abs().sum(dim=0) 436 neuron_activity_window.append(neuron_activity) 437 if len(neuron_activity_window) > window_size: 438 neuron_activity_window.pop(0) 439 440 # Compute loss 441 loss = self.loss(batch, reconstructed, features) 442 443 # Backward pass 444 optimizer.zero_grad() 445 loss.backward() 446 447 # Remove gradients parallel to decoder weights (Adam optimization trick from paper) 448 if self.normalize_decoder: 449 with torch.no_grad(): 450 # Project gradients to be orthogonal to decoder weights 451 decoder_w = self.decoder.weight 452 decoder_grad = self.decoder.weight.grad 453 454 # Compute projection 455 projection = (decoder_grad * decoder_w).sum( 456 dim=0, keepdim=True 457 ) * decoder_w 458 self.decoder.weight.grad = decoder_grad - projection 459 460 optimizer.step() 461 462 # Normalize decoder weights 463 self.normalize_decoder_weights() 464 465 # Track metrics 466 epoch_loss += loss.item() 467 epoch_l0 += (features > 0).sum(dim=1).float().mean().item() 468 469 n_steps_since_resample += 1 470 global_step += 1 471 472 # Resample dead neurons using sliding window 473 if ( 474 resample_dead_neurons 475 and n_steps_since_resample >= resample_interval 476 ): 477 if len(neuron_activity_window) > 0: 478 avg_activity = torch.stack(neuron_activity_window).mean(dim=0) 479 window_dead_mask = avg_activity < dead_threshold 480 else: 481 window_dead_mask = None 482 483 n_resampled = self.resample_dead_neurons( 484 batch, dead_threshold=dead_threshold, dead_mask=window_dead_mask 485 ) 486 if n_resampled > 0: 487 neuron_activity_window.clear() 488 n_steps_since_resample = 0 489 490 # Average metrics 491 avg_loss = epoch_loss / len(train_loader) 492 avg_l0 = epoch_l0 / len(train_loader) 493 494 self.training_losses.append(avg_loss) 495 self.training_l0_norms.append(avg_l0) 496 497 # Validation 498 with torch.no_grad(): 499 val_data_tensor = val_data.to(device) 500 val_reconstructed, val_features = self.forward(val_data_tensor) 501 val_loss = F.mse_loss(val_reconstructed, val_data_tensor).item() 502 val_l0 = (val_features > 0).sum(dim=1).float().mean().item() 503 504 # Log to wandb 505 if wandb_logger: 506 wandb_logger.log_metrics( 507 { 508 "epoch": epoch + 1, 509 "train_loss": avg_loss, 510 "train_l0_norm": avg_l0, 511 "val_loss": val_loss, 512 "val_l0_norm": val_l0, 513 "learning_rate": learning_rate, 514 }, 515 step=epoch + 1, 516 ) 517 518 if verbose: 519 if pbar: 520 pbar.update(1) 521 pbar.set_postfix( 522 { 523 "loss": f"{avg_loss:.6f}", 524 "val_loss": f"{val_loss:.6f}", 525 "L0": f"{avg_l0:.2f}", 526 "val_L0": f"{val_l0:.2f}", 527 } 528 ) 529 else: 530 logger.info( 531 f"Epoch {epoch + 1}/{num_epochs}: " 532 f"loss={avg_loss:.6f}, val_loss={val_loss:.6f}, " 533 f"L0={avg_l0:.2f}, val_L0={val_l0:.2f}" 534 ) 535 536 if pbar: 537 pbar.close() 538 539 # Finalize wandb and log summary metrics 540 if wandb_logger: 541 # Log final feature densities 542 densities = self.get_feature_density(activations.cpu().numpy()) 543 n_dead = (densities == 0).sum() 544 n_active = (densities > 0).sum() 545 546 wandb_logger.log_metrics( 547 { 548 "final_train_loss": self.training_losses[-1], 549 "final_val_loss": val_loss, 550 "final_l0_norm": self.training_l0_norms[-1], 551 "final_val_l0_norm": val_l0, 552 "n_dead_features": int(n_dead), 553 "n_active_features": int(n_active), 554 "feature_sparsity": float(n_active / len(densities)), 555 } 556 ) 557 558 # Log feature density histogram 559 wandb_logger.log_histogram(densities, "feature_density_histogram") 560 561 logger.info(f"wandb run URL: {wandb_logger.get_run_url()}") 562 563 # Finalize wandb (close the run) only if we created it 564 if _owns_wandb: 565 wandb_logger.finalize() 566 567 logger.info("Training complete!") 568 return self
Train the sparse autoencoder on MLP activations.
Arguments:
- activations: Training data of shape (n_samples, activation_dim)
- batch_size: Batch size for training
- num_epochs: Number of training epochs
- learning_rate: Learning rate for Adam optimizer
- validation_split: Fraction of data to use for validation
- resample_dead_neurons: Whether to resample dead neurons during training
- resample_interval: Steps between resampling checks
- dead_threshold: Activation threshold below which a neuron is considered dead
- window_size: Number of recent batches to track for dead neuron detection
- device: Device to train on (cuda/cpu). If None, auto-detect
- verbose: Whether to show progress bars
- wandb_config: Optional WandbConfig for experiment tracking
- wandb_enabled: If True and wandb_config is None, create default WandbConfig
Returns:
self (trained model)
570 def get_feature_density(self, activations: np.ndarray) -> np.ndarray: 571 """ 572 Compute the density (fraction of non-zero activations) for each feature. 573 574 Args: 575 activations: Data to compute density on 576 577 Returns: 578 Array of feature densities of shape (hidden_dim,) 579 """ 580 self.eval() 581 device = next(self.parameters()).device 582 with torch.no_grad(): 583 if isinstance(activations, np.ndarray): 584 activations = torch.from_numpy(activations).float().to(device) 585 else: 586 activations = activations.to(device) 587 588 features = self.encode(activations) 589 density = (features > 0).float().mean(dim=0).cpu().numpy() 590 591 return density
Compute the density (fraction of non-zero activations) for each feature.
Arguments:
- activations: Data to compute density on
Returns:
Array of feature densities of shape (hidden_dim,)
593 def get_top_activating_examples( 594 self, 595 activations: np.ndarray, 596 feature_idx: int, 597 k: int = 10, 598 ) -> Tuple[np.ndarray, np.ndarray]: 599 """ 600 Get the top k examples that activate a given feature the most. 601 602 Args: 603 activations: Data to search through 604 feature_idx: Index of the feature to analyze 605 k: Number of top examples to return 606 607 Returns: 608 Tuple of (top activations values, top indices) 609 """ 610 self.eval() 611 device = next(self.parameters()).device 612 with torch.no_grad(): 613 if isinstance(activations, np.ndarray): 614 activations = torch.from_numpy(activations).float().to(device) 615 else: 616 activations = activations.to(device) 617 618 features = self.encode(activations) 619 feature_activations = features[:, feature_idx].cpu().numpy() 620 621 top_k_indices = np.argsort(feature_activations)[-k:][::-1] 622 top_k_values = feature_activations[top_k_indices] 623 624 return top_k_values, top_k_indices
Get the top k examples that activate a given feature the most.
Arguments:
- activations: Data to search through
- feature_idx: Index of the feature to analyze
- k: Number of top examples to return
Returns:
Tuple of (top activations values, top indices)
626 def save(self, filepath: Union[str, Path]) -> None: 627 """Save model state, hyperparameters, and training history to disk. 628 629 Saves a dictionary containing the model architecture parameters, 630 state dict, and training loss/L0 history as a PyTorch ``.pt`` file. 631 The file can be loaded with ``SparseAutoencoder.load()``. 632 633 Args: 634 filepath: Path where the model file will be saved. Parent 635 directories are created automatically if they don't exist. 636 """ 637 filepath = Path(filepath) 638 filepath.parent.mkdir(parents=True, exist_ok=True) 639 640 state = { 641 "activation_dim": self.activation_dim, 642 "hidden_dim": self.hidden_dim, 643 "l1_coefficient": self.l1_coefficient, 644 "state_dict": self.state_dict(), 645 "training_losses": self.training_losses, 646 "training_l0_norms": self.training_l0_norms, 647 } 648 649 torch.save(state, filepath) 650 logger.info(f"Saved model to {filepath}")
Save model state, hyperparameters, and training history to disk.
Saves a dictionary containing the model architecture parameters,
state dict, and training loss/L0 history as a PyTorch .pt file.
The file can be loaded with SparseAutoencoder.load().
Arguments:
- filepath: Path where the model file will be saved. Parent directories are created automatically if they don't exist.
652 @classmethod 653 def load(cls, filepath: Union[str, Path]) -> "SparseAutoencoder": 654 """Load a saved SparseAutoencoder from disk. 655 656 Reconstructs the model from a ``.pt`` file saved by ``save()``, 657 restoring the architecture, learned weights, and training 658 history (losses and L0 norms). 659 660 Args: 661 filepath: Path to the saved ``.pt`` model file. 662 663 Returns: 664 A ``SparseAutoencoder`` instance with restored weights and 665 training metrics. 666 """ 667 filepath = Path(filepath) 668 state = torch.load(filepath, weights_only=False) 669 670 model = cls( 671 activation_dim=state["activation_dim"], 672 hidden_dim=state["hidden_dim"], 673 l1_coefficient=state["l1_coefficient"], 674 ) 675 model.load_state_dict(state["state_dict"]) 676 model.training_losses = state.get("training_losses", []) 677 model.training_l0_norms = state.get("training_l0_norms", []) 678 679 logger.info(f"Loaded model from {filepath}") 680 return model
Load a saved SparseAutoencoder from disk.
Reconstructs the model from a .pt file saved by save(),
restoring the architecture, learned weights, and training
history (losses and L0 norms).
Arguments:
- filepath: Path to the saved
.ptmodel file.
Returns:
A
SparseAutoencoderinstance with restored weights and training metrics.
41class FeatureVisualizer: 42 """ 43 Visualize sparse autoencoder features and activations. 44 45 This class provides methods to create various plots that help understand 46 the learned features, similar to the visualizations in the 47 "Towards Monosemanticity" paper. 48 49 Example: 50 ```python 51 visualizer = FeatureVisualizer(sae, activations, metadata) 52 visualizer.plot_feature_density() 53 visualizer.plot_top_features(n_features=10) 54 visualizer.plot_feature_examples(feature_idx=0) 55 ``` 56 """ 57 58 def __init__( 59 self, 60 sae: SparseAutoencoder, 61 activations: np.ndarray, 62 metadata: Optional[Dict[str, Any]] = None, 63 output_dir: Union[str, Path] = "./visualizations", 64 style: str = "whitegrid", 65 dpi: int = 150, 66 wandb_config: Optional[WandbConfig] = None, 67 log_to_wandb: bool = True, 68 ): 69 """ 70 Initialize the visualizer. 71 72 Args: 73 sae: Trained sparse autoencoder 74 activations: The MLP activations used to train the SAE 75 metadata: Optional metadata dictionary (e.g., from ActivationExtractor) 76 output_dir: Directory to save plots 77 style: Seaborn style to use 78 dpi: DPI for saved figures 79 wandb_config: Optional WandbConfig for logging visualizations 80 log_to_wandb: If True, log plots to wandb when wandb_config is provided 81 """ 82 self.sae = sae 83 self.activations = activations 84 self.metadata = metadata or {} 85 self.output_dir = Path(output_dir) 86 self.dpi = dpi 87 self.wandb_config = wandb_config 88 self.log_to_wandb = log_to_wandb 89 90 # Set style 91 sns.set_style(style) 92 sns.set_palette("husl") 93 94 # Create output directory 95 self.output_dir.mkdir(parents=True, exist_ok=True) 96 97 # Compute feature activations 98 self._compute_features() 99 100 def _log_figure_to_wandb(self, fig: plt.Figure, name: str) -> None: 101 """ 102 Log a matplotlib figure to wandb. 103 104 Args: 105 fig: The matplotlib figure 106 name: Name for the plot in wandb 107 """ 108 if self.wandb_config and self.log_to_wandb: 109 try: 110 import wandb 111 112 wandb.log({name: wandb.Image(fig)}) 113 logger.debug(f"Logged {name} to wandb") 114 except Exception as e: 115 logger.warning(f"Failed to log {name} to wandb: {e}") 116 117 def _compute_features(self) -> None: 118 """Compute sparse feature activations from the SAE. 119 120 Runs the encoder forward pass on the stored activations to 121 produce a matrix of feature activations. The result is stored 122 in ``self.features`` as a numpy array of shape 123 ``(n_samples, hidden_dim)`` and is used by all plotting methods. 124 """ 125 import torch 126 127 self.sae.eval() 128 device = next(self.sae.parameters()).device 129 with torch.no_grad(): 130 activations_tensor = torch.from_numpy(self.activations).float().to(device) 131 self.features = self.sae.encode(activations_tensor).cpu().numpy() 132 133 logger.info(f"Computed features with shape: {self.features.shape}") 134 135 def plot_feature_density( 136 self, 137 bins: int = 50, 138 log_scale: bool = True, 139 save_path: Optional[str] = None, 140 ) -> plt.Figure: 141 """ 142 Plot the distribution of feature densities. 143 144 Feature density is the fraction of examples on which a feature fires. 145 This histogram helps understand the sparsity distribution. 146 147 Args: 148 bins: Number of histogram bins 149 log_scale: Whether to use log scale on x-axis 150 save_path: Optional path to save the figure 151 152 Returns: 153 The matplotlib Figure object 154 """ 155 # Compute feature densities 156 densities = (self.features > 0).mean(axis=0) 157 158 fig, ax = plt.subplots(figsize=(10, 6)) 159 160 # Remove zero-density features (dead neurons) 161 active_densities = densities[densities > 0] 162 163 # Plot histogram on log scale 164 if log_scale: 165 bins_log = np.logspace(-8, 0, bins) 166 ax.hist(active_densities, bins=bins_log, edgecolor="black", alpha=0.7) 167 ax.set_xscale("log") 168 else: 169 ax.hist(active_densities, bins=bins, edgecolor="black", alpha=0.7) 170 171 ax.set_xlabel("Feature Density (fraction of examples)", fontsize=12) 172 ax.set_ylabel("Number of Features", fontsize=12) 173 ax.set_title("Distribution of Feature Densities", fontsize=14) 174 175 # Add statistics 176 n_dead = (densities == 0).sum() 177 n_active = len(active_densities) 178 179 stats_text = f"Dead features: {n_dead} ({n_dead / len(densities) * 100:.1f}%)\n" 180 stats_text += ( 181 f"Active features: {n_active} ({n_active / len(densities) * 100:.1f}%)\n" 182 ) 183 stats_text += f"Median density: {np.median(active_densities):.2e}" 184 185 ax.text( 186 0.95, 187 0.95, 188 stats_text, 189 transform=ax.transAxes, 190 verticalalignment="top", 191 horizontalalignment="right", 192 bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5), 193 ) 194 195 plt.tight_layout() 196 197 if save_path or self.output_dir: 198 path = save_path or self.output_dir / "feature_density.png" 199 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 200 logger.info(f"Saved feature density plot to {path}") 201 202 # Log to wandb 203 self._log_figure_to_wandb(fig, "feature_density") 204 205 return fig 206 207 def plot_activation_histogram( 208 self, 209 feature_idx: int, 210 bins: int = 100, 211 log_y: bool = True, 212 save_path: Optional[str] = None, 213 ) -> plt.Figure: 214 """ 215 Plot activation histogram for a specific feature. 216 217 Args: 218 feature_idx: Index of the feature to visualize 219 bins: Number of histogram bins 220 log_y: Whether to use log scale on y-axis 221 save_path: Optional path to save the figure 222 223 Returns: 224 The matplotlib Figure object 225 """ 226 feature_acts = self.features[:, feature_idx] 227 228 fig, ax = plt.subplots(figsize=(10, 6)) 229 230 # Plot histogram 231 counts, bin_edges, patches = ax.hist( 232 feature_acts[feature_acts > 0], # Only non-zero activations 233 bins=bins, 234 edgecolor="black", 235 alpha=0.7, 236 ) 237 238 if log_y: 239 ax.set_yscale("log") 240 241 ax.set_xlabel("Activation Value", fontsize=12) 242 ax.set_ylabel("Count (log scale)" if log_y else "Count", fontsize=12) 243 ax.set_title(f"Activation Histogram for Feature {feature_idx}", fontsize=14) 244 245 # Add statistics 246 density = (feature_acts > 0).mean() 247 max_act = feature_acts.max() 248 mean_act = feature_acts[feature_acts > 0].mean() if density > 0 else 0 249 250 stats_text = f"Density: {density:.2e}\n" 251 stats_text += f"Max activation: {max_act:.4f}\n" 252 stats_text += f"Mean (active): {mean_act:.4f}" 253 254 ax.text( 255 0.95, 256 0.95, 257 stats_text, 258 transform=ax.transAxes, 259 verticalalignment="top", 260 horizontalalignment="right", 261 bbox=dict(boxstyle="round", facecolor="lightblue", alpha=0.5), 262 ) 263 264 plt.tight_layout() 265 266 if save_path or self.output_dir: 267 path = ( 268 save_path 269 or self.output_dir / f"activation_histogram_feature_{feature_idx}.png" 270 ) 271 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 272 logger.info(f"Saved activation histogram to {path}") 273 274 return fig 275 276 def plot_training_curves( 277 self, 278 save_path: Optional[str] = None, 279 ) -> plt.Figure: 280 """ 281 Plot training loss and L0 norm curves. 282 283 Args: 284 save_path: Optional path to save the figure 285 286 Returns: 287 The matplotlib Figure object 288 """ 289 if not self.sae.training_losses: 290 logger.warning("No training data available") 291 return None 292 293 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) 294 295 epochs = range(1, len(self.sae.training_losses) + 1) 296 297 # Loss curve 298 ax1.plot(epochs, self.sae.training_losses, "b-", linewidth=2) 299 ax1.set_xlabel("Epoch", fontsize=12) 300 ax1.set_ylabel("Loss", fontsize=12) 301 ax1.set_title("Training Loss", fontsize=14) 302 ax1.grid(True, alpha=0.3) 303 304 # L0 norm curve 305 ax2.plot(epochs, self.sae.training_l0_norms, "r-", linewidth=2) 306 ax2.set_xlabel("Epoch", fontsize=12) 307 ax2.set_ylabel("L0 Norm (avg # active features)", fontsize=12) 308 ax2.set_title("Sparsity (L0 Norm)", fontsize=14) 309 ax2.grid(True, alpha=0.3) 310 311 plt.tight_layout() 312 313 if save_path or self.output_dir: 314 path = save_path or self.output_dir / "training_curves.png" 315 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 316 logger.info(f"Saved training curves to {path}") 317 318 return fig 319 320 def plot_top_features( 321 self, 322 n_features: int = 10, 323 by: str = "density", 324 save_path: Optional[str] = None, 325 ) -> plt.Figure: 326 """ 327 Plot top N features by various metrics. 328 329 Args: 330 n_features: Number of top features to show 331 by: Metric to sort by ("density", "max_activation", "mean_activation") 332 save_path: Optional path to save the figure 333 334 Returns: 335 The matplotlib Figure object 336 """ 337 fig, ax = plt.subplots(figsize=(12, 6)) 338 339 # Compute metric for each feature 340 if by == "density": 341 values = (self.features > 0).mean(axis=0) 342 ylabel = "Feature Density" 343 title = f"Top {n_features} Features by Density" 344 elif by == "max_activation": 345 values = self.features.max(axis=0) 346 ylabel = "Max Activation" 347 title = f"Top {n_features} Features by Max Activation" 348 elif by == "mean_activation": 349 values = self.features.mean(axis=0) 350 ylabel = "Mean Activation" 351 title = f"Top {n_features} Features by Mean Activation" 352 else: 353 raise ValueError(f"Unknown metric: {by}") 354 355 # Get top features 356 top_indices = np.argsort(values)[-n_features:][::-1] 357 top_values = values[top_indices] 358 359 # Plot bar chart 360 _ = ax.barh(range(n_features), top_values[::-1]) 361 ax.set_yticks(range(n_features)) 362 ax.set_yticklabels([f"Feature {i}" for i in top_indices[::-1]]) 363 ax.set_xlabel(ylabel, fontsize=12) 364 ax.set_title(title, fontsize=14) 365 ax.grid(True, axis="x", alpha=0.3) 366 367 plt.tight_layout() 368 369 if save_path or self.output_dir: 370 path = save_path or self.output_dir / f"top_features_{by}.png" 371 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 372 logger.info(f"Saved top features plot to {path}") 373 374 return fig 375 376 def plot_feature_examples( 377 self, 378 feature_idx: int, 379 k: int = 10, 380 show_text: bool = True, 381 max_text_length: int = 200, 382 save_path: Optional[str] = None, 383 ) -> plt.Figure: 384 """ 385 Plot top k activating examples for a specific feature. 386 387 Args: 388 feature_idx: Index of the feature to visualize 389 k: Number of examples to show 390 show_text: Whether to show the text examples 391 max_text_length: Maximum length of text to display 392 save_path: Optional path to save the figure 393 394 Returns: 395 The matplotlib Figure object 396 """ 397 # Get top activating examples 398 top_values, top_indices = self.sae.get_top_activating_examples( 399 self.activations, feature_idx, k 400 ) 401 402 fig, ax = plt.subplots(figsize=(12, k * 0.5)) 403 404 # Plot bar chart 405 _ = ax.barh(range(k), top_values[::-1]) 406 ax.set_yticks(range(k)) 407 ax.set_xlabel("Activation Value", fontsize=12) 408 ax.set_title( 409 f"Top {k} Activating Examples for Feature {feature_idx}", fontsize=14 410 ) 411 ax.grid(True, axis="x", alpha=0.3) 412 413 # Add text examples if available 414 if show_text and "samples_metadata" in self.metadata: 415 samples_metadata = self.metadata["samples_metadata"] 416 417 labels = [] 418 for i, idx in enumerate(top_indices[::-1]): 419 if idx < len(samples_metadata): 420 text = samples_metadata[idx].get("text", "") 421 # Clean up text 422 text = text.replace("\n", " ").strip() 423 if len(text) > max_text_length: 424 text = text[:max_text_length] + "..." 425 labels.append(f"{i + 1}. {text}") 426 else: 427 labels.append(f"{i + 1}. (No metadata)") 428 429 ax.set_yticklabels(labels, fontsize=9) 430 431 plt.tight_layout() 432 433 if save_path or self.output_dir: 434 path = save_path or self.output_dir / f"feature_{feature_idx}_examples.png" 435 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 436 logger.info(f"Saved feature examples to {path}") 437 438 return fig 439 440 def plot_decoder_weights( 441 self, 442 feature_indices: Optional[List[int]] = None, 443 n_features: int = 10, 444 save_path: Optional[str] = None, 445 ) -> plt.Figure: 446 """ 447 Visualize decoder weights for specific features. 448 449 This shows how each feature maps back to the MLP activation space. 450 451 Args: 452 feature_indices: Specific feature indices to plot 453 n_features: Number of features to plot (if feature_indices is None) 454 save_path: Optional path to save the figure 455 456 Returns: 457 The matplotlib Figure object 458 """ 459 import torch 460 461 if feature_indices is None: 462 # Get features by density 463 densities = (self.features > 0).mean(axis=0) 464 feature_indices = np.argsort(densities)[-n_features:][::-1] 465 466 n_features_plot = len(feature_indices) 467 fig, axes = plt.subplots(1, n_features_plot, figsize=(3 * n_features_plot, 4)) 468 469 if n_features_plot == 1: 470 axes = [axes] 471 472 with torch.no_grad(): 473 for i, feat_idx in enumerate(feature_indices): 474 decoder_weight = self.sae.decoder.weight[:, feat_idx].cpu().numpy() 475 476 axes[i].hist(decoder_weight, bins=50, edgecolor="black", alpha=0.7) 477 axes[i].set_title(f"Feature {feat_idx}", fontsize=12) 478 axes[i].set_xlabel("Weight Value", fontsize=10) 479 axes[i].set_ylabel("Count", fontsize=10) 480 axes[i].grid(True, alpha=0.3) 481 482 plt.suptitle("Decoder Weight Distributions", fontsize=14) 483 plt.tight_layout() 484 485 if save_path or self.output_dir: 486 path = save_path or self.output_dir / "decoder_weights.png" 487 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 488 logger.info(f"Saved decoder weights plot to {path}") 489 490 return fig 491 492 def create_feature_dashboard( 493 self, 494 feature_idx: int, 495 save_path: Optional[str] = None, 496 ) -> plt.Figure: 497 """ 498 Create a comprehensive dashboard for a single feature. 499 500 Includes activation histogram, top examples, and decoder weights. 501 502 Args: 503 feature_idx: Index of the feature to visualize 504 save_path: Optional path to save the figure 505 506 Returns: 507 The matplotlib Figure object 508 """ 509 fig = plt.figure(figsize=(16, 10)) 510 gs = GridSpec(2, 2, figure=fig, hspace=0.3, wspace=0.3) 511 512 # 1. Activation histogram 513 ax1 = fig.add_subplot(gs[0, 0]) 514 feature_acts = self.features[:, feature_idx] 515 ax1.hist(feature_acts[feature_acts > 0], bins=50, edgecolor="black", alpha=0.7) 516 ax1.set_xlabel("Activation Value", fontsize=11) 517 ax1.set_ylabel("Count", fontsize=11) 518 ax1.set_title(f"Activation Distribution (Feature {feature_idx})", fontsize=12) 519 ax1.set_yscale("log") 520 521 # 2. Top activating examples 522 ax2 = fig.add_subplot(gs[0, 1]) 523 top_values, top_indices = self.sae.get_top_activating_examples( 524 self.activations, feature_idx, 10 525 ) 526 ax2.barh(range(10), top_values[::-1]) 527 ax2.set_xlabel("Activation Value", fontsize=11) 528 ax2.set_title("Top 10 Activating Examples", fontsize=12) 529 ax2.set_yticks(range(10)) 530 ax2.grid(True, axis="x", alpha=0.3) 531 532 # 3. Decoder weights 533 ax3 = fig.add_subplot(gs[1, :]) 534 import torch 535 536 with torch.no_grad(): 537 decoder_weight = self.sae.decoder.weight[:, feature_idx].cpu().numpy() 538 539 ax3.hist(decoder_weight, bins=100, edgecolor="black", alpha=0.7) 540 ax3.set_xlabel("Weight Value", fontsize=11) 541 ax3.set_ylabel("Count", fontsize=11) 542 ax3.set_title("Decoder Weight Distribution", fontsize=12) 543 ax3.grid(True, alpha=0.3) 544 545 # Add statistics box 546 density = (feature_acts > 0).mean() 547 max_act = feature_acts.max() 548 549 stats_text = f"Feature {feature_idx} Statistics:\n" 550 stats_text += f"Density: {density:.2e}\n" 551 stats_text += f"Max Activation: {max_act:.4f}\n" 552 stats_text += f"Decoder Norm: {np.linalg.norm(decoder_weight):.4f}" 553 554 fig.text( 555 0.5, 556 0.02, 557 stats_text, 558 ha="center", 559 fontsize=12, 560 bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5), 561 ) 562 563 plt.suptitle(f"Feature {feature_idx} Dashboard", fontsize=16) 564 565 if save_path or self.output_dir: 566 path = save_path or self.output_dir / f"feature_{feature_idx}_dashboard.png" 567 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 568 logger.info(f"Saved feature dashboard to {path}") 569 570 return fig 571 572 def save_all(self, n_features: int = 10) -> None: 573 """ 574 Generate and save all standard visualizations. 575 576 Args: 577 n_features: Number of features to include in detailed plots 578 """ 579 logger.info("Generating all visualizations...") 580 581 self.plot_feature_density() 582 self.plot_training_curves() 583 584 for metric in ["density", "max_activation"]: 585 self.plot_top_features(n_features=n_features, by=metric) 586 587 # Create dashboards for top features by density 588 densities = (self.features > 0).mean(axis=0) 589 top_features = np.argsort(densities)[-n_features:] 590 591 for feat_idx in top_features: 592 self.create_feature_dashboard(feat_idx) 593 594 logger.info(f"All visualizations saved to {self.output_dir}")
Visualize sparse autoencoder features and activations.
This class provides methods to create various plots that help understand the learned features, similar to the visualizations in the "Towards Monosemanticity" paper.
Example:
visualizer = FeatureVisualizer(sae, activations, metadata) visualizer.plot_feature_density() visualizer.plot_top_features(n_features=10) visualizer.plot_feature_examples(feature_idx=0)
58 def __init__( 59 self, 60 sae: SparseAutoencoder, 61 activations: np.ndarray, 62 metadata: Optional[Dict[str, Any]] = None, 63 output_dir: Union[str, Path] = "./visualizations", 64 style: str = "whitegrid", 65 dpi: int = 150, 66 wandb_config: Optional[WandbConfig] = None, 67 log_to_wandb: bool = True, 68 ): 69 """ 70 Initialize the visualizer. 71 72 Args: 73 sae: Trained sparse autoencoder 74 activations: The MLP activations used to train the SAE 75 metadata: Optional metadata dictionary (e.g., from ActivationExtractor) 76 output_dir: Directory to save plots 77 style: Seaborn style to use 78 dpi: DPI for saved figures 79 wandb_config: Optional WandbConfig for logging visualizations 80 log_to_wandb: If True, log plots to wandb when wandb_config is provided 81 """ 82 self.sae = sae 83 self.activations = activations 84 self.metadata = metadata or {} 85 self.output_dir = Path(output_dir) 86 self.dpi = dpi 87 self.wandb_config = wandb_config 88 self.log_to_wandb = log_to_wandb 89 90 # Set style 91 sns.set_style(style) 92 sns.set_palette("husl") 93 94 # Create output directory 95 self.output_dir.mkdir(parents=True, exist_ok=True) 96 97 # Compute feature activations 98 self._compute_features()
Initialize the visualizer.
Arguments:
- sae: Trained sparse autoencoder
- activations: The MLP activations used to train the SAE
- metadata: Optional metadata dictionary (e.g., from ActivationExtractor)
- output_dir: Directory to save plots
- style: Seaborn style to use
- dpi: DPI for saved figures
- wandb_config: Optional WandbConfig for logging visualizations
- log_to_wandb: If True, log plots to wandb when wandb_config is provided
135 def plot_feature_density( 136 self, 137 bins: int = 50, 138 log_scale: bool = True, 139 save_path: Optional[str] = None, 140 ) -> plt.Figure: 141 """ 142 Plot the distribution of feature densities. 143 144 Feature density is the fraction of examples on which a feature fires. 145 This histogram helps understand the sparsity distribution. 146 147 Args: 148 bins: Number of histogram bins 149 log_scale: Whether to use log scale on x-axis 150 save_path: Optional path to save the figure 151 152 Returns: 153 The matplotlib Figure object 154 """ 155 # Compute feature densities 156 densities = (self.features > 0).mean(axis=0) 157 158 fig, ax = plt.subplots(figsize=(10, 6)) 159 160 # Remove zero-density features (dead neurons) 161 active_densities = densities[densities > 0] 162 163 # Plot histogram on log scale 164 if log_scale: 165 bins_log = np.logspace(-8, 0, bins) 166 ax.hist(active_densities, bins=bins_log, edgecolor="black", alpha=0.7) 167 ax.set_xscale("log") 168 else: 169 ax.hist(active_densities, bins=bins, edgecolor="black", alpha=0.7) 170 171 ax.set_xlabel("Feature Density (fraction of examples)", fontsize=12) 172 ax.set_ylabel("Number of Features", fontsize=12) 173 ax.set_title("Distribution of Feature Densities", fontsize=14) 174 175 # Add statistics 176 n_dead = (densities == 0).sum() 177 n_active = len(active_densities) 178 179 stats_text = f"Dead features: {n_dead} ({n_dead / len(densities) * 100:.1f}%)\n" 180 stats_text += ( 181 f"Active features: {n_active} ({n_active / len(densities) * 100:.1f}%)\n" 182 ) 183 stats_text += f"Median density: {np.median(active_densities):.2e}" 184 185 ax.text( 186 0.95, 187 0.95, 188 stats_text, 189 transform=ax.transAxes, 190 verticalalignment="top", 191 horizontalalignment="right", 192 bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5), 193 ) 194 195 plt.tight_layout() 196 197 if save_path or self.output_dir: 198 path = save_path or self.output_dir / "feature_density.png" 199 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 200 logger.info(f"Saved feature density plot to {path}") 201 202 # Log to wandb 203 self._log_figure_to_wandb(fig, "feature_density") 204 205 return fig
Plot the distribution of feature densities.
Feature density is the fraction of examples on which a feature fires. This histogram helps understand the sparsity distribution.
Arguments:
- bins: Number of histogram bins
- log_scale: Whether to use log scale on x-axis
- save_path: Optional path to save the figure
Returns:
The matplotlib Figure object
207 def plot_activation_histogram( 208 self, 209 feature_idx: int, 210 bins: int = 100, 211 log_y: bool = True, 212 save_path: Optional[str] = None, 213 ) -> plt.Figure: 214 """ 215 Plot activation histogram for a specific feature. 216 217 Args: 218 feature_idx: Index of the feature to visualize 219 bins: Number of histogram bins 220 log_y: Whether to use log scale on y-axis 221 save_path: Optional path to save the figure 222 223 Returns: 224 The matplotlib Figure object 225 """ 226 feature_acts = self.features[:, feature_idx] 227 228 fig, ax = plt.subplots(figsize=(10, 6)) 229 230 # Plot histogram 231 counts, bin_edges, patches = ax.hist( 232 feature_acts[feature_acts > 0], # Only non-zero activations 233 bins=bins, 234 edgecolor="black", 235 alpha=0.7, 236 ) 237 238 if log_y: 239 ax.set_yscale("log") 240 241 ax.set_xlabel("Activation Value", fontsize=12) 242 ax.set_ylabel("Count (log scale)" if log_y else "Count", fontsize=12) 243 ax.set_title(f"Activation Histogram for Feature {feature_idx}", fontsize=14) 244 245 # Add statistics 246 density = (feature_acts > 0).mean() 247 max_act = feature_acts.max() 248 mean_act = feature_acts[feature_acts > 0].mean() if density > 0 else 0 249 250 stats_text = f"Density: {density:.2e}\n" 251 stats_text += f"Max activation: {max_act:.4f}\n" 252 stats_text += f"Mean (active): {mean_act:.4f}" 253 254 ax.text( 255 0.95, 256 0.95, 257 stats_text, 258 transform=ax.transAxes, 259 verticalalignment="top", 260 horizontalalignment="right", 261 bbox=dict(boxstyle="round", facecolor="lightblue", alpha=0.5), 262 ) 263 264 plt.tight_layout() 265 266 if save_path or self.output_dir: 267 path = ( 268 save_path 269 or self.output_dir / f"activation_histogram_feature_{feature_idx}.png" 270 ) 271 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 272 logger.info(f"Saved activation histogram to {path}") 273 274 return fig
Plot activation histogram for a specific feature.
Arguments:
- feature_idx: Index of the feature to visualize
- bins: Number of histogram bins
- log_y: Whether to use log scale on y-axis
- save_path: Optional path to save the figure
Returns:
The matplotlib Figure object
276 def plot_training_curves( 277 self, 278 save_path: Optional[str] = None, 279 ) -> plt.Figure: 280 """ 281 Plot training loss and L0 norm curves. 282 283 Args: 284 save_path: Optional path to save the figure 285 286 Returns: 287 The matplotlib Figure object 288 """ 289 if not self.sae.training_losses: 290 logger.warning("No training data available") 291 return None 292 293 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) 294 295 epochs = range(1, len(self.sae.training_losses) + 1) 296 297 # Loss curve 298 ax1.plot(epochs, self.sae.training_losses, "b-", linewidth=2) 299 ax1.set_xlabel("Epoch", fontsize=12) 300 ax1.set_ylabel("Loss", fontsize=12) 301 ax1.set_title("Training Loss", fontsize=14) 302 ax1.grid(True, alpha=0.3) 303 304 # L0 norm curve 305 ax2.plot(epochs, self.sae.training_l0_norms, "r-", linewidth=2) 306 ax2.set_xlabel("Epoch", fontsize=12) 307 ax2.set_ylabel("L0 Norm (avg # active features)", fontsize=12) 308 ax2.set_title("Sparsity (L0 Norm)", fontsize=14) 309 ax2.grid(True, alpha=0.3) 310 311 plt.tight_layout() 312 313 if save_path or self.output_dir: 314 path = save_path or self.output_dir / "training_curves.png" 315 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 316 logger.info(f"Saved training curves to {path}") 317 318 return fig
Plot training loss and L0 norm curves.
Arguments:
- save_path: Optional path to save the figure
Returns:
The matplotlib Figure object
320 def plot_top_features( 321 self, 322 n_features: int = 10, 323 by: str = "density", 324 save_path: Optional[str] = None, 325 ) -> plt.Figure: 326 """ 327 Plot top N features by various metrics. 328 329 Args: 330 n_features: Number of top features to show 331 by: Metric to sort by ("density", "max_activation", "mean_activation") 332 save_path: Optional path to save the figure 333 334 Returns: 335 The matplotlib Figure object 336 """ 337 fig, ax = plt.subplots(figsize=(12, 6)) 338 339 # Compute metric for each feature 340 if by == "density": 341 values = (self.features > 0).mean(axis=0) 342 ylabel = "Feature Density" 343 title = f"Top {n_features} Features by Density" 344 elif by == "max_activation": 345 values = self.features.max(axis=0) 346 ylabel = "Max Activation" 347 title = f"Top {n_features} Features by Max Activation" 348 elif by == "mean_activation": 349 values = self.features.mean(axis=0) 350 ylabel = "Mean Activation" 351 title = f"Top {n_features} Features by Mean Activation" 352 else: 353 raise ValueError(f"Unknown metric: {by}") 354 355 # Get top features 356 top_indices = np.argsort(values)[-n_features:][::-1] 357 top_values = values[top_indices] 358 359 # Plot bar chart 360 _ = ax.barh(range(n_features), top_values[::-1]) 361 ax.set_yticks(range(n_features)) 362 ax.set_yticklabels([f"Feature {i}" for i in top_indices[::-1]]) 363 ax.set_xlabel(ylabel, fontsize=12) 364 ax.set_title(title, fontsize=14) 365 ax.grid(True, axis="x", alpha=0.3) 366 367 plt.tight_layout() 368 369 if save_path or self.output_dir: 370 path = save_path or self.output_dir / f"top_features_{by}.png" 371 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 372 logger.info(f"Saved top features plot to {path}") 373 374 return fig
Plot top N features by various metrics.
Arguments:
- n_features: Number of top features to show
- by: Metric to sort by ("density", "max_activation", "mean_activation")
- save_path: Optional path to save the figure
Returns:
The matplotlib Figure object
376 def plot_feature_examples( 377 self, 378 feature_idx: int, 379 k: int = 10, 380 show_text: bool = True, 381 max_text_length: int = 200, 382 save_path: Optional[str] = None, 383 ) -> plt.Figure: 384 """ 385 Plot top k activating examples for a specific feature. 386 387 Args: 388 feature_idx: Index of the feature to visualize 389 k: Number of examples to show 390 show_text: Whether to show the text examples 391 max_text_length: Maximum length of text to display 392 save_path: Optional path to save the figure 393 394 Returns: 395 The matplotlib Figure object 396 """ 397 # Get top activating examples 398 top_values, top_indices = self.sae.get_top_activating_examples( 399 self.activations, feature_idx, k 400 ) 401 402 fig, ax = plt.subplots(figsize=(12, k * 0.5)) 403 404 # Plot bar chart 405 _ = ax.barh(range(k), top_values[::-1]) 406 ax.set_yticks(range(k)) 407 ax.set_xlabel("Activation Value", fontsize=12) 408 ax.set_title( 409 f"Top {k} Activating Examples for Feature {feature_idx}", fontsize=14 410 ) 411 ax.grid(True, axis="x", alpha=0.3) 412 413 # Add text examples if available 414 if show_text and "samples_metadata" in self.metadata: 415 samples_metadata = self.metadata["samples_metadata"] 416 417 labels = [] 418 for i, idx in enumerate(top_indices[::-1]): 419 if idx < len(samples_metadata): 420 text = samples_metadata[idx].get("text", "") 421 # Clean up text 422 text = text.replace("\n", " ").strip() 423 if len(text) > max_text_length: 424 text = text[:max_text_length] + "..." 425 labels.append(f"{i + 1}. {text}") 426 else: 427 labels.append(f"{i + 1}. (No metadata)") 428 429 ax.set_yticklabels(labels, fontsize=9) 430 431 plt.tight_layout() 432 433 if save_path or self.output_dir: 434 path = save_path or self.output_dir / f"feature_{feature_idx}_examples.png" 435 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 436 logger.info(f"Saved feature examples to {path}") 437 438 return fig
Plot top k activating examples for a specific feature.
Arguments:
- feature_idx: Index of the feature to visualize
- k: Number of examples to show
- show_text: Whether to show the text examples
- max_text_length: Maximum length of text to display
- save_path: Optional path to save the figure
Returns:
The matplotlib Figure object
440 def plot_decoder_weights( 441 self, 442 feature_indices: Optional[List[int]] = None, 443 n_features: int = 10, 444 save_path: Optional[str] = None, 445 ) -> plt.Figure: 446 """ 447 Visualize decoder weights for specific features. 448 449 This shows how each feature maps back to the MLP activation space. 450 451 Args: 452 feature_indices: Specific feature indices to plot 453 n_features: Number of features to plot (if feature_indices is None) 454 save_path: Optional path to save the figure 455 456 Returns: 457 The matplotlib Figure object 458 """ 459 import torch 460 461 if feature_indices is None: 462 # Get features by density 463 densities = (self.features > 0).mean(axis=0) 464 feature_indices = np.argsort(densities)[-n_features:][::-1] 465 466 n_features_plot = len(feature_indices) 467 fig, axes = plt.subplots(1, n_features_plot, figsize=(3 * n_features_plot, 4)) 468 469 if n_features_plot == 1: 470 axes = [axes] 471 472 with torch.no_grad(): 473 for i, feat_idx in enumerate(feature_indices): 474 decoder_weight = self.sae.decoder.weight[:, feat_idx].cpu().numpy() 475 476 axes[i].hist(decoder_weight, bins=50, edgecolor="black", alpha=0.7) 477 axes[i].set_title(f"Feature {feat_idx}", fontsize=12) 478 axes[i].set_xlabel("Weight Value", fontsize=10) 479 axes[i].set_ylabel("Count", fontsize=10) 480 axes[i].grid(True, alpha=0.3) 481 482 plt.suptitle("Decoder Weight Distributions", fontsize=14) 483 plt.tight_layout() 484 485 if save_path or self.output_dir: 486 path = save_path or self.output_dir / "decoder_weights.png" 487 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 488 logger.info(f"Saved decoder weights plot to {path}") 489 490 return fig
Visualize decoder weights for specific features.
This shows how each feature maps back to the MLP activation space.
Arguments:
- feature_indices: Specific feature indices to plot
- n_features: Number of features to plot (if feature_indices is None)
- save_path: Optional path to save the figure
Returns:
The matplotlib Figure object
492 def create_feature_dashboard( 493 self, 494 feature_idx: int, 495 save_path: Optional[str] = None, 496 ) -> plt.Figure: 497 """ 498 Create a comprehensive dashboard for a single feature. 499 500 Includes activation histogram, top examples, and decoder weights. 501 502 Args: 503 feature_idx: Index of the feature to visualize 504 save_path: Optional path to save the figure 505 506 Returns: 507 The matplotlib Figure object 508 """ 509 fig = plt.figure(figsize=(16, 10)) 510 gs = GridSpec(2, 2, figure=fig, hspace=0.3, wspace=0.3) 511 512 # 1. Activation histogram 513 ax1 = fig.add_subplot(gs[0, 0]) 514 feature_acts = self.features[:, feature_idx] 515 ax1.hist(feature_acts[feature_acts > 0], bins=50, edgecolor="black", alpha=0.7) 516 ax1.set_xlabel("Activation Value", fontsize=11) 517 ax1.set_ylabel("Count", fontsize=11) 518 ax1.set_title(f"Activation Distribution (Feature {feature_idx})", fontsize=12) 519 ax1.set_yscale("log") 520 521 # 2. Top activating examples 522 ax2 = fig.add_subplot(gs[0, 1]) 523 top_values, top_indices = self.sae.get_top_activating_examples( 524 self.activations, feature_idx, 10 525 ) 526 ax2.barh(range(10), top_values[::-1]) 527 ax2.set_xlabel("Activation Value", fontsize=11) 528 ax2.set_title("Top 10 Activating Examples", fontsize=12) 529 ax2.set_yticks(range(10)) 530 ax2.grid(True, axis="x", alpha=0.3) 531 532 # 3. Decoder weights 533 ax3 = fig.add_subplot(gs[1, :]) 534 import torch 535 536 with torch.no_grad(): 537 decoder_weight = self.sae.decoder.weight[:, feature_idx].cpu().numpy() 538 539 ax3.hist(decoder_weight, bins=100, edgecolor="black", alpha=0.7) 540 ax3.set_xlabel("Weight Value", fontsize=11) 541 ax3.set_ylabel("Count", fontsize=11) 542 ax3.set_title("Decoder Weight Distribution", fontsize=12) 543 ax3.grid(True, alpha=0.3) 544 545 # Add statistics box 546 density = (feature_acts > 0).mean() 547 max_act = feature_acts.max() 548 549 stats_text = f"Feature {feature_idx} Statistics:\n" 550 stats_text += f"Density: {density:.2e}\n" 551 stats_text += f"Max Activation: {max_act:.4f}\n" 552 stats_text += f"Decoder Norm: {np.linalg.norm(decoder_weight):.4f}" 553 554 fig.text( 555 0.5, 556 0.02, 557 stats_text, 558 ha="center", 559 fontsize=12, 560 bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5), 561 ) 562 563 plt.suptitle(f"Feature {feature_idx} Dashboard", fontsize=16) 564 565 if save_path or self.output_dir: 566 path = save_path or self.output_dir / f"feature_{feature_idx}_dashboard.png" 567 plt.savefig(path, dpi=self.dpi, bbox_inches="tight") 568 logger.info(f"Saved feature dashboard to {path}") 569 570 return fig
Create a comprehensive dashboard for a single feature.
Includes activation histogram, top examples, and decoder weights.
Arguments:
- feature_idx: Index of the feature to visualize
- save_path: Optional path to save the figure
Returns:
The matplotlib Figure object
572 def save_all(self, n_features: int = 10) -> None: 573 """ 574 Generate and save all standard visualizations. 575 576 Args: 577 n_features: Number of features to include in detailed plots 578 """ 579 logger.info("Generating all visualizations...") 580 581 self.plot_feature_density() 582 self.plot_training_curves() 583 584 for metric in ["density", "max_activation"]: 585 self.plot_top_features(n_features=n_features, by=metric) 586 587 # Create dashboards for top features by density 588 densities = (self.features > 0).mean(axis=0) 589 top_features = np.argsort(densities)[-n_features:] 590 591 for feat_idx in top_features: 592 self.create_feature_dashboard(feat_idx) 593 594 logger.info(f"All visualizations saved to {self.output_dir}")
Generate and save all standard visualizations.
Arguments:
- n_features: Number of features to include in detailed plots
394class SAESteering: 395 """ 396 Steer language model generation using trained SAE features. 397 398 Wraps a HuggingFace language model (via nnsight) and applies SAE 399 feature interventions during generation. At each generation step, 400 a forward hook on the target MLP module adds a scaled decoder 401 weight vector, biasing the residual stream toward the chosen 402 feature direction. 403 404 The HuggingFace auth token is read automatically from 405 ``HUGGINGFACE_HUB_TOKEN`` (via :func:`~drrik.settings.get_settings`) 406 when the *token* parameter is not explicitly provided. 407 408 Attributes: 409 source: The steering source this instance was built from — a 410 :class:`~drrik.autoencoder.SparseAutoencoder` or a 411 :class:`SteeringVectors`. 412 sae: The trained :class:`~drrik.autoencoder.SparseAutoencoder` 413 providing feature directions, or ``None`` when built from 414 :class:`SteeringVectors`. 415 vectors: The :class:`SteeringVectors` pre-extracted components, 416 or ``None`` when built from a ``SparseAutoencoder``. 417 model: The nnsight :class:`~nnsight.LanguageModel` wrapper. 418 tokenizer: The HuggingFace tokenizer for the model. 419 target_layer: The layer index where interventions are applied 420 (``None`` for :class:`SteeringVectors`; layers come from 421 the components). 422 hook_path: ``"mlp"`` for SAE sources; the source's 423 ``hook_path`` for :class:`SteeringVectors`. 424 device: The device the steering directions live on. 425 426 Example: 427 ```python 428 sae = SparseAutoencoder.load("sae_model.pt") 429 steering = SAESteering(source=sae, model_name="google/gemma-2b", layer=5) 430 431 # Steered generation 432 result = steering.generate( 433 "The sky is", 434 feature_idx=128, 435 strength=2.5, 436 max_new_tokens=50, 437 ) 438 439 # Baseline (no steering) 440 baseline = steering.generate("The sky is", max_new_tokens=50) 441 442 # Multi-feature steering 443 result = steering.generate( 444 "The sky is", 445 feature_indices=[10, 128], 446 strengths=[1.0, 2.5], 447 ) 448 ``` 449 450 Or from pre-extracted :class:`SteeringVectors`: 451 452 ```python 453 vectors = SteeringVectors.from_hf_dataset("user/dataset") 454 steering = SAESteering(source=vectors, model_name="google/gemma-2b") 455 ``` 456 """ 457 458 def __init__( 459 self, 460 source: Union[SparseAutoencoder, SteeringVectors], 461 model_name: str, 462 layer: Optional[int] = None, 463 revision: str = "main", 464 torch_dtype: str = "float16", 465 device_map: str = "auto", 466 trust_remote_code: bool = True, 467 token: Optional[str] = None, 468 ): 469 """ 470 Initialize the SAE steering controller. 471 472 Args: 473 source: Steering direction source — either a trained 474 :class:`~drrik.autoencoder.SparseAutoencoder` (then 475 ``layer`` is required) or a 476 :class:`SteeringVectors` of pre-extracted components 477 (then ``layer`` is ignored; layers come from the 478 components). 479 model_name: HuggingFace model identifier for generation. 480 layer: Transformer layer index for a ``SparseAutoencoder`` 481 source. Ignored for ``SteeringVectors``. 482 revision: Model revision to load. 483 torch_dtype: Weight dtype for model loading. 484 device_map: Device mapping strategy. 485 trust_remote_code: Whether to trust remote code from the repo. 486 token: HuggingFace token for gated models. Falls back to 487 ``HUGGINGFACE_HUB_TOKEN`` from ``settings.yml`` when ``None``. 488 489 Raises: 490 TypeError: If ``source`` is neither type. 491 ValueError: If ``source`` is a ``SparseAutoencoder`` and 492 ``layer`` is ``None``. 493 """ 494 if isinstance(source, SparseAutoencoder): 495 if layer is None: 496 raise ValueError("layer is required when source is a SparseAutoencoder") 497 self.sae: Optional[SparseAutoencoder] = source 498 self.vectors: Optional[SteeringVectors] = None 499 self.target_layer: Optional[int] = layer 500 self.hook_path = "mlp" 501 self.device = next(source.parameters()).device 502 elif isinstance(source, SteeringVectors): 503 self.sae = None 504 self.vectors = source 505 self.target_layer = None 506 self.hook_path = source.hook_path 507 self.device = source.components[0].vector.device 508 else: 509 raise TypeError( 510 "source must be a SparseAutoencoder or SteeringVectors, " 511 f"got {type(source).__name__}" 512 ) 513 self.source = source 514 515 if token is None: 516 settings = get_settings() 517 token = settings.huggingface_hub_token 518 519 if layer is not None: 520 logger.info(f"Loading model '{model_name}' for steering at layer {layer}") 521 else: 522 layers = sorted({c.layer for c in source.components}) 523 logger.info( 524 f"Loading model '{model_name}' for steering at layer(s) {layers}" 525 ) 526 527 dtype_map = { 528 "float16": torch.float16, 529 "bfloat16": torch.bfloat16, 530 "float32": torch.float32, 531 } 532 load_dtype = dtype_map.get(torch_dtype, torch.float16) 533 534 model_kwargs = { 535 "revision": revision, 536 "torch_dtype": load_dtype, 537 "device_map": device_map, 538 "trust_remote_code": trust_remote_code, 539 } 540 if token: 541 model_kwargs["token"] = token 542 543 self.model = LanguageModel(model_name, **model_kwargs) 544 # nnsight keeps the model on meta until dispatched; generation calls 545 # the underlying module directly (no tracing), so dispatch here. 546 if hasattr(self.model, "dispatch"): 547 self.model.dispatch() 548 self.tokenizer = AutoTokenizer.from_pretrained( 549 model_name, 550 revision=revision, 551 trust_remote_code=trust_remote_code, 552 token=token, 553 ) 554 555 if self.tokenizer.pad_token is None: 556 self.tokenizer.pad_token = self.tokenizer.eos_token 557 558 logger.info(f"Model loaded on {self.model.device}") 559 560 def _tokenize(self, text: str) -> tuple[torch.Tensor, Optional[torch.Tensor]]: 561 """Tokenize text and move tensors to the model device. 562 563 Centralises tokenization so that :meth:`generate` and 564 :meth:`_generate_baseline` don't duplicate this work. 565 566 Args: 567 text: Raw prompt string. 568 569 Returns: 570 A tuple of ``(input_ids, attention_mask)`` tensors on 571 the model's device. ``attention_mask`` may be ``None`` 572 if the tokenizer does not produce one. 573 """ 574 inputs = self.tokenizer( 575 text, return_tensors="pt", padding=True, truncation=True 576 ) 577 input_ids = inputs["input_ids"].to(self.model.device) 578 attention_mask = inputs.get("attention_mask", None) 579 if attention_mask is not None: 580 attention_mask = attention_mask.to(self.model.device) 581 return input_ids, attention_mask 582 583 def _build_prompt_ids( 584 self, text: str, system_prompt: Optional[str] 585 ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: 586 """Tokenize a prompt, optionally through the chat template. 587 588 Args: 589 text: User prompt text. 590 system_prompt: When set, the prompt is rendered as a 591 system+user chat via ``apply_chat_template`` (required 592 for instruct models). 593 594 Returns: 595 ``(input_ids, attention_mask)`` on the model device. 596 """ 597 if system_prompt is not None: 598 messages = [ 599 {"role": "system", "content": system_prompt}, 600 {"role": "user", "content": text}, 601 ] 602 encoded = self.tokenizer.apply_chat_template( 603 messages, return_tensors="pt", add_generation_prompt=True 604 ) 605 # transformers>=5 returns a BatchEncoding (mapping); older 606 # versions return a tensor directly. 607 input_ids = ( 608 encoded["input_ids"] 609 if not isinstance(encoded, torch.Tensor) 610 else encoded 611 ) 612 return input_ids.to(self.model.device), None 613 return self._tokenize(text) 614 615 def _generate_with_hooks( 616 self, 617 input_ids: torch.Tensor, 618 attention_mask: Optional[torch.Tensor], 619 layer_components: Dict[int, List[Tuple[float, torch.Tensor]]], 620 max_new_tokens: int, 621 temperature: float, 622 top_p: float, 623 repetition_penalty: float, 624 clamp: bool, 625 seed: Optional[int], 626 ) -> str: 627 """ 628 Generate text with forward-hook steering on one or more layers. 629 630 Registers one :func:`make_steering_hook` per layer in 631 *layer_components*; each hook injects its ``(strength, vector)`` 632 pairs at every forward pass. All hooks are removed in a 633 ``finally`` block to guarantee cleanup even on error. 634 635 Generation is performed token-by-token using the underlying 636 HuggingFace model (``self.model._module``) with top-p 637 (nucleus) sampling. 638 639 Args: 640 input_ids: Tokenized input tensor of shape ``(1, seq_len)``. 641 attention_mask: Optional attention mask tensor. 642 layer_components: Map of layer index to ``(strength, vector)`` 643 pairs injected at that layer. 644 max_new_tokens: Maximum tokens to generate. 645 temperature: Sampling temperature. 646 top_p: Nucleus sampling threshold. 647 repetition_penalty: CTRL-style repetition penalty (1.0 off). 648 clamp: Remove existing projection before adding (see 649 :func:`make_steering_hook`). 650 seed: Optional RNG seed for reproducible sampling. 651 652 Returns: 653 The decoded text string (special tokens stripped). 654 """ 655 generated_tokens = input_ids.clone() 656 prompt_len = input_ids.shape[-1] 657 device = generated_tokens.device 658 hook_handles = [] 659 # ponytail: device-bound generator for reproducibility; torch.manual_seed 660 # (global) if per-device generators ever misbehave on MPS 661 generator = ( 662 torch.Generator(device=device).manual_seed(seed) 663 if seed is not None 664 else None 665 ) 666 667 try: 668 for layer, comps in layer_components.items(): 669 if self.hook_path == "layer": 670 path = f"model.layers[{layer}]" 671 else: 672 path = f"model.layers[{layer}].mlp" 673 target_module = resolve_module_path(self.model, path) 674 hook_handles.append( 675 target_module.register_forward_hook( 676 make_steering_hook(comps, clamp) 677 ) 678 ) 679 680 base_model = self.model._module 681 682 for _ in tqdm(range(max_new_tokens), desc="Steering generation"): 683 if generated_tokens.shape[-1] >= self.tokenizer.model_max_length: 684 break 685 686 outputs = base_model( 687 input_ids=generated_tokens, 688 attention_mask=attention_mask, 689 use_cache=True, 690 ) 691 692 logits = outputs.logits[:, -1, :] 693 next_token = _sample_next_token( 694 logits, 695 temperature, 696 top_p, 697 repetition_penalty=repetition_penalty, 698 generated_tokens=generated_tokens, 699 generator=generator, 700 ) 701 702 if next_token.item() == self.tokenizer.eos_token_id: 703 break 704 705 generated_tokens = torch.cat([generated_tokens, next_token], dim=-1) 706 if attention_mask is not None: 707 attention_mask = torch.cat( 708 [attention_mask, torch.ones(1, 1, device=device)], dim=-1 709 ) 710 finally: 711 for handle in hook_handles: 712 handle.remove() 713 714 return self.tokenizer.decode( 715 generated_tokens[0][prompt_len:], skip_special_tokens=True 716 ) 717 718 def generate( 719 self, 720 text: str, 721 feature_idx: Optional[int] = None, 722 strength: float = 1.0, 723 max_new_tokens: int = 50, 724 temperature: float = 0.8, 725 top_p: float = 0.9, 726 feature_indices: Optional[List[int]] = None, 727 strengths: Optional[List[float]] = None, 728 clamp: bool = False, 729 repetition_penalty: float = 1.0, 730 system_prompt: Optional[str] = None, 731 strength_scale: float = 1.0, 732 seed: Optional[int] = None, 733 ) -> str: 734 """ 735 Generate text with optional SAE feature steering. 736 737 This is the main entry point for generation. 738 739 - **No features** → calls :meth:`_generate_baseline` (plain 740 autoregressive generation). 741 - **Single feature** → pass ``feature_idx`` and ``strength``. 742 - **Multiple features** → pass ``feature_indices`` and 743 ``strengths`` as equal-length lists; each decoder direction 744 is injected as its own component. 745 - **SteeringVectors source** → all pre-extracted components 746 are injected at their layers, scaled by ``strength_scale``; 747 feature selection params are rejected. 748 749 Args: 750 text: The prompt text to generate from. 751 feature_idx: Index of a single SAE feature to steer with. 752 strength: Steering magnitude for a single feature 753 (default ``1.0``). 754 max_new_tokens: Maximum number of tokens to generate. 755 temperature: Sampling temperature. 756 top_p: Nucleus sampling threshold. 757 feature_indices: List of feature indices for multi-feature 758 steering. 759 strengths: List of steering magnitudes, one per entry in 760 ``feature_indices``. Defaults to ``[1.0, …]`` if 761 ``None``. 762 clamp: Remove the hidden state's existing projection onto 763 each steering vector before adding it, so the 764 component along the vector becomes exactly ``strength`` 765 (the Golden Gate / Eiffel Tower scheme). 766 repetition_penalty: CTRL-style repetition penalty for 767 already-generated tokens (``1.0`` disables). 768 system_prompt: When set, render the prompt as a 769 system+user chat via ``apply_chat_template`` (required 770 for instruct models). 771 strength_scale: Multiplier on all steering strengths 772 (applies to both SAE and SteeringVectors sources). 773 ``0.0`` with a vectors source runs the baseline loop. 774 seed: Optional RNG seed for reproducible sampling. 775 776 Returns: 777 The generated text string (special tokens stripped). 778 779 Raises: 780 ValueError: If ``feature_indices`` and ``strengths`` have 781 mismatched lengths, a feature index is out of range, or 782 feature selection params are passed with a 783 :class:`SteeringVectors` source. 784 785 Example: 786 ```python 787 # Single feature 788 steering.generate("Hello", feature_idx=42, strength=2.0) 789 790 # Multi-feature 791 steering.generate( 792 "Hello", 793 feature_indices=[10, 42], 794 strengths=[1.0, 3.0], 795 ) 796 797 # Chat-templated, clamped, reproducible 798 steering.generate( 799 "Hello", 800 feature_idx=42, 801 system_prompt="You are a helpful assistant.", 802 clamp=True, 803 seed=0, 804 ) 805 806 # Baseline 807 steering.generate("Hello", max_new_tokens=100) 808 ``` 809 """ 810 if self.vectors is not None: 811 if feature_idx is not None or feature_indices is not None: 812 raise ValueError( 813 "feature selection is fixed by the SteeringVectors source; " 814 "use strength_scale to adjust magnitude" 815 ) 816 layer_components = {} 817 for c in self.vectors.components: 818 layer_components.setdefault(c.layer, []).append( 819 (c.strength * strength_scale, c.vector) 820 ) 821 if strength_scale == 0.0: 822 return self._generate_baseline( 823 text, 824 max_new_tokens, 825 temperature, 826 top_p, 827 repetition_penalty, 828 seed, 829 system_prompt, 830 ) 831 else: 832 if feature_idx is None and feature_indices is None: 833 return self._generate_baseline( 834 text, 835 max_new_tokens, 836 temperature, 837 top_p, 838 repetition_penalty, 839 seed, 840 system_prompt, 841 ) 842 if feature_idx is not None: 843 feature_indices = [feature_idx] 844 strengths = [strength] 845 elif strengths is None: 846 strengths = [1.0] * len(feature_indices) 847 if len(feature_indices) != len(strengths): 848 raise ValueError( 849 "feature_indices and strengths must have the same length" 850 ) 851 pairs = [] 852 for fid, s in zip(feature_indices, strengths): 853 if fid < 0 or fid >= self.sae.hidden_dim: 854 raise ValueError( 855 f"feature_idx {fid} out of range [0, {self.sae.hidden_dim})" 856 ) 857 pairs.append( 858 (s * strength_scale, self.sae.decoder.weight[:, fid].detach()) 859 ) 860 layer_components = {self.target_layer: pairs} 861 862 input_ids, attention_mask = self._build_prompt_ids(text, system_prompt) 863 864 return self._generate_with_hooks( 865 input_ids, 866 attention_mask, 867 layer_components, 868 max_new_tokens, 869 temperature, 870 top_p, 871 repetition_penalty, 872 clamp, 873 seed, 874 ) 875 876 def _generate_baseline( 877 self, 878 text: str, 879 max_new_tokens: int, 880 temperature: float, 881 top_p: float, 882 repetition_penalty: float = 1.0, 883 seed: Optional[int] = None, 884 system_prompt: Optional[str] = None, 885 ) -> str: 886 """ 887 Generate text without any SAE steering (baseline). 888 889 Runs the same token-by-token autoregressive loop as 890 :meth:`_generate_with_hooks` but without registering a 891 steering hook. 892 893 Args: 894 text: The prompt text. 895 max_new_tokens: Maximum tokens to generate. 896 temperature: Sampling temperature. 897 top_p: Nucleus sampling threshold. 898 repetition_penalty: CTRL-style repetition penalty (1.0 off). 899 seed: Optional RNG seed for reproducible sampling. 900 system_prompt: When set, render the prompt through the 901 chat template (see :meth:`_build_prompt_ids`). 902 903 Returns: 904 The decoded text string (special tokens stripped). 905 """ 906 input_ids, attention_mask = self._build_prompt_ids(text, system_prompt) 907 908 base_model = self.model._module 909 generated_tokens = input_ids.clone() 910 prompt_len = input_ids.shape[-1] 911 device = generated_tokens.device 912 generator = ( 913 torch.Generator(device=device).manual_seed(seed) 914 if seed is not None 915 else None 916 ) 917 918 with torch.no_grad(): 919 for _ in tqdm(range(max_new_tokens), desc="Baseline generation"): 920 if generated_tokens.shape[-1] >= self.tokenizer.model_max_length: 921 break 922 923 outputs = base_model( 924 input_ids=generated_tokens, 925 attention_mask=attention_mask, 926 use_cache=True, 927 ) 928 929 logits = outputs.logits[:, -1, :] 930 next_token = _sample_next_token( 931 logits, 932 temperature, 933 top_p, 934 repetition_penalty=repetition_penalty, 935 generated_tokens=generated_tokens, 936 generator=generator, 937 ) 938 939 if next_token.item() == self.tokenizer.eos_token_id: 940 break 941 942 generated_tokens = torch.cat([generated_tokens, next_token], dim=-1) 943 if attention_mask is not None: 944 attention_mask = torch.cat( 945 [attention_mask, torch.ones(1, 1, device=device)], dim=-1 946 ) 947 948 return self.tokenizer.decode( 949 generated_tokens[0][prompt_len:], skip_special_tokens=True 950 ) 951 952 def compare_steering( 953 self, 954 text: str, 955 feature_idx: int, 956 strengths: Optional[List[float]] = None, 957 max_new_tokens: int = 50, 958 temperature: float = 0.8, 959 top_p: float = 0.9, 960 ) -> Dict[str, str]: 961 """ 962 Compare baseline generation against steered generations at different strengths. 963 964 Runs one generation per strength value (including ``0.0`` for a 965 no-steering baseline) and returns a dict mapping descriptive 966 labels to the generated text. 967 968 Args: 969 text: The prompt text. 970 feature_idx: SAE feature index to steer with. 971 strengths: List of strength values to test. Defaults to 972 ``[0.0, 1.0, 2.0, 3.0, 5.0]``. 973 max_new_tokens: Maximum tokens to generate per run. 974 temperature: Sampling temperature. 975 top_p: Nucleus sampling threshold. 976 977 Returns: 978 Dictionary mapping strength labels to generated text. 979 Keys are ``"baseline"`` (for strength ``0.0``) and 980 ``"strength_1.0"``, ``"strength_2.0"``, etc. 981 982 Example: 983 ```python 984 results = steering.compare_steering( 985 "The sky is", 986 feature_idx=42, 987 strengths=[0.0, 1.0, 3.0], 988 ) 989 for label, text in results.items(): 990 print(f"{label}: {text}") 991 ``` 992 """ 993 if strengths is None: 994 strengths = [0.0, 1.0, 2.0, 3.0, 5.0] 995 996 results = {} 997 998 for s in strengths: 999 if s == 0.0: 1000 label = "baseline" 1001 else: 1002 label = f"strength_{s}" 1003 1004 logger.info(f"Generating with {label} (strength={s})") 1005 generated = self.generate( 1006 text, 1007 feature_idx=feature_idx, 1008 strength=s, 1009 max_new_tokens=max_new_tokens, 1010 temperature=temperature, 1011 top_p=top_p, 1012 ) 1013 results[label] = generated 1014 1015 return results 1016 1017 def find_steering_features( 1018 self, 1019 text: str, 1020 activations: np.ndarray, 1021 top_k: int = 20, 1022 min_activation: float = 0.0, 1023 ) -> List[Dict[str, Any]]: 1024 """ 1025 Find the top-k SAE features that most activate on given activations. 1026 1027 Encodes the provided activations through the SAE and returns the 1028 features with the highest mean activation values, sorted by 1029 magnitude. Useful for identifying which features are relevant 1030 to a given input before steering. 1031 1032 Args: 1033 text: The prompt text (used for logging only). 1034 activations: MLP activations array of shape 1035 ``(n_samples, activation_dim)``. 1036 top_k: Number of top features to return. 1037 min_activation: Minimum mean activation threshold to 1038 consider a feature. 1039 1040 Returns: 1041 List of dicts sorted by descending activation, each 1042 containing: 1043 1044 - ``feature_idx`` (``int``): The feature index. 1045 - ``activation`` (``float``): Mean activation value. 1046 - ``normalized_activation`` (``float``): Activation 1047 divided by the decoder weight L2 norm. 1048 - ``decoder_weight_norm`` (``float``): L2 norm of the 1049 decoder weight vector. 1050 """ 1051 if self.sae is None: 1052 raise TypeError( 1053 "find_steering_features requires a SparseAutoencoder source; " 1054 "this instance was built from SteeringVectors (no encoder)" 1055 ) 1056 self.sae.eval() 1057 device = self.device 1058 1059 with torch.no_grad(): 1060 if isinstance(activations, np.ndarray): 1061 acts_tensor = torch.from_numpy(activations).float().to(device) 1062 else: 1063 acts_tensor = activations.to(device) 1064 1065 features = self.sae.encode(acts_tensor) 1066 1067 avg_activations = features.mean(dim=0).cpu().numpy() 1068 decoder_norms = self.sae.decoder.weight.norm(dim=0).cpu().numpy() 1069 1070 active_mask = avg_activations >= min_activation 1071 active_indices = np.where(active_mask)[0] 1072 1073 sorted_indices = active_indices[ 1074 np.argsort(avg_activations[active_indices])[::-1] 1075 ][:top_k] 1076 1077 results = [] 1078 for idx in sorted_indices: 1079 results.append( 1080 { 1081 "feature_idx": int(idx), 1082 "activation": float(avg_activations[idx]), 1083 "normalized_activation": float( 1084 avg_activations[idx] / (decoder_norms[idx] + 1e-8) 1085 ), 1086 "decoder_weight_norm": float(decoder_norms[idx]), 1087 } 1088 ) 1089 1090 logger.info(f"Found {len(results)} active features for text: {text[:100]}") 1091 return results 1092 1093 def build_token_feature_map( 1094 self, 1095 tokens: Optional[List[int]] = None, 1096 top_k_features: int = 10, 1097 min_activation: float = 1e-3, 1098 batch_size: int = 64, 1099 ) -> Dict[int, Dict[str, Any]]: 1100 """ 1101 Build a token-to-feature activation map. 1102 1103 For each input token (or batch of tokens), this method passes it 1104 through the model at the target layer to capture MLP activations, 1105 then encodes those activations through the SAE encoder to determine 1106 which features are activated by each token. The result is a 1107 dictionary mapping token IDs to their active SAE features and 1108 mean activation magnitudes. 1109 1110 This provides a lookup table for understanding what semantic 1111 content each token is associated with in terms of learned SAE 1112 features — useful for interpreting which features respond to 1113 specific tokens without having to steer by them during generation. 1114 1115 Args: 1116 tokens: List of token IDs to evaluate. If ``None``, evaluates 1117 the first N tokens from the tokenizer's vocabulary 1118 (default ``min(1024, len(tokenizer) - num_special_tokens)``). 1119 Special/padding tokens are skipped. 1120 top_k_features: Maximum number of features per token to report 1121 when there are more than this many above the activation 1122 threshold. Default ``10``. 1123 min_activation: Minimum mean activation value for a feature 1124 to be considered "active" by a token. Features below this 1125 threshold are excluded from the mapping. Default ``1e-3``. 1126 batch_size: Batch size for processing tokens through the model. 1127 Used when ``tokens`` is provided; otherwise tokens are 1128 processed individually. Default ``64``. 1129 1130 Returns: 1131 Dictionary mapping token IDs to dictionaries containing: 1132 1133 - ``active_features`` (``List[int]``): Indices of features whose 1134 mean activation exceeds ``min_activation``, sorted by 1135 descending activation strength. 1136 - ``mean_activations`` (``Dict[int, float]``): Mean activation 1137 value per active feature. 1138 - ``decoder_norms`` (``Dict[int, float]``): L2 norm of the 1139 decoder weight vector for each active feature. 1140 1141 Example: 1142 ```python 1143 steering = SAESteering( 1144 source=sae, model_name="google/gemma-2b", layer=5 1145 ) 1146 token_map = steering.build_token_feature_map() 1147 1148 # Check which features respond to a specific token 1149 for token_id in range(100): 1150 if token_id in token_map: 1151 active = token_map[token_id]["active_features"] 1152 logger.info(f"Token {token_id} activates features: {active[:5]}") 1153 1154 # Find tokens that strongly activate feature 42 1155 matching_tokens = [ 1156 tid for tid, info in token_map.items() 1157 if 42 in info["mean_activations"] 1158 and info["mean_activations"][42] > 0.1 1159 ] 1160 ``` 1161 """ 1162 if self.sae is None: 1163 raise TypeError( 1164 "build_token_feature_map requires a SparseAutoencoder source; " 1165 "this instance was built from SteeringVectors (no encoder)" 1166 ) 1167 # Determine tokens to evaluate 1168 n_special = len(self.tokenizer.all_special_ids) 1169 vocab_size = self.tokenizer.vocab_size 1170 1171 if tokens is None: 1172 eval_tokens = list( 1173 range( 1174 n_special, 1175 min(vocab_size - 1, n_special + max(1024, vocab_size // 2)), 1176 ) 1177 ) 1178 else: 1179 eval_tokens = [t for t in tokens if t not in self.tokenizer.all_special_ids] 1180 1181 logger.info(f"Evaluating {len(eval_tokens)} tokens for feature mapping") 1182 self.sae.eval() 1183 device = self.device 1184 1185 token_map: Dict[int, Dict[str, Any]] = {} 1186 1187 # Use forward hook on the target MLP to capture activations at layer `target_layer` 1188 base_model = self.model._module 1189 target_module = resolve_module_path( 1190 base_model, f"model.layers[{self.target_layer}].mlp" 1191 ) 1192 captured_activations: Optional[torch.Tensor] = None 1193 1194 def activation_hook(module, input_args, output): 1195 nonlocal captured_activations 1196 if isinstance(output, torch.Tensor): 1197 if output.dim() == 3 and output.shape[1] == 1: 1198 captured_activations = output.squeeze(1).clone() 1199 elif output.dim() == 2: 1200 captured_activations = output.clone() 1201 1202 hook_handle = target_module.register_forward_hook(activation_hook) 1203 try: 1204 # Process tokens in batches 1205 for i in range(0, len(eval_tokens), batch_size): 1206 batch_tokens = eval_tokens[i : i + batch_size] 1207 input_ids = torch.tensor([[t] for t in batch_tokens], device=device) 1208 1209 with torch.no_grad(): 1210 _ = base_model(input_ids=input_ids) 1211 1212 if captured_activations is None: 1213 logger.warning( 1214 "No activations captured from hook — model may not produce 2D output" 1215 ) 1216 continue 1217 1218 # Encode through SAE encoder to get feature activations 1219 features = self.sae.encode(captured_activations).detach().cpu().numpy() 1220 1221 for token_id, feat_row in zip(batch_tokens, features): 1222 mean_act = float(np.mean(feat_row)) 1223 if mean_act >= min_activation: 1224 active_features = sorted( 1225 int(idx) 1226 for idx, val in enumerate(feat_row) 1227 if val > 0 and val > min_activation 1228 )[:top_k_features] 1229 1230 token_map[token_id] = { 1231 "active_features": active_features, 1232 "mean_activations": { 1233 int(fid): float(feat_row[fid]) 1234 for fid in active_features 1235 }, 1236 "decoder_norms": {}, 1237 } 1238 1239 logger.info(f"Built token→feature map with {len(token_map)} tokens") 1240 1241 finally: 1242 hook_handle.remove() 1243 1244 return token_map 1245 1246 def get_steering_direction( 1247 self, 1248 feature_idx: int, 1249 normalize: bool = True, 1250 ) -> np.ndarray: 1251 """ 1252 Get the steering direction vector for a given feature. 1253 1254 The steering direction is the SAE decoder weight column for the 1255 specified feature. This vector represents the direction in MLP 1256 activation space that the feature encodes. 1257 1258 Args: 1259 feature_idx: Index of the SAE feature. 1260 normalize: If ``True``, L2-normalise the direction vector. 1261 1262 Returns: 1263 NumPy array of shape ``(activation_dim,)`` containing the 1264 steering direction. 1265 1266 Raises: 1267 ValueError: If ``feature_idx`` is out of range 1268 ``[0, sae.hidden_dim)``. 1269 """ 1270 if self.sae is None: 1271 raise TypeError( 1272 "get_steering_direction requires a SparseAutoencoder source; " 1273 "this instance was built from SteeringVectors (no encoder)" 1274 ) 1275 if feature_idx < 0 or feature_idx >= self.sae.hidden_dim: 1276 raise ValueError( 1277 f"feature_idx {feature_idx} out of range [0, {self.sae.hidden_dim})" 1278 ) 1279 1280 direction = self.sae.decoder.weight[:, feature_idx].detach().cpu().numpy() 1281 1282 if normalize: 1283 norm = np.linalg.norm(direction) 1284 if norm > 0: 1285 direction = direction / norm 1286 1287 return direction 1288 1289 def save_steering_analysis( 1290 self, 1291 results: Dict[str, str], 1292 output_dir: Union[str, Path], 1293 prompt: str = "", 1294 feature_label: str = "", 1295 ) -> Path: 1296 """ 1297 Save steering comparison results to disk. 1298 1299 Writes a text file containing the prompt and all generated 1300 outputs (baseline and steered) for later review. The 1301 filename includes a timestamp (and optional feature label) 1302 to prevent overwriting previous analyses. 1303 1304 Args: 1305 results: Dictionary mapping labels to generated text 1306 (as returned by :meth:`compare_steering`). 1307 output_dir: Directory to save the analysis file. 1308 prompt: The original prompt text. 1309 feature_label: Optional label included in the filename 1310 (e.g. ``"feature_42"``). 1311 1312 Returns: 1313 Path to the saved analysis file. 1314 """ 1315 output_dir = Path(output_dir) 1316 output_dir.mkdir(parents=True, exist_ok=True) 1317 1318 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 1319 suffix = f"_{feature_label}" if feature_label else "" 1320 filepath = output_dir / f"steering_analysis{suffix}_{timestamp}.txt" 1321 1322 with open(filepath, "w") as f: 1323 f.write(f"Prompt: {prompt}\n") 1324 f.write("=" * 80 + "\n\n") 1325 1326 for label, text in results.items(): 1327 f.write(f"--- {label} ---\n") 1328 f.write(f"{text}\n\n") 1329 f.write("-" * 40 + "\n") 1330 1331 logger.info(f"Saved steering analysis to {filepath}") 1332 return filepath
Steer language model generation using trained SAE features.
Wraps a HuggingFace language model (via nnsight) and applies SAE feature interventions during generation. At each generation step, a forward hook on the target MLP module adds a scaled decoder weight vector, biasing the residual stream toward the chosen feature direction.
The HuggingFace auth token is read automatically from
HUGGINGFACE_HUB_TOKEN (via ~drrik.settings.get_settings())
when the token parameter is not explicitly provided.
Attributes:
- source: The steering source this instance was built from — a
~drrik.autoencoder.SparseAutoencoderor aSteeringVectors. - sae: The trained
~drrik.autoencoder.SparseAutoencoderproviding feature directions, orNonewhen built fromSteeringVectors. - vectors: The
SteeringVectorspre-extracted components, orNonewhen built from aSparseAutoencoder. - model: The nnsight
~nnsight.LanguageModelwrapper. - tokenizer: The HuggingFace tokenizer for the model.
- target_layer: The layer index where interventions are applied
(
NoneforSteeringVectors; layers come from the components). - hook_path:
"mlp"for SAE sources; the source'shook_pathforSteeringVectors. - device: The device the steering directions live on.
Example:
sae = SparseAutoencoder.load("sae_model.pt") steering = SAESteering(source=sae, model_name="google/gemma-2b", layer=5) # Steered generation result = steering.generate( "The sky is", feature_idx=128, strength=2.5, max_new_tokens=50, ) # Baseline (no steering) baseline = steering.generate("The sky is", max_new_tokens=50) # Multi-feature steering result = steering.generate( "The sky is", feature_indices=[10, 128], strengths=[1.0, 2.5], )Or from pre-extracted
SteeringVectors:vectors = SteeringVectors.from_hf_dataset("user/dataset") steering = SAESteering(source=vectors, model_name="google/gemma-2b")
458 def __init__( 459 self, 460 source: Union[SparseAutoencoder, SteeringVectors], 461 model_name: str, 462 layer: Optional[int] = None, 463 revision: str = "main", 464 torch_dtype: str = "float16", 465 device_map: str = "auto", 466 trust_remote_code: bool = True, 467 token: Optional[str] = None, 468 ): 469 """ 470 Initialize the SAE steering controller. 471 472 Args: 473 source: Steering direction source — either a trained 474 :class:`~drrik.autoencoder.SparseAutoencoder` (then 475 ``layer`` is required) or a 476 :class:`SteeringVectors` of pre-extracted components 477 (then ``layer`` is ignored; layers come from the 478 components). 479 model_name: HuggingFace model identifier for generation. 480 layer: Transformer layer index for a ``SparseAutoencoder`` 481 source. Ignored for ``SteeringVectors``. 482 revision: Model revision to load. 483 torch_dtype: Weight dtype for model loading. 484 device_map: Device mapping strategy. 485 trust_remote_code: Whether to trust remote code from the repo. 486 token: HuggingFace token for gated models. Falls back to 487 ``HUGGINGFACE_HUB_TOKEN`` from ``settings.yml`` when ``None``. 488 489 Raises: 490 TypeError: If ``source`` is neither type. 491 ValueError: If ``source`` is a ``SparseAutoencoder`` and 492 ``layer`` is ``None``. 493 """ 494 if isinstance(source, SparseAutoencoder): 495 if layer is None: 496 raise ValueError("layer is required when source is a SparseAutoencoder") 497 self.sae: Optional[SparseAutoencoder] = source 498 self.vectors: Optional[SteeringVectors] = None 499 self.target_layer: Optional[int] = layer 500 self.hook_path = "mlp" 501 self.device = next(source.parameters()).device 502 elif isinstance(source, SteeringVectors): 503 self.sae = None 504 self.vectors = source 505 self.target_layer = None 506 self.hook_path = source.hook_path 507 self.device = source.components[0].vector.device 508 else: 509 raise TypeError( 510 "source must be a SparseAutoencoder or SteeringVectors, " 511 f"got {type(source).__name__}" 512 ) 513 self.source = source 514 515 if token is None: 516 settings = get_settings() 517 token = settings.huggingface_hub_token 518 519 if layer is not None: 520 logger.info(f"Loading model '{model_name}' for steering at layer {layer}") 521 else: 522 layers = sorted({c.layer for c in source.components}) 523 logger.info( 524 f"Loading model '{model_name}' for steering at layer(s) {layers}" 525 ) 526 527 dtype_map = { 528 "float16": torch.float16, 529 "bfloat16": torch.bfloat16, 530 "float32": torch.float32, 531 } 532 load_dtype = dtype_map.get(torch_dtype, torch.float16) 533 534 model_kwargs = { 535 "revision": revision, 536 "torch_dtype": load_dtype, 537 "device_map": device_map, 538 "trust_remote_code": trust_remote_code, 539 } 540 if token: 541 model_kwargs["token"] = token 542 543 self.model = LanguageModel(model_name, **model_kwargs) 544 # nnsight keeps the model on meta until dispatched; generation calls 545 # the underlying module directly (no tracing), so dispatch here. 546 if hasattr(self.model, "dispatch"): 547 self.model.dispatch() 548 self.tokenizer = AutoTokenizer.from_pretrained( 549 model_name, 550 revision=revision, 551 trust_remote_code=trust_remote_code, 552 token=token, 553 ) 554 555 if self.tokenizer.pad_token is None: 556 self.tokenizer.pad_token = self.tokenizer.eos_token 557 558 logger.info(f"Model loaded on {self.model.device}")
Initialize the SAE steering controller.
Arguments:
- source: Steering direction source — either a trained
~drrik.autoencoder.SparseAutoencoder(thenlayeris required) or aSteeringVectorsof pre-extracted components (thenlayeris ignored; layers come from the components). - model_name: HuggingFace model identifier for generation.
- layer: Transformer layer index for a
SparseAutoencodersource. Ignored forSteeringVectors. - revision: Model revision to load.
- torch_dtype: Weight dtype for model loading.
- device_map: Device mapping strategy.
- trust_remote_code: Whether to trust remote code from the repo.
- token: HuggingFace token for gated models. Falls back to
HUGGINGFACE_HUB_TOKENfromsettings.ymlwhenNone.
Raises:
- TypeError: If
sourceis neither type. - ValueError: If
sourceis aSparseAutoencoderandlayerisNone.
718 def generate( 719 self, 720 text: str, 721 feature_idx: Optional[int] = None, 722 strength: float = 1.0, 723 max_new_tokens: int = 50, 724 temperature: float = 0.8, 725 top_p: float = 0.9, 726 feature_indices: Optional[List[int]] = None, 727 strengths: Optional[List[float]] = None, 728 clamp: bool = False, 729 repetition_penalty: float = 1.0, 730 system_prompt: Optional[str] = None, 731 strength_scale: float = 1.0, 732 seed: Optional[int] = None, 733 ) -> str: 734 """ 735 Generate text with optional SAE feature steering. 736 737 This is the main entry point for generation. 738 739 - **No features** → calls :meth:`_generate_baseline` (plain 740 autoregressive generation). 741 - **Single feature** → pass ``feature_idx`` and ``strength``. 742 - **Multiple features** → pass ``feature_indices`` and 743 ``strengths`` as equal-length lists; each decoder direction 744 is injected as its own component. 745 - **SteeringVectors source** → all pre-extracted components 746 are injected at their layers, scaled by ``strength_scale``; 747 feature selection params are rejected. 748 749 Args: 750 text: The prompt text to generate from. 751 feature_idx: Index of a single SAE feature to steer with. 752 strength: Steering magnitude for a single feature 753 (default ``1.0``). 754 max_new_tokens: Maximum number of tokens to generate. 755 temperature: Sampling temperature. 756 top_p: Nucleus sampling threshold. 757 feature_indices: List of feature indices for multi-feature 758 steering. 759 strengths: List of steering magnitudes, one per entry in 760 ``feature_indices``. Defaults to ``[1.0, …]`` if 761 ``None``. 762 clamp: Remove the hidden state's existing projection onto 763 each steering vector before adding it, so the 764 component along the vector becomes exactly ``strength`` 765 (the Golden Gate / Eiffel Tower scheme). 766 repetition_penalty: CTRL-style repetition penalty for 767 already-generated tokens (``1.0`` disables). 768 system_prompt: When set, render the prompt as a 769 system+user chat via ``apply_chat_template`` (required 770 for instruct models). 771 strength_scale: Multiplier on all steering strengths 772 (applies to both SAE and SteeringVectors sources). 773 ``0.0`` with a vectors source runs the baseline loop. 774 seed: Optional RNG seed for reproducible sampling. 775 776 Returns: 777 The generated text string (special tokens stripped). 778 779 Raises: 780 ValueError: If ``feature_indices`` and ``strengths`` have 781 mismatched lengths, a feature index is out of range, or 782 feature selection params are passed with a 783 :class:`SteeringVectors` source. 784 785 Example: 786 ```python 787 # Single feature 788 steering.generate("Hello", feature_idx=42, strength=2.0) 789 790 # Multi-feature 791 steering.generate( 792 "Hello", 793 feature_indices=[10, 42], 794 strengths=[1.0, 3.0], 795 ) 796 797 # Chat-templated, clamped, reproducible 798 steering.generate( 799 "Hello", 800 feature_idx=42, 801 system_prompt="You are a helpful assistant.", 802 clamp=True, 803 seed=0, 804 ) 805 806 # Baseline 807 steering.generate("Hello", max_new_tokens=100) 808 ``` 809 """ 810 if self.vectors is not None: 811 if feature_idx is not None or feature_indices is not None: 812 raise ValueError( 813 "feature selection is fixed by the SteeringVectors source; " 814 "use strength_scale to adjust magnitude" 815 ) 816 layer_components = {} 817 for c in self.vectors.components: 818 layer_components.setdefault(c.layer, []).append( 819 (c.strength * strength_scale, c.vector) 820 ) 821 if strength_scale == 0.0: 822 return self._generate_baseline( 823 text, 824 max_new_tokens, 825 temperature, 826 top_p, 827 repetition_penalty, 828 seed, 829 system_prompt, 830 ) 831 else: 832 if feature_idx is None and feature_indices is None: 833 return self._generate_baseline( 834 text, 835 max_new_tokens, 836 temperature, 837 top_p, 838 repetition_penalty, 839 seed, 840 system_prompt, 841 ) 842 if feature_idx is not None: 843 feature_indices = [feature_idx] 844 strengths = [strength] 845 elif strengths is None: 846 strengths = [1.0] * len(feature_indices) 847 if len(feature_indices) != len(strengths): 848 raise ValueError( 849 "feature_indices and strengths must have the same length" 850 ) 851 pairs = [] 852 for fid, s in zip(feature_indices, strengths): 853 if fid < 0 or fid >= self.sae.hidden_dim: 854 raise ValueError( 855 f"feature_idx {fid} out of range [0, {self.sae.hidden_dim})" 856 ) 857 pairs.append( 858 (s * strength_scale, self.sae.decoder.weight[:, fid].detach()) 859 ) 860 layer_components = {self.target_layer: pairs} 861 862 input_ids, attention_mask = self._build_prompt_ids(text, system_prompt) 863 864 return self._generate_with_hooks( 865 input_ids, 866 attention_mask, 867 layer_components, 868 max_new_tokens, 869 temperature, 870 top_p, 871 repetition_penalty, 872 clamp, 873 seed, 874 )
Generate text with optional SAE feature steering.
This is the main entry point for generation.
- No features → calls
_generate_baseline()(plain autoregressive generation). - Single feature → pass
feature_idxandstrength. - Multiple features → pass
feature_indicesandstrengthsas equal-length lists; each decoder direction is injected as its own component. - SteeringVectors source → all pre-extracted components
are injected at their layers, scaled by
strength_scale; feature selection params are rejected.
Arguments:
- text: The prompt text to generate from.
- feature_idx: Index of a single SAE feature to steer with.
- strength: Steering magnitude for a single feature
(default
1.0). - max_new_tokens: Maximum number of tokens to generate.
- temperature: Sampling temperature.
- top_p: Nucleus sampling threshold.
- feature_indices: List of feature indices for multi-feature steering.
- strengths: List of steering magnitudes, one per entry in
feature_indices. Defaults to[1.0, …]ifNone. - clamp: Remove the hidden state's existing projection onto
each steering vector before adding it, so the
component along the vector becomes exactly
strength(the Golden Gate / Eiffel Tower scheme). - repetition_penalty: CTRL-style repetition penalty for
already-generated tokens (
1.0disables). - system_prompt: When set, render the prompt as a
system+user chat via
apply_chat_template(required for instruct models). - strength_scale: Multiplier on all steering strengths
(applies to both SAE and SteeringVectors sources).
0.0with a vectors source runs the baseline loop. - seed: Optional RNG seed for reproducible sampling.
Returns:
The generated text string (special tokens stripped).
Raises:
- ValueError: If
feature_indicesandstrengthshave mismatched lengths, a feature index is out of range, or feature selection params are passed with aSteeringVectorssource.
Example:
# Single feature steering.generate("Hello", feature_idx=42, strength=2.0) # Multi-feature steering.generate( "Hello", feature_indices=[10, 42], strengths=[1.0, 3.0], ) # Chat-templated, clamped, reproducible steering.generate( "Hello", feature_idx=42, system_prompt="You are a helpful assistant.", clamp=True, seed=0, ) # Baseline steering.generate("Hello", max_new_tokens=100)
952 def compare_steering( 953 self, 954 text: str, 955 feature_idx: int, 956 strengths: Optional[List[float]] = None, 957 max_new_tokens: int = 50, 958 temperature: float = 0.8, 959 top_p: float = 0.9, 960 ) -> Dict[str, str]: 961 """ 962 Compare baseline generation against steered generations at different strengths. 963 964 Runs one generation per strength value (including ``0.0`` for a 965 no-steering baseline) and returns a dict mapping descriptive 966 labels to the generated text. 967 968 Args: 969 text: The prompt text. 970 feature_idx: SAE feature index to steer with. 971 strengths: List of strength values to test. Defaults to 972 ``[0.0, 1.0, 2.0, 3.0, 5.0]``. 973 max_new_tokens: Maximum tokens to generate per run. 974 temperature: Sampling temperature. 975 top_p: Nucleus sampling threshold. 976 977 Returns: 978 Dictionary mapping strength labels to generated text. 979 Keys are ``"baseline"`` (for strength ``0.0``) and 980 ``"strength_1.0"``, ``"strength_2.0"``, etc. 981 982 Example: 983 ```python 984 results = steering.compare_steering( 985 "The sky is", 986 feature_idx=42, 987 strengths=[0.0, 1.0, 3.0], 988 ) 989 for label, text in results.items(): 990 print(f"{label}: {text}") 991 ``` 992 """ 993 if strengths is None: 994 strengths = [0.0, 1.0, 2.0, 3.0, 5.0] 995 996 results = {} 997 998 for s in strengths: 999 if s == 0.0: 1000 label = "baseline" 1001 else: 1002 label = f"strength_{s}" 1003 1004 logger.info(f"Generating with {label} (strength={s})") 1005 generated = self.generate( 1006 text, 1007 feature_idx=feature_idx, 1008 strength=s, 1009 max_new_tokens=max_new_tokens, 1010 temperature=temperature, 1011 top_p=top_p, 1012 ) 1013 results[label] = generated 1014 1015 return results
Compare baseline generation against steered generations at different strengths.
Runs one generation per strength value (including 0.0 for a
no-steering baseline) and returns a dict mapping descriptive
labels to the generated text.
Arguments:
- text: The prompt text.
- feature_idx: SAE feature index to steer with.
- strengths: List of strength values to test. Defaults to
[0.0, 1.0, 2.0, 3.0, 5.0]. - max_new_tokens: Maximum tokens to generate per run.
- temperature: Sampling temperature.
- top_p: Nucleus sampling threshold.
Returns:
Dictionary mapping strength labels to generated text. Keys are
"baseline"(for strength0.0) and"strength_1.0","strength_2.0", etc.
Example:
results = steering.compare_steering( "The sky is", feature_idx=42, strengths=[0.0, 1.0, 3.0], ) for label, text in results.items(): print(f"{label}: {text}")
1017 def find_steering_features( 1018 self, 1019 text: str, 1020 activations: np.ndarray, 1021 top_k: int = 20, 1022 min_activation: float = 0.0, 1023 ) -> List[Dict[str, Any]]: 1024 """ 1025 Find the top-k SAE features that most activate on given activations. 1026 1027 Encodes the provided activations through the SAE and returns the 1028 features with the highest mean activation values, sorted by 1029 magnitude. Useful for identifying which features are relevant 1030 to a given input before steering. 1031 1032 Args: 1033 text: The prompt text (used for logging only). 1034 activations: MLP activations array of shape 1035 ``(n_samples, activation_dim)``. 1036 top_k: Number of top features to return. 1037 min_activation: Minimum mean activation threshold to 1038 consider a feature. 1039 1040 Returns: 1041 List of dicts sorted by descending activation, each 1042 containing: 1043 1044 - ``feature_idx`` (``int``): The feature index. 1045 - ``activation`` (``float``): Mean activation value. 1046 - ``normalized_activation`` (``float``): Activation 1047 divided by the decoder weight L2 norm. 1048 - ``decoder_weight_norm`` (``float``): L2 norm of the 1049 decoder weight vector. 1050 """ 1051 if self.sae is None: 1052 raise TypeError( 1053 "find_steering_features requires a SparseAutoencoder source; " 1054 "this instance was built from SteeringVectors (no encoder)" 1055 ) 1056 self.sae.eval() 1057 device = self.device 1058 1059 with torch.no_grad(): 1060 if isinstance(activations, np.ndarray): 1061 acts_tensor = torch.from_numpy(activations).float().to(device) 1062 else: 1063 acts_tensor = activations.to(device) 1064 1065 features = self.sae.encode(acts_tensor) 1066 1067 avg_activations = features.mean(dim=0).cpu().numpy() 1068 decoder_norms = self.sae.decoder.weight.norm(dim=0).cpu().numpy() 1069 1070 active_mask = avg_activations >= min_activation 1071 active_indices = np.where(active_mask)[0] 1072 1073 sorted_indices = active_indices[ 1074 np.argsort(avg_activations[active_indices])[::-1] 1075 ][:top_k] 1076 1077 results = [] 1078 for idx in sorted_indices: 1079 results.append( 1080 { 1081 "feature_idx": int(idx), 1082 "activation": float(avg_activations[idx]), 1083 "normalized_activation": float( 1084 avg_activations[idx] / (decoder_norms[idx] + 1e-8) 1085 ), 1086 "decoder_weight_norm": float(decoder_norms[idx]), 1087 } 1088 ) 1089 1090 logger.info(f"Found {len(results)} active features for text: {text[:100]}") 1091 return results
Find the top-k SAE features that most activate on given activations.
Encodes the provided activations through the SAE and returns the features with the highest mean activation values, sorted by magnitude. Useful for identifying which features are relevant to a given input before steering.
Arguments:
- text: The prompt text (used for logging only).
- activations: MLP activations array of shape
(n_samples, activation_dim). - top_k: Number of top features to return.
- min_activation: Minimum mean activation threshold to consider a feature.
Returns:
List of dicts sorted by descending activation, each containing:
feature_idx(int): The feature index.activation(float): Mean activation value.normalized_activation(float): Activation divided by the decoder weight L2 norm.decoder_weight_norm(float): L2 norm of the decoder weight vector.
1093 def build_token_feature_map( 1094 self, 1095 tokens: Optional[List[int]] = None, 1096 top_k_features: int = 10, 1097 min_activation: float = 1e-3, 1098 batch_size: int = 64, 1099 ) -> Dict[int, Dict[str, Any]]: 1100 """ 1101 Build a token-to-feature activation map. 1102 1103 For each input token (or batch of tokens), this method passes it 1104 through the model at the target layer to capture MLP activations, 1105 then encodes those activations through the SAE encoder to determine 1106 which features are activated by each token. The result is a 1107 dictionary mapping token IDs to their active SAE features and 1108 mean activation magnitudes. 1109 1110 This provides a lookup table for understanding what semantic 1111 content each token is associated with in terms of learned SAE 1112 features — useful for interpreting which features respond to 1113 specific tokens without having to steer by them during generation. 1114 1115 Args: 1116 tokens: List of token IDs to evaluate. If ``None``, evaluates 1117 the first N tokens from the tokenizer's vocabulary 1118 (default ``min(1024, len(tokenizer) - num_special_tokens)``). 1119 Special/padding tokens are skipped. 1120 top_k_features: Maximum number of features per token to report 1121 when there are more than this many above the activation 1122 threshold. Default ``10``. 1123 min_activation: Minimum mean activation value for a feature 1124 to be considered "active" by a token. Features below this 1125 threshold are excluded from the mapping. Default ``1e-3``. 1126 batch_size: Batch size for processing tokens through the model. 1127 Used when ``tokens`` is provided; otherwise tokens are 1128 processed individually. Default ``64``. 1129 1130 Returns: 1131 Dictionary mapping token IDs to dictionaries containing: 1132 1133 - ``active_features`` (``List[int]``): Indices of features whose 1134 mean activation exceeds ``min_activation``, sorted by 1135 descending activation strength. 1136 - ``mean_activations`` (``Dict[int, float]``): Mean activation 1137 value per active feature. 1138 - ``decoder_norms`` (``Dict[int, float]``): L2 norm of the 1139 decoder weight vector for each active feature. 1140 1141 Example: 1142 ```python 1143 steering = SAESteering( 1144 source=sae, model_name="google/gemma-2b", layer=5 1145 ) 1146 token_map = steering.build_token_feature_map() 1147 1148 # Check which features respond to a specific token 1149 for token_id in range(100): 1150 if token_id in token_map: 1151 active = token_map[token_id]["active_features"] 1152 logger.info(f"Token {token_id} activates features: {active[:5]}") 1153 1154 # Find tokens that strongly activate feature 42 1155 matching_tokens = [ 1156 tid for tid, info in token_map.items() 1157 if 42 in info["mean_activations"] 1158 and info["mean_activations"][42] > 0.1 1159 ] 1160 ``` 1161 """ 1162 if self.sae is None: 1163 raise TypeError( 1164 "build_token_feature_map requires a SparseAutoencoder source; " 1165 "this instance was built from SteeringVectors (no encoder)" 1166 ) 1167 # Determine tokens to evaluate 1168 n_special = len(self.tokenizer.all_special_ids) 1169 vocab_size = self.tokenizer.vocab_size 1170 1171 if tokens is None: 1172 eval_tokens = list( 1173 range( 1174 n_special, 1175 min(vocab_size - 1, n_special + max(1024, vocab_size // 2)), 1176 ) 1177 ) 1178 else: 1179 eval_tokens = [t for t in tokens if t not in self.tokenizer.all_special_ids] 1180 1181 logger.info(f"Evaluating {len(eval_tokens)} tokens for feature mapping") 1182 self.sae.eval() 1183 device = self.device 1184 1185 token_map: Dict[int, Dict[str, Any]] = {} 1186 1187 # Use forward hook on the target MLP to capture activations at layer `target_layer` 1188 base_model = self.model._module 1189 target_module = resolve_module_path( 1190 base_model, f"model.layers[{self.target_layer}].mlp" 1191 ) 1192 captured_activations: Optional[torch.Tensor] = None 1193 1194 def activation_hook(module, input_args, output): 1195 nonlocal captured_activations 1196 if isinstance(output, torch.Tensor): 1197 if output.dim() == 3 and output.shape[1] == 1: 1198 captured_activations = output.squeeze(1).clone() 1199 elif output.dim() == 2: 1200 captured_activations = output.clone() 1201 1202 hook_handle = target_module.register_forward_hook(activation_hook) 1203 try: 1204 # Process tokens in batches 1205 for i in range(0, len(eval_tokens), batch_size): 1206 batch_tokens = eval_tokens[i : i + batch_size] 1207 input_ids = torch.tensor([[t] for t in batch_tokens], device=device) 1208 1209 with torch.no_grad(): 1210 _ = base_model(input_ids=input_ids) 1211 1212 if captured_activations is None: 1213 logger.warning( 1214 "No activations captured from hook — model may not produce 2D output" 1215 ) 1216 continue 1217 1218 # Encode through SAE encoder to get feature activations 1219 features = self.sae.encode(captured_activations).detach().cpu().numpy() 1220 1221 for token_id, feat_row in zip(batch_tokens, features): 1222 mean_act = float(np.mean(feat_row)) 1223 if mean_act >= min_activation: 1224 active_features = sorted( 1225 int(idx) 1226 for idx, val in enumerate(feat_row) 1227 if val > 0 and val > min_activation 1228 )[:top_k_features] 1229 1230 token_map[token_id] = { 1231 "active_features": active_features, 1232 "mean_activations": { 1233 int(fid): float(feat_row[fid]) 1234 for fid in active_features 1235 }, 1236 "decoder_norms": {}, 1237 } 1238 1239 logger.info(f"Built token→feature map with {len(token_map)} tokens") 1240 1241 finally: 1242 hook_handle.remove() 1243 1244 return token_map
Build a token-to-feature activation map.
For each input token (or batch of tokens), this method passes it through the model at the target layer to capture MLP activations, then encodes those activations through the SAE encoder to determine which features are activated by each token. The result is a dictionary mapping token IDs to their active SAE features and mean activation magnitudes.
This provides a lookup table for understanding what semantic content each token is associated with in terms of learned SAE features — useful for interpreting which features respond to specific tokens without having to steer by them during generation.
Arguments:
- tokens: List of token IDs to evaluate. If
None, evaluates the first N tokens from the tokenizer's vocabulary (defaultmin(1024, len(tokenizer) - num_special_tokens)). Special/padding tokens are skipped. - top_k_features: Maximum number of features per token to report
when there are more than this many above the activation
threshold. Default
10. - min_activation: Minimum mean activation value for a feature
to be considered "active" by a token. Features below this
threshold are excluded from the mapping. Default
1e-3. - batch_size: Batch size for processing tokens through the model.
Used when
tokensis provided; otherwise tokens are processed individually. Default64.
Returns:
Dictionary mapping token IDs to dictionaries containing:
active_features(List[int]): Indices of features whose mean activation exceedsmin_activation, sorted by descending activation strength.mean_activations(Dict[int, float]): Mean activation value per active feature.decoder_norms(Dict[int, float]): L2 norm of the decoder weight vector for each active feature.
Example:
steering = SAESteering( source=sae, model_name="google/gemma-2b", layer=5 ) token_map = steering.build_token_feature_map() # Check which features respond to a specific token for token_id in range(100): if token_id in token_map: active = token_map[token_id]["active_features"] logger.info(f"Token {token_id} activates features: {active[:5]}") # Find tokens that strongly activate feature 42 matching_tokens = [ tid for tid, info in token_map.items() if 42 in info["mean_activations"] and info["mean_activations"][42] > 0.1 ]
1246 def get_steering_direction( 1247 self, 1248 feature_idx: int, 1249 normalize: bool = True, 1250 ) -> np.ndarray: 1251 """ 1252 Get the steering direction vector for a given feature. 1253 1254 The steering direction is the SAE decoder weight column for the 1255 specified feature. This vector represents the direction in MLP 1256 activation space that the feature encodes. 1257 1258 Args: 1259 feature_idx: Index of the SAE feature. 1260 normalize: If ``True``, L2-normalise the direction vector. 1261 1262 Returns: 1263 NumPy array of shape ``(activation_dim,)`` containing the 1264 steering direction. 1265 1266 Raises: 1267 ValueError: If ``feature_idx`` is out of range 1268 ``[0, sae.hidden_dim)``. 1269 """ 1270 if self.sae is None: 1271 raise TypeError( 1272 "get_steering_direction requires a SparseAutoencoder source; " 1273 "this instance was built from SteeringVectors (no encoder)" 1274 ) 1275 if feature_idx < 0 or feature_idx >= self.sae.hidden_dim: 1276 raise ValueError( 1277 f"feature_idx {feature_idx} out of range [0, {self.sae.hidden_dim})" 1278 ) 1279 1280 direction = self.sae.decoder.weight[:, feature_idx].detach().cpu().numpy() 1281 1282 if normalize: 1283 norm = np.linalg.norm(direction) 1284 if norm > 0: 1285 direction = direction / norm 1286 1287 return direction
Get the steering direction vector for a given feature.
The steering direction is the SAE decoder weight column for the specified feature. This vector represents the direction in MLP activation space that the feature encodes.
Arguments:
- feature_idx: Index of the SAE feature.
- normalize: If
True, L2-normalise the direction vector.
Returns:
NumPy array of shape
(activation_dim,)containing the steering direction.
Raises:
- ValueError: If
feature_idxis out of range[0, sae.hidden_dim).
1289 def save_steering_analysis( 1290 self, 1291 results: Dict[str, str], 1292 output_dir: Union[str, Path], 1293 prompt: str = "", 1294 feature_label: str = "", 1295 ) -> Path: 1296 """ 1297 Save steering comparison results to disk. 1298 1299 Writes a text file containing the prompt and all generated 1300 outputs (baseline and steered) for later review. The 1301 filename includes a timestamp (and optional feature label) 1302 to prevent overwriting previous analyses. 1303 1304 Args: 1305 results: Dictionary mapping labels to generated text 1306 (as returned by :meth:`compare_steering`). 1307 output_dir: Directory to save the analysis file. 1308 prompt: The original prompt text. 1309 feature_label: Optional label included in the filename 1310 (e.g. ``"feature_42"``). 1311 1312 Returns: 1313 Path to the saved analysis file. 1314 """ 1315 output_dir = Path(output_dir) 1316 output_dir.mkdir(parents=True, exist_ok=True) 1317 1318 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 1319 suffix = f"_{feature_label}" if feature_label else "" 1320 filepath = output_dir / f"steering_analysis{suffix}_{timestamp}.txt" 1321 1322 with open(filepath, "w") as f: 1323 f.write(f"Prompt: {prompt}\n") 1324 f.write("=" * 80 + "\n\n") 1325 1326 for label, text in results.items(): 1327 f.write(f"--- {label} ---\n") 1328 f.write(f"{text}\n\n") 1329 f.write("-" * 40 + "\n") 1330 1331 logger.info(f"Saved steering analysis to {filepath}") 1332 return filepath
Save steering comparison results to disk.
Writes a text file containing the prompt and all generated outputs (baseline and steered) for later review. The filename includes a timestamp (and optional feature label) to prevent overwriting previous analyses.
Arguments:
- results: Dictionary mapping labels to generated text
(as returned by
compare_steering()). - output_dir: Directory to save the analysis file.
- prompt: The original prompt text.
- feature_label: Optional label included in the filename
(e.g.
"feature_42").
Returns:
Path to the saved analysis file.
178class SteeringVectors: 179 """Container of steering components with a shared hook path. 180 181 Holds pre-extracted, unit-norm SAE decoder columns ready for injection 182 during generation. The hook path records where the source SAE was 183 trained: ``"layer"`` (residual stream, e.g. Anthropic's 184 ``resid_post_layer_*`` SAEs) or ``"mlp"`` (MLP output, e.g. SAEs 185 trained by Drrik's own pipeline). 186 187 Example: 188 ```python 189 vectors = SteeringVectors.from_hf_dataset( 190 "shawon/llama-3.1-8b-instruct_eiffel_tower" 191 ) 192 steering = SAESteering(source=vectors, model_name="meta-llama/Llama-3.1-8B-Instruct") 193 ``` 194 """ 195 196 def __init__(self, components: List[SteeringComponent], hook_path: str = "layer"): 197 """ 198 Args: 199 components: Non-empty list of steering components. 200 hook_path: ``"layer"`` (hook ``model.layers[N]`` output) or 201 ``"mlp"`` (hook ``model.layers[N].mlp`` output). 202 203 Raises: 204 ValueError: If ``components`` is empty or ``hook_path`` is invalid. 205 """ 206 if hook_path not in ("layer", "mlp"): 207 raise ValueError(f"hook_path must be 'layer' or 'mlp', got {hook_path!r}") 208 if not components: 209 raise ValueError("SteeringVectors requires at least one component") 210 self.components = list(components) 211 self.hook_path = hook_path 212 213 @property 214 def layers(self) -> List[int]: 215 """Sorted unique layer indices across all components.""" 216 return sorted({c.layer for c in self.components}) 217 218 @property 219 def activation_dim(self) -> int: 220 """Dimension of the steering vectors (all components share it).""" 221 return self.components[0].vector.shape[0] 222 223 @classmethod 224 def from_sae( 225 cls, 226 sae: SparseAutoencoder, 227 layer: int, 228 feature_indices: List[int], 229 strengths: Optional[List[float]] = None, 230 ) -> "SteeringVectors": 231 """Build components from a Drrik-trained SAE's decoder columns. 232 233 Args: 234 sae: A trained :class:`~drrik.autoencoder.SparseAutoencoder`. 235 layer: Layer index the SAE was trained on. 236 feature_indices: Feature indices to extract. 237 strengths: Magnitude per feature. Defaults to all ``1.0``. 238 239 Returns: 240 ``SteeringVectors`` with ``hook_path="mlp"``. 241 242 Raises: 243 ValueError: If lengths mismatch or an index is out of range. 244 """ 245 if strengths is None: 246 strengths = [1.0] * len(feature_indices) 247 if len(feature_indices) != len(strengths): 248 raise ValueError("feature_indices and strengths must have the same length") 249 250 components = [] 251 for fid, s in zip(feature_indices, strengths): 252 if fid < 0 or fid >= sae.hidden_dim: 253 raise ValueError( 254 f"feature_idx {fid} out of range [0, {sae.hidden_dim})" 255 ) 256 v = sae.decoder.weight[:, fid].detach().clone().float() 257 v = v / v.norm() 258 components.append(SteeringComponent(layer, int(fid), float(s), v)) 259 return cls(components, hook_path="mlp") 260 261 @classmethod 262 def from_hf_dataset(cls, source: str) -> "SteeringVectors": 263 """Load components from an HF dataset (or local parquet file). 264 265 The dataset must contain ``layer``, ``feature_idx``, ``strength``, 266 and ``vector`` (list of floats) columns, as produced by 267 ``drrik extract-vectors``. 268 269 Args: 270 source: HF dataset repo id (e.g. 271 ``"shawon/llama-3.1-8b-instruct_eiffel_tower"``) or a 272 local path to a parquet file. 273 274 Returns: 275 ``SteeringVectors`` with ``hook_path="layer"``. 276 """ 277 from datasets import load_dataset 278 279 p = Path(source) 280 if p.exists(): 281 ds = load_dataset("parquet", data_files=str(p))["train"] 282 else: 283 ds = load_dataset(source)["train"] 284 285 components = [ 286 SteeringComponent( 287 layer=int(row["layer"]), 288 feature_idx=int(row["feature_idx"]), 289 strength=float(row["strength"]), 290 vector=torch.tensor(row["vector"], dtype=torch.float32), 291 ) 292 for row in ds 293 ] 294 return cls(components, hook_path="layer") 295 296 def save(self, path: Union[str, Path]) -> None: 297 """Save components and hook path to a ``.pt`` file. 298 299 Args: 300 path: Destination path. Parent directories are created. 301 """ 302 path = Path(path) 303 path.parent.mkdir(parents=True, exist_ok=True) 304 torch.save( 305 { 306 "hook_path": self.hook_path, 307 "components": [ 308 { 309 "layer": c.layer, 310 "feature_idx": c.feature_idx, 311 "strength": c.strength, 312 "vector": c.vector.cpu(), 313 } 314 for c in self.components 315 ], 316 }, 317 path, 318 ) 319 logger.info(f"Saved {len(self.components)} steering vectors to {path}") 320 321 @classmethod 322 def from_file(cls, path: Union[str, Path]) -> "SteeringVectors": 323 """Load components saved by :meth:`save` (hook path restored). 324 325 Args: 326 path: Path to a ``.pt`` file written by :meth:`save`. 327 328 Returns: 329 The reconstructed ``SteeringVectors``. 330 """ 331 state = torch.load(Path(path), weights_only=True) 332 components = [ 333 SteeringComponent( 334 layer=int(c["layer"]), 335 feature_idx=int(c["feature_idx"]), 336 strength=float(c["strength"]), 337 vector=c["vector"].float(), 338 ) 339 for c in state["components"] 340 ] 341 return cls(components, hook_path=state["hook_path"])
Container of steering components with a shared hook path.
Holds pre-extracted, unit-norm SAE decoder columns ready for injection
during generation. The hook path records where the source SAE was
trained: "layer" (residual stream, e.g. Anthropic's
resid_post_layer_* SAEs) or "mlp" (MLP output, e.g. SAEs
trained by Drrik's own pipeline).
Example:
vectors = SteeringVectors.from_hf_dataset( "shawon/llama-3.1-8b-instruct_eiffel_tower" ) steering = SAESteering(source=vectors, model_name="meta-llama/Llama-3.1-8B-Instruct")
196 def __init__(self, components: List[SteeringComponent], hook_path: str = "layer"): 197 """ 198 Args: 199 components: Non-empty list of steering components. 200 hook_path: ``"layer"`` (hook ``model.layers[N]`` output) or 201 ``"mlp"`` (hook ``model.layers[N].mlp`` output). 202 203 Raises: 204 ValueError: If ``components`` is empty or ``hook_path`` is invalid. 205 """ 206 if hook_path not in ("layer", "mlp"): 207 raise ValueError(f"hook_path must be 'layer' or 'mlp', got {hook_path!r}") 208 if not components: 209 raise ValueError("SteeringVectors requires at least one component") 210 self.components = list(components) 211 self.hook_path = hook_path
Arguments:
- components: Non-empty list of steering components.
- hook_path:
"layer"(hookmodel.layers[N]output) or"mlp"(hookmodel.layers[N].mlpoutput).
Raises:
- ValueError: If
componentsis empty orhook_pathis invalid.
213 @property 214 def layers(self) -> List[int]: 215 """Sorted unique layer indices across all components.""" 216 return sorted({c.layer for c in self.components})
Sorted unique layer indices across all components.
218 @property 219 def activation_dim(self) -> int: 220 """Dimension of the steering vectors (all components share it).""" 221 return self.components[0].vector.shape[0]
Dimension of the steering vectors (all components share it).
223 @classmethod 224 def from_sae( 225 cls, 226 sae: SparseAutoencoder, 227 layer: int, 228 feature_indices: List[int], 229 strengths: Optional[List[float]] = None, 230 ) -> "SteeringVectors": 231 """Build components from a Drrik-trained SAE's decoder columns. 232 233 Args: 234 sae: A trained :class:`~drrik.autoencoder.SparseAutoencoder`. 235 layer: Layer index the SAE was trained on. 236 feature_indices: Feature indices to extract. 237 strengths: Magnitude per feature. Defaults to all ``1.0``. 238 239 Returns: 240 ``SteeringVectors`` with ``hook_path="mlp"``. 241 242 Raises: 243 ValueError: If lengths mismatch or an index is out of range. 244 """ 245 if strengths is None: 246 strengths = [1.0] * len(feature_indices) 247 if len(feature_indices) != len(strengths): 248 raise ValueError("feature_indices and strengths must have the same length") 249 250 components = [] 251 for fid, s in zip(feature_indices, strengths): 252 if fid < 0 or fid >= sae.hidden_dim: 253 raise ValueError( 254 f"feature_idx {fid} out of range [0, {sae.hidden_dim})" 255 ) 256 v = sae.decoder.weight[:, fid].detach().clone().float() 257 v = v / v.norm() 258 components.append(SteeringComponent(layer, int(fid), float(s), v)) 259 return cls(components, hook_path="mlp")
Build components from a Drrik-trained SAE's decoder columns.
Arguments:
- sae: A trained
~drrik.autoencoder.SparseAutoencoder. - layer: Layer index the SAE was trained on.
- feature_indices: Feature indices to extract.
- strengths: Magnitude per feature. Defaults to all
1.0.
Returns:
SteeringVectorswithhook_path="mlp".
Raises:
- ValueError: If lengths mismatch or an index is out of range.
261 @classmethod 262 def from_hf_dataset(cls, source: str) -> "SteeringVectors": 263 """Load components from an HF dataset (or local parquet file). 264 265 The dataset must contain ``layer``, ``feature_idx``, ``strength``, 266 and ``vector`` (list of floats) columns, as produced by 267 ``drrik extract-vectors``. 268 269 Args: 270 source: HF dataset repo id (e.g. 271 ``"shawon/llama-3.1-8b-instruct_eiffel_tower"``) or a 272 local path to a parquet file. 273 274 Returns: 275 ``SteeringVectors`` with ``hook_path="layer"``. 276 """ 277 from datasets import load_dataset 278 279 p = Path(source) 280 if p.exists(): 281 ds = load_dataset("parquet", data_files=str(p))["train"] 282 else: 283 ds = load_dataset(source)["train"] 284 285 components = [ 286 SteeringComponent( 287 layer=int(row["layer"]), 288 feature_idx=int(row["feature_idx"]), 289 strength=float(row["strength"]), 290 vector=torch.tensor(row["vector"], dtype=torch.float32), 291 ) 292 for row in ds 293 ] 294 return cls(components, hook_path="layer")
Load components from an HF dataset (or local parquet file).
The dataset must contain layer, feature_idx, strength,
and vector (list of floats) columns, as produced by
drrik extract-vectors.
Arguments:
- source: HF dataset repo id (e.g.
"shawon/llama-3.1-8b-instruct_eiffel_tower") or a local path to a parquet file.
Returns:
SteeringVectorswithhook_path="layer".
296 def save(self, path: Union[str, Path]) -> None: 297 """Save components and hook path to a ``.pt`` file. 298 299 Args: 300 path: Destination path. Parent directories are created. 301 """ 302 path = Path(path) 303 path.parent.mkdir(parents=True, exist_ok=True) 304 torch.save( 305 { 306 "hook_path": self.hook_path, 307 "components": [ 308 { 309 "layer": c.layer, 310 "feature_idx": c.feature_idx, 311 "strength": c.strength, 312 "vector": c.vector.cpu(), 313 } 314 for c in self.components 315 ], 316 }, 317 path, 318 ) 319 logger.info(f"Saved {len(self.components)} steering vectors to {path}")
Save components and hook path to a .pt file.
Arguments:
- path: Destination path. Parent directories are created.
321 @classmethod 322 def from_file(cls, path: Union[str, Path]) -> "SteeringVectors": 323 """Load components saved by :meth:`save` (hook path restored). 324 325 Args: 326 path: Path to a ``.pt`` file written by :meth:`save`. 327 328 Returns: 329 The reconstructed ``SteeringVectors``. 330 """ 331 state = torch.load(Path(path), weights_only=True) 332 components = [ 333 SteeringComponent( 334 layer=int(c["layer"]), 335 feature_idx=int(c["feature_idx"]), 336 strength=float(c["strength"]), 337 vector=c["vector"].float(), 338 ) 339 for c in state["components"] 340 ] 341 return cls(components, hook_path=state["hook_path"])
Load components saved by save() (hook path restored).
Arguments:
- path: Path to a
.ptfile written bysave().
Returns:
The reconstructed
SteeringVectors.
276class Config(BaseSettings): 277 """Main configuration class for the Drrik framework. 278 279 Aggregates all sub-configurations (extractor, autoencoder, visualization) 280 into a single object. Settings can be loaded from environment variables 281 (with ``DRIK_`` prefix) or from a YAML config file via the CLI. 282 283 Attributes: 284 extractor: Activation extraction configuration. 285 autoencoder: Sparse autoencoder configuration. 286 visualization: Feature visualization configuration. 287 random_seed: Global random seed for reproducibility. Applied 288 to numpy, torch, and random at pipeline start. 289 log_level: Logging verbosity. One of ``DEBUG``, ``INFO``, 290 ``WARNING``, ``ERROR``, ``CRITICAL``. 291 292 Example: 293 Load from environment variables: 294 295 ```bash 296 export DRIK_RANDOM_SEED=42 297 export DRIK_AUTOENCODER__HIDDEN_DIM=16384 298 config = Config() 299 ``` 300 301 Or construct programmatically: 302 303 ```python 304 config = Config( 305 extractor=ActivationExtractorConfig(...), 306 autoencoder=SparseAutoencoderConfig(activation_dim=2048), 307 ) 308 ``` 309 """ 310 311 extractor: ActivationExtractorConfig = Field( 312 default_factory=ActivationExtractorConfig 313 ) 314 autoencoder: SparseAutoencoderConfig = Field( 315 default_factory=SparseAutoencoderConfig 316 ) 317 visualization: VisualizationConfig = Field(default_factory=VisualizationConfig) 318 319 random_seed: int = Field(default=42, description="Random seed for reproducibility") 320 log_level: str = Field( 321 default="INFO", description="Logging level (DEBUG, INFO, WARNING, ERROR)" 322 ) 323 324 model_config = ConfigDict( 325 env_prefix="DRIK_", 326 env_nested_delimiter="__", 327 ) 328 329 def create_output_dirs(self) -> None: 330 """Create output directories if they don't exist. 331 332 Ensures that both the extractor output directory (if configured) 333 and the visualization output directory exist on disk, creating 334 any missing parent directories as needed. 335 """ 336 if self.extractor.output_dir: 337 self.extractor.output_dir.mkdir(parents=True, exist_ok=True) 338 self.visualization.output_dir.mkdir(parents=True, exist_ok=True)
Main configuration class for the Drrik framework.
Aggregates all sub-configurations (extractor, autoencoder, visualization)
into a single object. Settings can be loaded from environment variables
(with DRIK_ prefix) or from a YAML config file via the CLI.
Attributes:
- extractor: Activation extraction configuration.
- autoencoder: Sparse autoencoder configuration.
- visualization: Feature visualization configuration.
- random_seed: Global random seed for reproducibility. Applied to numpy, torch, and random at pipeline start.
- log_level: Logging verbosity. One of
DEBUG,INFO,WARNING,ERROR,CRITICAL.
Example:
Load from environment variables:
export DRIK_RANDOM_SEED=42 export DRIK_AUTOENCODER__HIDDEN_DIM=16384 config = Config()Or construct programmatically:
config = Config( extractor=ActivationExtractorConfig(...), autoencoder=SparseAutoencoderConfig(activation_dim=2048), )
329 def create_output_dirs(self) -> None: 330 """Create output directories if they don't exist. 331 332 Ensures that both the extractor output directory (if configured) 333 and the visualization output directory exist on disk, creating 334 any missing parent directories as needed. 335 """ 336 if self.extractor.output_dir: 337 self.extractor.output_dir.mkdir(parents=True, exist_ok=True) 338 self.visualization.output_dir.mkdir(parents=True, exist_ok=True)
Create output directories if they don't exist.
Ensures that both the extractor output directory (if configured) and the visualization output directory exist on disk, creating any missing parent directories as needed.
35class EnvironmentSettings(BaseModel): 36 """ 37 Environment settings for API keys and tokens. 38 39 Loads from a YAML config file (default ``settings.yml`` in the 40 project root). Environment variables override file values when 41 set (``HUGGINGFACE_HUB_TOKEN``, ``WANDB_API_KEY``, ``WANDB_PROJECT``). 42 43 Example settings.yml: 44 ```yaml 45 huggingface_hub_token: hf_... 46 wandb_api_key: ... 47 wandb_project: drrik-experiments 48 ``` 49 50 Attributes: 51 huggingface_hub_token: HuggingFace Hub API token for gated models 52 wandb_api_key: Weights & Biases API key for experiment tracking 53 wandb_project: Default wandb project name 54 """ 55 56 huggingface_hub_token: Optional[str] = Field( 57 default=None, 58 description="HuggingFace Hub API token for accessing gated models. " 59 "Get your token at: https://huggingface.co/settings/tokens", 60 ) 61 62 wandb_api_key: Optional[str] = Field( 63 default=None, 64 description="Weights & Biases API key for experiment tracking. " 65 "Get your key at: https://wandb.ai/settings", 66 ) 67 68 wandb_project: str = Field( 69 default="drrik-experiments", description="Default wandb project name" 70 ) 71 72 @classmethod 73 def from_yaml(cls, path: str | Path = DEFAULT_CONFIG_PATH) -> "EnvironmentSettings": 74 """Load settings from a YAML file, with env var overrides. 75 76 Args: 77 path: Path to the YAML config file. If the file does not 78 exist, falls back to environment variables only. 79 80 Returns: 81 An ``EnvironmentSettings`` instance populated from the file 82 and/or environment variables. 83 """ 84 data: dict = {} 85 p = Path(path) 86 if p.exists(): 87 with open(p) as f: 88 data = yaml.safe_load(f) or {} 89 90 # Environment variables override file values 91 for env_key, field_name in [ 92 ("HUGGINGFACE_HUB_TOKEN", "huggingface_hub_token"), 93 ("WANDB_API_KEY", "wandb_api_key"), 94 ("WANDB_PROJECT", "wandb_project"), 95 ]: 96 val = os.environ.get(env_key) 97 if val is not None: 98 data[field_name] = val 99 100 instance = cls(**{k: v for k, v in data.items() if k in cls.model_fields}) 101 102 if instance.huggingface_hub_token: 103 logger.info("HuggingFace Hub token is configured") 104 if instance.wandb_api_key: 105 logger.info("Weights & Biases API key is configured") 106 107 return instance 108 109 @property 110 def use_wandb(self) -> bool: 111 """Check if wandb should be enabled based on API key. 112 113 Returns: 114 ``True`` if an API key is set, ``False`` otherwise. 115 """ 116 return self.wandb_api_key is not None 117 118 @property 119 def has_hf_token(self) -> bool: 120 """Check if a HuggingFace Hub token is available. 121 122 Returns: 123 ``True`` if ``huggingface_hub_token`` is set, ``False`` 124 otherwise. 125 """ 126 return self.huggingface_hub_token is not None 127 128 def get_hf_auth(self) -> Optional[tuple]: 129 """Get HuggingFace authentication tuple for model loading. 130 131 Constructs the authentication argument expected by the 132 ``transformers`` library when loading gated models. 133 134 Returns: 135 A tuple of ``(True, token_string)`` if a token is 136 configured, otherwise ``None``. 137 """ 138 if self.has_hf_token: 139 return (True, self.huggingface_hub_token) 140 return None
Environment settings for API keys and tokens.
Loads from a YAML config file (default settings.yml in the
project root). Environment variables override file values when
set (HUGGINGFACE_HUB_TOKEN, WANDB_API_KEY, WANDB_PROJECT).
Example settings.yml:
huggingface_hub_token: hf_...
wandb_api_key: ...
wandb_project: drrik-experiments
Attributes:
- huggingface_hub_token: HuggingFace Hub API token for gated models
- wandb_api_key: Weights & Biases API key for experiment tracking
- wandb_project: Default wandb project name
HuggingFace Hub API token for accessing gated models. Get your token at: https://huggingface.co/settings/tokens
Weights & Biases API key for experiment tracking. Get your key at: https://wandb.ai/settings
72 @classmethod 73 def from_yaml(cls, path: str | Path = DEFAULT_CONFIG_PATH) -> "EnvironmentSettings": 74 """Load settings from a YAML file, with env var overrides. 75 76 Args: 77 path: Path to the YAML config file. If the file does not 78 exist, falls back to environment variables only. 79 80 Returns: 81 An ``EnvironmentSettings`` instance populated from the file 82 and/or environment variables. 83 """ 84 data: dict = {} 85 p = Path(path) 86 if p.exists(): 87 with open(p) as f: 88 data = yaml.safe_load(f) or {} 89 90 # Environment variables override file values 91 for env_key, field_name in [ 92 ("HUGGINGFACE_HUB_TOKEN", "huggingface_hub_token"), 93 ("WANDB_API_KEY", "wandb_api_key"), 94 ("WANDB_PROJECT", "wandb_project"), 95 ]: 96 val = os.environ.get(env_key) 97 if val is not None: 98 data[field_name] = val 99 100 instance = cls(**{k: v for k, v in data.items() if k in cls.model_fields}) 101 102 if instance.huggingface_hub_token: 103 logger.info("HuggingFace Hub token is configured") 104 if instance.wandb_api_key: 105 logger.info("Weights & Biases API key is configured") 106 107 return instance
Load settings from a YAML file, with env var overrides.
Arguments:
- path: Path to the YAML config file. If the file does not exist, falls back to environment variables only.
Returns:
An
EnvironmentSettingsinstance populated from the file and/or environment variables.
109 @property 110 def use_wandb(self) -> bool: 111 """Check if wandb should be enabled based on API key. 112 113 Returns: 114 ``True`` if an API key is set, ``False`` otherwise. 115 """ 116 return self.wandb_api_key is not None
Check if wandb should be enabled based on API key.
Returns:
Trueif an API key is set,Falseotherwise.
118 @property 119 def has_hf_token(self) -> bool: 120 """Check if a HuggingFace Hub token is available. 121 122 Returns: 123 ``True`` if ``huggingface_hub_token`` is set, ``False`` 124 otherwise. 125 """ 126 return self.huggingface_hub_token is not None
Check if a HuggingFace Hub token is available.
Returns:
Trueifhuggingface_hub_tokenis set,Falseotherwise.
128 def get_hf_auth(self) -> Optional[tuple]: 129 """Get HuggingFace authentication tuple for model loading. 130 131 Constructs the authentication argument expected by the 132 ``transformers`` library when loading gated models. 133 134 Returns: 135 A tuple of ``(True, token_string)`` if a token is 136 configured, otherwise ``None``. 137 """ 138 if self.has_hf_token: 139 return (True, self.huggingface_hub_token) 140 return None
Get HuggingFace authentication tuple for model loading.
Constructs the authentication argument expected by the
transformers library when loading gated models.
Returns:
A tuple of
(True, token_string)if a token is configured, otherwiseNone.
143class WandbConfig: 144 """ 145 Configuration for wandb experiment tracking. 146 147 This class handles wandb initialization, logging, and cleanup. 148 It's designed to be used as a context manager for automatic cleanup. 149 150 Example: 151 ```python 152 from drrik.settings import WandbConfig 153 154 # Use as context manager 155 with WandbConfig( 156 project="my-sae-experiment", 157 config={"model": "gemma-2b", "expansion": 8} 158 ) as wandb_logger: 159 wandb_logger.log_metrics({"loss": 0.5, "l0_norm": 10}) 160 161 # Or manually initialize/finalize 162 wandb_config = WandbConfig(project="my-sae-experiment") 163 wandb_config.initialize() 164 wandb_config.log_metrics({"loss": 0.5}) 165 wandb_config.finalize() 166 ``` 167 """ 168 169 def __init__( 170 self, 171 project: Optional[str] = None, 172 name: Optional[str] = None, 173 config: Optional[dict] = None, 174 tags: Optional[list[str]] = None, 175 settings: Optional[EnvironmentSettings] = None, 176 enabled: bool = True, 177 ): 178 """ 179 Initialize wandb configuration. 180 181 Args: 182 project: wandb project name (uses settings default if None) 183 name: Run name (auto-generated if None) 184 config: Configuration dict to log 185 tags: List of tags for the run 186 settings: EnvironmentSettings instance (uses global if None) 187 enabled: If False, disables wandb even if configured 188 """ 189 self.settings = settings or EnvironmentSettings.from_yaml() 190 self.enabled = enabled and self.settings.use_wandb 191 192 if self.enabled and not self.settings.wandb_api_key: 193 logger.warning( 194 "wandb is enabled but no API key is set. " 195 "Set WANDB_API_KEY environment variable or disable wandb." 196 ) 197 self.enabled = False 198 199 self.project = project or self.settings.wandb_project 200 self.name = name 201 self.config = config or {} 202 self.tags = tags 203 204 self._initialized = False 205 self._run = None 206 207 def initialize(self) -> bool: 208 """ 209 Initialize wandb run. 210 211 Returns: 212 True if wandb was successfully initialized, False otherwise 213 """ 214 if not self.enabled: 215 logger.info("wandb is disabled") 216 return False 217 218 if self._initialized: 219 logger.warning("wandb is already initialized") 220 return True 221 222 try: 223 import wandb 224 225 # Set API key 226 os.environ["WANDB_API_KEY"] = self.settings.wandb_api_key 227 228 # Initialize run 229 self._run = wandb.init( 230 project=self.project, 231 name=self.name, 232 config=self.config, 233 tags=self.tags, 234 reinit=True, # Allow re-initialization 235 ) 236 237 self._initialized = True 238 logger.info(f"wandb initialized: {self.get_run_url()}") 239 return True 240 241 except ImportError: 242 logger.warning( 243 "wandb package not installed. Install it with: pip install wandb" 244 ) 245 self.enabled = False 246 return False 247 except Exception as e: 248 logger.error(f"Failed to initialize wandb: {e}") 249 self.enabled = False 250 return False 251 252 def finalize(self) -> None: 253 """Finalize wandb run.""" 254 if self._initialized: 255 try: 256 import wandb 257 258 wandb.finish() 259 logger.info("wandb run finalized") 260 except Exception as e: 261 logger.error(f"Error finalizing wandb: {e}") 262 finally: 263 self._initialized = False 264 self._run = None 265 266 def log_metrics( 267 self, 268 metrics: dict, 269 step: Optional[int] = None, 270 commit: bool = True, 271 ) -> None: 272 """ 273 Log metrics to wandb. 274 275 Args: 276 metrics: Dictionary of metric names to values 277 step: Current step (for logging to specific step) 278 commit: Whether to commit the log 279 """ 280 if self._initialized: 281 try: 282 import wandb 283 284 wandb.log(metrics, step=step, commit=commit) 285 except Exception as e: 286 logger.error(f"Error logging to wandb: {e}") 287 288 def log_histogram( 289 self, 290 values, 291 name: str, 292 step: Optional[int] = None, 293 ) -> None: 294 """ 295 Log a histogram to wandb. 296 297 Args: 298 values: Array-like values to histogram 299 name: Name of the histogram 300 step: Current step 301 """ 302 if self._initialized: 303 try: 304 import wandb 305 import numpy as np 306 307 wandb.log({name: wandb.Histogram(np.array(values))}, step=step) 308 except Exception as e: 309 logger.error(f"Error logging histogram to wandb: {e}") 310 311 def log_model(self, model_path: str, name: str = "model") -> None: 312 """ 313 Log a model artifact to wandb. 314 315 Supports both single files and directories. 316 317 Args: 318 model_path: Path to the model file or directory 319 name: Artifact name 320 """ 321 if self._initialized: 322 try: 323 import wandb 324 from pathlib import Path 325 326 artifact = wandb.Artifact(name, type="model") 327 path = Path(model_path) 328 if path.is_dir(): 329 artifact.add_dir(str(path), name=path.name) 330 else: 331 artifact.add_file(str(path)) 332 wandb.log_artifact(artifact) 333 logger.info(f"Logged model artifact: {name} from {model_path}") 334 except Exception as e: 335 logger.error(f"Error logging model to wandb: {e}") 336 337 def log_artifact( 338 self, path: str, name: str, artifact_type: str = "dataset" 339 ) -> None: 340 """ 341 Log a dataset artifact to wandb. 342 343 Supports both single files and directories. 344 345 Args: 346 path: Path to the file or directory 347 name: Artifact name 348 artifact_type: Type of artifact (dataset, model, config, etc.) 349 """ 350 if self._initialized: 351 try: 352 import wandb 353 from pathlib import Path 354 355 artifact = wandb.Artifact(name, type=artifact_type) 356 p = Path(path) 357 if p.is_dir(): 358 artifact.add_dir(str(p), name=p.name) 359 else: 360 artifact.add_file(str(p)) 361 wandb.log_artifact(artifact) 362 logger.info(f"Logged {artifact_type} artifact: {name} from {path}") 363 except Exception as e: 364 logger.error(f"Error logging artifact to wandb: {e}") 365 366 def get_run_url(self) -> Optional[str]: 367 """Get the wandb run URL for the current experiment. 368 369 Returns: 370 The URL string of the active wandb run, or ``None`` if 371 no run has been initialized. 372 """ 373 if self._run: 374 return self._run.url 375 return None 376 377 def get_run_id(self) -> Optional[str]: 378 """Get the wandb run ID for the current experiment. 379 380 Returns: 381 The unique run identifier string, or ``None`` if no run 382 has been initialized. 383 """ 384 if self._run: 385 return self._run.id 386 return None 387 388 def __enter__(self): 389 """Enter the context manager, initializing the wandb run. 390 391 Returns: 392 The ``WandbConfig`` instance for use within the ``with`` 393 block. 394 """ 395 self.initialize() 396 return self 397 398 def __exit__(self, exc_type, exc_val, exc_tb): 399 """Exit the context manager, finalizing the wandb run. 400 401 Args: 402 exc_type: Exception type, if an exception was raised. 403 exc_val: Exception value, if an exception was raised. 404 exc_tb: Exception traceback, if an exception was raised. 405 406 Returns: 407 ``False`` to propagate any exceptions. 408 """ 409 self.finalize() 410 return False
Configuration for wandb experiment tracking.
This class handles wandb initialization, logging, and cleanup. It's designed to be used as a context manager for automatic cleanup.
Example:
from drrik.settings import WandbConfig # Use as context manager with WandbConfig( project="my-sae-experiment", config={"model": "gemma-2b", "expansion": 8} ) as wandb_logger: wandb_logger.log_metrics({"loss": 0.5, "l0_norm": 10}) # Or manually initialize/finalize wandb_config = WandbConfig(project="my-sae-experiment") wandb_config.initialize() wandb_config.log_metrics({"loss": 0.5}) wandb_config.finalize()
169 def __init__( 170 self, 171 project: Optional[str] = None, 172 name: Optional[str] = None, 173 config: Optional[dict] = None, 174 tags: Optional[list[str]] = None, 175 settings: Optional[EnvironmentSettings] = None, 176 enabled: bool = True, 177 ): 178 """ 179 Initialize wandb configuration. 180 181 Args: 182 project: wandb project name (uses settings default if None) 183 name: Run name (auto-generated if None) 184 config: Configuration dict to log 185 tags: List of tags for the run 186 settings: EnvironmentSettings instance (uses global if None) 187 enabled: If False, disables wandb even if configured 188 """ 189 self.settings = settings or EnvironmentSettings.from_yaml() 190 self.enabled = enabled and self.settings.use_wandb 191 192 if self.enabled and not self.settings.wandb_api_key: 193 logger.warning( 194 "wandb is enabled but no API key is set. " 195 "Set WANDB_API_KEY environment variable or disable wandb." 196 ) 197 self.enabled = False 198 199 self.project = project or self.settings.wandb_project 200 self.name = name 201 self.config = config or {} 202 self.tags = tags 203 204 self._initialized = False 205 self._run = None
Initialize wandb configuration.
Arguments:
- project: wandb project name (uses settings default if None)
- name: Run name (auto-generated if None)
- config: Configuration dict to log
- tags: List of tags for the run
- settings: EnvironmentSettings instance (uses global if None)
- enabled: If False, disables wandb even if configured
207 def initialize(self) -> bool: 208 """ 209 Initialize wandb run. 210 211 Returns: 212 True if wandb was successfully initialized, False otherwise 213 """ 214 if not self.enabled: 215 logger.info("wandb is disabled") 216 return False 217 218 if self._initialized: 219 logger.warning("wandb is already initialized") 220 return True 221 222 try: 223 import wandb 224 225 # Set API key 226 os.environ["WANDB_API_KEY"] = self.settings.wandb_api_key 227 228 # Initialize run 229 self._run = wandb.init( 230 project=self.project, 231 name=self.name, 232 config=self.config, 233 tags=self.tags, 234 reinit=True, # Allow re-initialization 235 ) 236 237 self._initialized = True 238 logger.info(f"wandb initialized: {self.get_run_url()}") 239 return True 240 241 except ImportError: 242 logger.warning( 243 "wandb package not installed. Install it with: pip install wandb" 244 ) 245 self.enabled = False 246 return False 247 except Exception as e: 248 logger.error(f"Failed to initialize wandb: {e}") 249 self.enabled = False 250 return False
Initialize wandb run.
Returns:
True if wandb was successfully initialized, False otherwise
252 def finalize(self) -> None: 253 """Finalize wandb run.""" 254 if self._initialized: 255 try: 256 import wandb 257 258 wandb.finish() 259 logger.info("wandb run finalized") 260 except Exception as e: 261 logger.error(f"Error finalizing wandb: {e}") 262 finally: 263 self._initialized = False 264 self._run = None
Finalize wandb run.
266 def log_metrics( 267 self, 268 metrics: dict, 269 step: Optional[int] = None, 270 commit: bool = True, 271 ) -> None: 272 """ 273 Log metrics to wandb. 274 275 Args: 276 metrics: Dictionary of metric names to values 277 step: Current step (for logging to specific step) 278 commit: Whether to commit the log 279 """ 280 if self._initialized: 281 try: 282 import wandb 283 284 wandb.log(metrics, step=step, commit=commit) 285 except Exception as e: 286 logger.error(f"Error logging to wandb: {e}")
Log metrics to wandb.
Arguments:
- metrics: Dictionary of metric names to values
- step: Current step (for logging to specific step)
- commit: Whether to commit the log
288 def log_histogram( 289 self, 290 values, 291 name: str, 292 step: Optional[int] = None, 293 ) -> None: 294 """ 295 Log a histogram to wandb. 296 297 Args: 298 values: Array-like values to histogram 299 name: Name of the histogram 300 step: Current step 301 """ 302 if self._initialized: 303 try: 304 import wandb 305 import numpy as np 306 307 wandb.log({name: wandb.Histogram(np.array(values))}, step=step) 308 except Exception as e: 309 logger.error(f"Error logging histogram to wandb: {e}")
Log a histogram to wandb.
Arguments:
- values: Array-like values to histogram
- name: Name of the histogram
- step: Current step
311 def log_model(self, model_path: str, name: str = "model") -> None: 312 """ 313 Log a model artifact to wandb. 314 315 Supports both single files and directories. 316 317 Args: 318 model_path: Path to the model file or directory 319 name: Artifact name 320 """ 321 if self._initialized: 322 try: 323 import wandb 324 from pathlib import Path 325 326 artifact = wandb.Artifact(name, type="model") 327 path = Path(model_path) 328 if path.is_dir(): 329 artifact.add_dir(str(path), name=path.name) 330 else: 331 artifact.add_file(str(path)) 332 wandb.log_artifact(artifact) 333 logger.info(f"Logged model artifact: {name} from {model_path}") 334 except Exception as e: 335 logger.error(f"Error logging model to wandb: {e}")
Log a model artifact to wandb.
Supports both single files and directories.
Arguments:
- model_path: Path to the model file or directory
- name: Artifact name
337 def log_artifact( 338 self, path: str, name: str, artifact_type: str = "dataset" 339 ) -> None: 340 """ 341 Log a dataset artifact to wandb. 342 343 Supports both single files and directories. 344 345 Args: 346 path: Path to the file or directory 347 name: Artifact name 348 artifact_type: Type of artifact (dataset, model, config, etc.) 349 """ 350 if self._initialized: 351 try: 352 import wandb 353 from pathlib import Path 354 355 artifact = wandb.Artifact(name, type=artifact_type) 356 p = Path(path) 357 if p.is_dir(): 358 artifact.add_dir(str(p), name=p.name) 359 else: 360 artifact.add_file(str(p)) 361 wandb.log_artifact(artifact) 362 logger.info(f"Logged {artifact_type} artifact: {name} from {path}") 363 except Exception as e: 364 logger.error(f"Error logging artifact to wandb: {e}")
Log a dataset artifact to wandb.
Supports both single files and directories.
Arguments:
- path: Path to the file or directory
- name: Artifact name
- artifact_type: Type of artifact (dataset, model, config, etc.)
366 def get_run_url(self) -> Optional[str]: 367 """Get the wandb run URL for the current experiment. 368 369 Returns: 370 The URL string of the active wandb run, or ``None`` if 371 no run has been initialized. 372 """ 373 if self._run: 374 return self._run.url 375 return None
Get the wandb run URL for the current experiment.
Returns:
The URL string of the active wandb run, or
Noneif no run has been initialized.
377 def get_run_id(self) -> Optional[str]: 378 """Get the wandb run ID for the current experiment. 379 380 Returns: 381 The unique run identifier string, or ``None`` if no run 382 has been initialized. 383 """ 384 if self._run: 385 return self._run.id 386 return None
Get the wandb run ID for the current experiment.
Returns:
The unique run identifier string, or
Noneif no run has been initialized.
417def get_settings() -> EnvironmentSettings: 418 """ 419 Get the global EnvironmentSettings instance. 420 421 Returns: 422 The global settings (creates one if it doesn't exist) 423 """ 424 global _global_settings 425 if _global_settings is None: 426 _global_settings = EnvironmentSettings.from_yaml() 427 return _global_settings
Get the global EnvironmentSettings instance.
Returns:
The global settings (creates one if it doesn't exist)