Source code for entoscope.probe.input

from collections import defaultdict
from dataclasses import dataclass, field

import numpy as np
import pandas as pd
import torch
from jaxtyping import Float, Int

from entoscope.model import Model
from entoscope.model.activation import check_transformer_activations
from entoscope.model.config import POSITION_AXES, ModelLocatorConfig
from entoscope.model.index import Index
from entoscope.operation.input import OperationInput
from entoscope.probe.config import ProbeConfig
from entoscope.probe.pool import pool


[docs] @dataclass class ProbeInput(OperationInput): """Positive and negative example prompts whose pooled activations become probe training data. The location grid is parsed once. When every prompt tokenizes to the same length the grid uses that length, so a filtered position resolves to a concrete index. When prompt lengths differ the grid is parsed without one: explicit positions stay symbolic, a non-negative one counting from the start and a negative one from each prompt's own end, and a grid that would enumerate every position (an unfiltered ``position``, ``dest_pos`` or ``src_pos`` axis) is rejected because its size would depend on the prompt. Position axes left outside the grid stay full slices and adapt to each prompt. ``model.to_tokens`` prepends BOS, so mean or sum pooling over positions includes it while ``last`` pooling (or a ``-1`` position) reads the final content token. Attributes: config: The probing config. positive_prompts: The examples labeled 1. negative_prompts: The examples labeled 0. """ config: ProbeConfig positive_prompts: list[str] negative_prompts: list[str] _labels: Int[torch.Tensor, " examples"] | None = field(init=False, default=None) _features: dict[int, Float[torch.Tensor, "examples features"]] | None = field(init=False, default=None) _index: Index[ModelLocatorConfig] | None = field(init=False, default=None) _token_tensors: list[Int[torch.Tensor, "batch pos"]] | None = field(init=False, default=None)
[docs] def prepare(self, model: Model, *, index_only: bool = False) -> "ProbeInput": """Resolve the locations the probes read, then pool every prompt's activation at each into its features. Args: model: The model the run reads. index_only: Whether to stop after the locations, before any forward pass. Returns: The input itself, prepared in place. Raises: ValueError: If either set of prompts is empty, the model computes no transformer activation the locator names, an explicit position is out of range for the shortest prompt, or a location's feature size varies across prompts. """ if self._index is None: if not self.positive_prompts or not self.negative_prompts: raise ValueError("Both positive and negative prompts are required to train a probe") self._token_tensors = [model.to_tokens(prompt) for prompt in self.positive_prompts + self.negative_prompts] lengths = {tokens.shape[1] for tokens in self._token_tensors} n_positions = next(iter(lengths)) if len(lengths) == 1 else None source = self.config.source.parse(model, n_positions) self._validate_positions(source.df, min(lengths)) index = Index[ModelLocatorConfig](self.config.source, source) check_transformer_activations(model, index.activation_names()) self._index = index if not index_only and self._features is None: self._features = self._collect_features(model, self.token_tensors, self.index) self._labels = torch.cat( [ torch.ones(len(self.positive_prompts), dtype=torch.long), torch.zeros(len(self.negative_prompts), dtype=torch.long), ] ) return self
[docs] def activation_names(self) -> list[str]: """Name the transformer activations the probes read, which a run caches and a plan reports. Returns: The transformer activation names, sorted. """ return self.index.activation_names()
def _collect_features( self, model: Model, token_tensors: list[Int[torch.Tensor, "batch pos"]], index: Index[ModelLocatorConfig], ) -> dict[int, Float[torch.Tensor, "examples features"]]: vectors: dict[int, list[torch.Tensor]] = defaultdict(list) names = self.activation_names() for tokens in token_tensors: with torch.no_grad(): _, cache = model.run_with_cache(tokens, names_filter=names) for single_index in index: activation = single_index.source.read_slices(cache[single_index.source.transformer_activation()]) assert activation.shape[0] == 1 feature = pool(activation[0], single_index.source.remaining_axes(), self.config.pool) vectors[single_index.output].append(feature.detach().to("cpu", torch.float32)) features: dict[int, Float[torch.Tensor, "examples features"]] = {} for output, feature_list in vectors.items(): if len({feature.shape[0] for feature in feature_list}) != 1: raise ValueError( f"Feature size varies across prompts for index row {output}; a position axis left in the " "features, neither pinned nor pooled, requires equal-length prompts" ) features[output] = torch.stack(feature_list) return features @staticmethod def _validate_positions(index_df: pd.DataFrame, min_length: int) -> None: """Check that every explicit position lands inside the shortest prompt, counted from either end. Args: index_df: The resolved locations. min_length: The shortest prompt's token count. Raises: ValueError: If a value of a position axis is out of range. """ for axis in POSITION_AXES: if axis not in index_df.columns: continue for cell in index_df[axis]: for position in cell if isinstance(cell, list) else [cell]: if isinstance(position, (int, np.integer)) and not -min_length <= position < min_length: raise ValueError( f"'{axis.value}' {int(position)} is out of range for the shortest prompt " f"({min_length} tokens)" ) @property def labels(self) -> Int[torch.Tensor, " examples"]: """Every example's label, positives first, once the input is prepared.""" assert self._labels is not None return self._labels @property def features(self) -> dict[int, Float[torch.Tensor, "examples features"]]: """Each location's features, one row per example, keyed by result row, once the input is prepared.""" assert self._features is not None return self._features @property def index(self) -> Index[ModelLocatorConfig]: """The locations the probes read, once the index is prepared.""" assert self._index is not None return self._index @property def token_tensors(self) -> list[Int[torch.Tensor, "batch pos"]]: """Every example's tokens, positives first, once the index is prepared.""" assert self._token_tensors is not None return self._token_tensors
[docs] def to(self, device: str | torch.device = "cpu") -> "ProbeInput": if self._labels is not None: self._labels = self._labels.to(device) if self._features is not None: self._features = {output: features.to(device) for output, features in self._features.items()} if self._token_tensors is not None: self._token_tensors = [tokens.to(device) for tokens in self._token_tensors] return self