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:

ActivationExtractor Loads models and datasets, extracts MLP activations via nnsight. SparseAutoencoder Overcomplete SAE with L1 regularization and dead neuron resampling. FeatureVisualizer Generates density histograms, training curves, and feature dashboards. SAESteering Steers language model generation by adding SAE feature directions to MLP activations during inference. Config Top-level Pydantic settings model aggregating all sub-configurations. EnvironmentSettings Loads API keys and environment variables from .env. WandbConfig Context manager for wandb experiment tracking. get_settings Returns the global EnvironmentSettings singleton.

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]
class ActivationExtractor:
 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 (None until load_model() is called).
  • tokenizer: The HuggingFace tokenizer (None until load_model() is called).
  • dataset: The loaded HuggingFace Dataset (None until load_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")
ActivationExtractor( config: Optional[drrik.config.ActivationExtractorConfig] = None, **kwargs)
 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. If None, 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
config
model
tokenizer
dataset
def load_model(self) -> nnsight.modeling.language.LanguageModel:
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.LanguageModel wrapper.

Raises:
  • RuntimeError: If model loading fails.
def load_dataset(self) -> datasets.arrow_dataset.Dataset:
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.
def extract( self, num_samples: Optional[int] = None) -> Tuple[numpy.ndarray, Dict[str, Any]]:
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, uses config.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.
def save_activations( self, activations: numpy.ndarray, metadata: Dict[str, Any], output_dir: Union[str, pathlib.Path, NoneType] = None) -> pathlib.Path:
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, uses config.output_dir.
Returns:

Path to the saved activations.npy file.

Raises:
  • ValueError: If output_dir is None and no output directory is configured.
def load_activations( self, filepath: Union[str, pathlib.Path]) -> Tuple[numpy.ndarray, Dict[str, Any]]:
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 companion metadata.pkl in the same directory.
  • .pkl (legacy) — loads a dict with "activations" and "metadata" keys.
Arguments:
  • filepath: Path to an .npy or .pkl file.
Returns:

A tuple of (activations array, metadata dict).

Raises:
  • FileNotFoundError: If the file (or companion metadata) does not exist.
class SparseAutoencoder(torch.nn.modules.module.Module):
 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)
SparseAutoencoder( activation_dim: int, hidden_dim: int, l1_coefficient: float = 0.01, normalize_decoder: bool = True, pre_encoder_bias: bool = True)
 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)
activation_dim
hidden_dim
l1_coefficient
normalize_decoder
pre_encoder_bias
encoder
decoder
encoder_bias
training_losses
training_l0_norms
def encode(self, x: torch.Tensor) -> torch.Tensor:
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)

def decode(self, features: torch.Tensor) -> torch.Tensor:
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)

def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
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)

def loss( self, x: torch.Tensor, reconstructed: torch.Tensor, features: torch.Tensor) -> torch.Tensor:
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

def normalize_decoder_weights(self) -> None:
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.

def resample_dead_neurons( self, activations: torch.Tensor, dead_threshold: float = 1e-08, dead_mask: Optional[torch.Tensor] = None) -> int:
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:

  1. Identify neurons that haven't fired above threshold
  2. Compute loss on a batch of data
  3. 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

def fit( self, activations: numpy.ndarray, batch_size: int = 256, num_epochs: int = 100, learning_rate: float = 0.0001, validation_split: float = 0.1, resample_dead_neurons: bool = True, resample_interval: int = 10000, dead_threshold: float = 1e-08, window_size: int = 100, device: Optional[str] = None, verbose: bool = True, wandb_config: Optional[WandbConfig] = None, wandb_enabled: bool = True) -> SparseAutoencoder:
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)

def get_feature_density(self, activations: numpy.ndarray) -> numpy.ndarray:
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,)

def get_top_activating_examples( self, activations: numpy.ndarray, feature_idx: int, k: int = 10) -> Tuple[numpy.ndarray, numpy.ndarray]:
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)

def save(self, filepath: Union[str, pathlib.Path]) -> None:
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.
@classmethod
def load( cls, filepath: Union[str, pathlib.Path]) -> SparseAutoencoder:
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 .pt model file.
Returns:

A SparseAutoencoder instance with restored weights and training metrics.

class FeatureVisualizer:
 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)
FeatureVisualizer( sae: SparseAutoencoder, activations: numpy.ndarray, metadata: Optional[Dict[str, Any]] = None, output_dir: Union[str, pathlib.Path] = './visualizations', style: str = 'whitegrid', dpi: int = 150, wandb_config: Optional[WandbConfig] = None, log_to_wandb: bool = True)
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
sae
activations
metadata
output_dir
dpi
wandb_config
log_to_wandb
def plot_feature_density( self, bins: int = 50, log_scale: bool = True, save_path: Optional[str] = None) -> matplotlib.figure.Figure:
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

def plot_activation_histogram( self, feature_idx: int, bins: int = 100, log_y: bool = True, save_path: Optional[str] = None) -> matplotlib.figure.Figure:
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

def plot_training_curves(self, save_path: Optional[str] = None) -> matplotlib.figure.Figure:
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

def plot_top_features( self, n_features: int = 10, by: str = 'density', save_path: Optional[str] = None) -> matplotlib.figure.Figure:
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

def plot_feature_examples( self, feature_idx: int, k: int = 10, show_text: bool = True, max_text_length: int = 200, save_path: Optional[str] = None) -> matplotlib.figure.Figure:
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

def plot_decoder_weights( self, feature_indices: Optional[List[int]] = None, n_features: int = 10, save_path: Optional[str] = None) -> matplotlib.figure.Figure:
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

def create_feature_dashboard( self, feature_idx: int, save_path: Optional[str] = None) -> matplotlib.figure.Figure:
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

def save_all(self, n_features: int = 10) -> None:
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
class SAESteering:
 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:
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")
SAESteering( source: Union[SparseAutoencoder, SteeringVectors], model_name: str, layer: Optional[int] = None, revision: str = 'main', torch_dtype: str = 'float16', device_map: str = 'auto', trust_remote_code: bool = True, token: Optional[str] = None)
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 (then layer is required) or a SteeringVectors of pre-extracted components (then layer is ignored; layers come from the components).
  • model_name: HuggingFace model identifier for generation.
  • layer: Transformer layer index for a SparseAutoencoder source. Ignored for SteeringVectors.
  • 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_TOKEN from settings.yml when None.
Raises:
source
model
tokenizer
def generate( self, text: str, feature_idx: Optional[int] = None, strength: float = 1.0, max_new_tokens: int = 50, temperature: float = 0.8, top_p: float = 0.9, feature_indices: Optional[List[int]] = None, strengths: Optional[List[float]] = None, clamp: bool = False, repetition_penalty: float = 1.0, system_prompt: Optional[str] = None, strength_scale: float = 1.0, seed: Optional[int] = None) -> str:
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_idx and strength.
  • Multiple features → pass feature_indices and strengths as 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, …] if None.
  • 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.0 disables).
  • 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.0 with 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_indices and strengths have mismatched lengths, a feature index is out of range, or feature selection params are passed with a SteeringVectors source.
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)
def compare_steering( self, text: str, feature_idx: int, strengths: Optional[List[float]] = None, max_new_tokens: int = 50, temperature: float = 0.8, top_p: float = 0.9) -> Dict[str, str]:
 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 strength 0.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}")
def find_steering_features( self, text: str, activations: numpy.ndarray, top_k: int = 20, min_activation: float = 0.0) -> List[Dict[str, Any]]:
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.
def build_token_feature_map( self, tokens: Optional[List[int]] = None, top_k_features: int = 10, min_activation: float = 0.001, batch_size: int = 64) -> Dict[int, Dict[str, Any]]:
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 (default min(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 tokens is provided; otherwise tokens are processed individually. Default 64.
Returns:

Dictionary mapping token IDs to dictionaries containing:

  • active_features (List[int]): Indices of features whose mean activation exceeds min_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
]
def get_steering_direction(self, feature_idx: int, normalize: bool = True) -> numpy.ndarray:
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_idx is out of range [0, sae.hidden_dim).
def save_steering_analysis( self, results: Dict[str, str], output_dir: Union[str, pathlib.Path], prompt: str = '', feature_label: str = '') -> pathlib.Path:
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.

class SteeringVectors:
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")
SteeringVectors( components: List[drrik.steering.SteeringComponent], hook_path: str = 'layer')
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" (hook model.layers[N] output) or "mlp" (hook model.layers[N].mlp output).
Raises:
components
hook_path
layers: List[int]
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.

activation_dim: int
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).

@classmethod
def from_sae( cls, sae: SparseAutoencoder, layer: int, feature_indices: List[int], strengths: Optional[List[float]] = None) -> SteeringVectors:
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:

SteeringVectors with hook_path="mlp".

Raises:
  • ValueError: If lengths mismatch or an index is out of range.
@classmethod
def from_hf_dataset(cls, source: str) -> SteeringVectors:
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:

SteeringVectors with hook_path="layer".

def save(self, path: Union[str, pathlib.Path]) -> None:
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.
@classmethod
def from_file(cls, path: Union[str, pathlib.Path]) -> SteeringVectors:
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 .pt file written by save().
Returns:

The reconstructed SteeringVectors.

class Config(pydantic_settings.main.BaseSettings):
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),
)
extractor: drrik.config.ActivationExtractorConfig = PydanticUndefined
autoencoder: drrik.config.SparseAutoencoderConfig = PydanticUndefined
visualization: drrik.config.VisualizationConfig = PydanticUndefined
random_seed: int = 42

Random seed for reproducibility

log_level: str = 'INFO'

Logging level (DEBUG, INFO, WARNING, ERROR)

def create_output_dirs(self) -> None:
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.

class EnvironmentSettings(pydantic.main.BaseModel):
 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_token: Optional[str] = None

HuggingFace Hub API token for accessing gated models. Get your token at: https://huggingface.co/settings/tokens

wandb_api_key: Optional[str] = None

Weights & Biases API key for experiment tracking. Get your key at: https://wandb.ai/settings

wandb_project: str = 'drrik-experiments'

Default wandb project name

@classmethod
def from_yaml( cls, path: str | pathlib.Path = 'settings.yml') -> EnvironmentSettings:
 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 EnvironmentSettings instance populated from the file and/or environment variables.

use_wandb: bool
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:

True if an API key is set, False otherwise.

has_hf_token: bool
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:

True if huggingface_hub_token is set, False otherwise.

def get_hf_auth(self) -> Optional[tuple]:
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, otherwise None.

class WandbConfig:
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()
WandbConfig( project: Optional[str] = None, name: Optional[str] = None, config: Optional[dict] = None, tags: Optional[list[str]] = None, settings: Optional[EnvironmentSettings] = None, enabled: bool = True)
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
settings
enabled
project
name
config
tags
def initialize(self) -> bool:
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

def finalize(self) -> None:
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.

def log_metrics( self, metrics: dict, step: Optional[int] = None, commit: bool = True) -> None:
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
def log_histogram(self, values, name: str, step: Optional[int] = None) -> None:
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
def log_model(self, model_path: str, name: str = 'model') -> None:
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
def log_artifact(self, path: str, name: str, artifact_type: str = 'dataset') -> None:
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.)
def get_run_url(self) -> Optional[str]:
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 None if no run has been initialized.

def get_run_id(self) -> Optional[str]:
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 None if no run has been initialized.

def get_settings() -> EnvironmentSettings:
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)