interlens.interp¶
interlens.interp
¶
First-class interpretability layer.
All four tools hook into the same generation path (real turns, sample, and every generation inside the
future tool loop) and are tagged to conversation structure, so downstream consumers (logit lens, SAEs, probes,
CKA/Procrustes) read the ActivationCache via the raw-model escape hatch without any harness change.
ActivationCache
¶
A queryable store of captured activations, tagged by conversation structure.
Records know which participant/turn/layer/site they came from, so a downstream probe can ask for "bob's turn
3, layer 18, the answer span" rather than juggling anonymous tensors. This is the object every interp
consumer reads; the harness never puts activations in Message.metadata (that would blow up branch()'s
transcript copy), so the cache is the single home for heavy tensors.
Source code in src/interlens/interp/activation_cache.py
add_batch
¶
Add many records at once, offloading all their tensors in one batched pinned transfer (see
offload_to_cpu). Preferred over a loop of add when a single capture pass produces many records —
it turns N GPU->CPU copies into one per shape-group.
Source code in src/interlens/interp/activation_cache.py
at
¶
Return the single matching tensor, erroring if the filters aren't unique — the ergonomic accessor for "give me exactly this activation".
Source code in src/interlens/interp/activation_cache.py
ActivationRecord
dataclass
¶
ActivationRecord(
participant: str,
message_idx: int,
layer: int,
site: Site,
tensor: Tensor,
token_span: tuple[int, int],
phases: dict[Phase, tuple[int, int]] = dict(),
)
One captured tensor plus everything needed to know what it is.
tensor is [seq, d_model] for one (participant turn, layer, site). phases maps prompt /
reasoning / answer to (start, end) token indices into that sequence, so reasoning-vs-answer
activations are separable for CoT models. token_span is the (prompt_len, seq_len) boundary between
the fed-in prompt and the newly generated tokens.
CaptureRequest
dataclass
¶
A pending capture handed to generate: where to store records (cache) and what to grab (spec).
The Conversation builds this (via conv.capture(...)) and the participant fills the cache with records
tagged by participant + turn, so the caller ends up with structurally-tagged activations.
CaptureSpec
dataclass
¶
CaptureSpec(
sites: tuple[Site, ...] = ("residual",),
layers: tuple[int, ...] | None = None,
offload: OffloadLocation = "cpu",
)
What to capture during a generation: which sites at which layers, and where to keep the tensors.
Capture defaults to a narrow set — you pass the layers/sites you actually want — because capturing all
layers × all tokens × many rollouts OOMs fast. offload='cpu' moves captured tensors off-GPU as they are
recorded (essential for large sweeps); offload=None keeps them on-device.
CapturedSite
¶
Bases: NamedTuple
One activation captured by capture_activations: the tensor ([seq, d_model]) at a given
layer and site. A lightweight typed row (unpacks like the old (layer, site, tensor) tuple) that
the participant folds into a fully-tagged ActivationRecord.
GradCaptureSpec
dataclass
¶
What grad-connected activations to pull from forward_with_grad (mirrors CaptureSpec minus offload).
sites is any of "residual"/"attn"/"mlp"; layers selects decoder-layer indices (None =
all). Unlike CaptureSpec there is no offload — the whole point is to keep tensors on-device and in the
autograd graph, so an intermediate-layer objective (e.g. project onto a concept direction) can be backpropagated.
GradForwardOutput
¶
Bases: NamedTuple
Result of forward_with_grad: grad-connected logits ([batch, seq, vocab]) and, if a
GradCaptureSpec was passed, hidden mapping (site, layer) -> tensor[batch, seq, d_model] (also
grad-connected). hidden is empty when no capture was requested.
LinearBridge
¶
Bases: Module
Learned linear map from model A's hidden width d_a to model B's embedding width d_b.
For heterogeneous (different-tokenizer) pairs where a vocab mixture is undefined: take A's last-layer hidden
states (via forward_with_grad(..., capture=GradCaptureSpec(sites=('residual',), layers=(last,)))), map them
into B's embedding space, and pass the result as inputs_embeds to B. Trained jointly with A against B's loss.
bias=False by default to match the (linear, origin-preserving) cross-model maps used in the Procrustes work.
Source code in src/interlens/interp/bridge.py
forward
¶
[batch, seq, d_a] -> [batch, seq, d_b] soft embeddings in B's input space.
Patch
dataclass
¶
Activation patching: overwrite a decoder layer's residual at specific token positions with saved
activations (captured from another run/branch).
This is the cross-branch causal-tracing primitive: capture activations at turn N in one branch (via
ActivationCache), then inject them at the aligned positions of another branch's forward. The harness owns
this because only it knows the turn/position correspondence between branches.
P2 applies the patch on the (single) prompt forward — positions index into the prompt sequence. Aligning
positions across branches is the caller's responsibility; Patch just performs the overwrite.
RouterSteeringSpec
dataclass
¶
A causal intervention on MoE routing: add a per-expert bias to the gate logits during generation, nudging
which experts fire. The routing analogue of :class:SteeringSpec (which steers the residual stream) — here
forward hooks sit on each sparse layer's mlp.gate (the router nn.Linear whose output is the
[tokens, n_experts] logits) and add coef * bias before top-k selection.
Build it with :meth:toward_load to push routing toward a target expert-usage distribution (e.g. the MoE's
solo-on-domain RoutingStats.expert_load): the bias is log(target + eps), so adding it acts like a
log-prior that shifts the router's softmax toward the target experts, with coef the strength (0 = no-op).
A summary (layers, coef, per-layer bias norm) is available via :meth:summary for reproducibility.
register
¶
Register the gate-bias hooks on model's sparse layers; returns handles (caller removes after use).
Source code in src/interlens/interp/routing.py
toward_load
classmethod
¶
toward_load(
target_load: Tensor,
layers: tuple[int, ...],
coef: float = 1.0,
eps: float = 1e-06,
) -> "RouterSteeringSpec"
Steer toward a target expert-usage distribution target_load ([len(layers), n_experts], rows the
fraction of routing mass per expert). Bias = log(target_load + eps) (a log-prior on expert choice).
Source code in src/interlens/interp/routing.py
RoutingCapture
¶
Bases: NamedTuple
Per-token routing at one MoE layer, from one capture_router_logits pass.
router_logits is the raw pre-softmax gate output [seq, n_experts] (cpu fp32), or None when the
capture was compacted with top_k_only=True. topk_experts / topk_probs are always present: the
k selected expert ids (int16) and their softmax router probabilities (fp16), [seq, k] each.
RoutingStats
dataclass
¶
RoutingStats(
expert_load: Tensor,
expert_mass: Tensor | None,
layers: tuple[int, ...],
n_tokens: int,
top_k: int,
)
Aggregate expert-usage distributions over a set of token positions.
expert_load[l, e] is the fraction of top-k selections at layer l that went to expert e
(rows sum to 1 — the discrete "which experts fired" histogram). expert_mass[l, e] is the mean softmax
router probability (None if the captures were top_k_only — full logits are needed for mass).
layers are the decoder-layer indices of the rows; n_tokens is how many positions were pooled.
SteeringSpec
dataclass
¶
A residual-stream intervention applied during generation via forward hooks on decoder layers.
mode='add' adds coef * direction to the residual at layers; mode='ablate' projects the
direction component out of the residual (directional ablation). The same mechanism covers both because
ablation is just the projection-removal variant of an additive hook.
A summary (mode, layers, coef, direction norm) is recorded into Message.metadata['steering'] by the
participant so a steered/ablated turn is reproducible.
difference_of_means
classmethod
¶
difference_of_means(
pos_acts,
neg_acts,
layers,
coef: float = 1.0,
mode: Mode = "add",
) -> "SteeringSpec"
Build a spec whose direction is the unit difference-of-means of two activation populations,
normalize(mean(pos) − mean(neg)) — the classic contrastive/concept-direction recipe, pointing TOWARD
the positive class. pos_acts/neg_acts are [n, d_model] (a stack of per-example residuals) or a
pre-pooled [d_model] vector; anything torch.as_tensor accepts works. Steer toward the positive
concept with coef>0 and away from it (suppress) with coef<0. layers is an int or an
iterable of decoder-layer indices. This lives here (not re-hand-rolled per experiment) per the
contribution convention.
Source code in src/interlens/interp/steering.py
register
¶
Register the steering hooks on model and return the handles (caller removes them after generate).
Source code in src/interlens/interp/steering.py
capture_activations
¶
capture_activations(
model: "PreTrainedModel",
input_ids: Tensor,
spec: CaptureSpec,
) -> list[CapturedSite]
Run one clean forward pass over input_ids and return [(layer, site, tensor[seq, d_model])].
Design choice: capture is a separate forward pass over the full (prompt + generated) sequence rather than
accumulating hooks across the multi-step decode loop. This is simpler and provably complete — every position
is present in one pass — at the cost of one extra forward. Residual-stream activations come from
output_hidden_states (no hooks needed); attn/mlp sublayer outputs come from forward hooks on the
corresponding submodules.
Note: attn captures the attention sublayer output (post-o_proj), which is available under any attention
backend. Attention weights/patterns are NOT captured here — those require attn_implementation='eager' +
output_attentions and are out of scope for the default kernel.
Source code in src/interlens/interp/capture.py
capture_router_logits
¶
capture_router_logits(
model: "PreTrainedModel",
input_ids: Tensor,
layers: tuple[int, ...] | None = None,
top_k_only: bool = False,
offload: str = "cpu",
) -> list[RoutingCapture]
One clean forward pass over input_ids ([1, seq]); return per-MoE-layer RoutingCapture.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
'PreTrainedModel'
|
an HF MoE causal LM whose |
required |
input_ids
|
Tensor
|
full token sequence to route, shape |
required |
layers
|
tuple[int, ...] | None
|
decoder-layer indices to keep (default: all sparse layers, per |
None
|
top_k_only
|
bool
|
drop the full |
False
|
offload
|
str
|
device for the returned tensors (default |
'cpu'
|
Source code in src/interlens/interp/routing.py
continuation_logprob
¶
continuation_logprob(
model: "PreTrainedModel",
*,
target_ids: Tensor,
prefix_ids: Tensor | None = None,
prefix_embeds: Tensor | None = None,
attention_mask: Tensor | None = None,
reduction: str = "mean",
checkpoint: bool = False
) -> torch.Tensor
Differentiable teacher-forced logprob of target_ids continuing a prefix, under model.
The grad-enabled analog of train_to_steer/rl_elicit.py::cont_logprob (which is @torch.no_grad and returns
a float). Supply the prefix as prefix_ids ([batch, P] long) OR prefix_embeds ([batch, P, d_model]
float — e.g. a learnable soft prompt, or soft embeddings bridged from model A). target_ids is [batch, T]
long (or [T] broadcast to batch 1). The prefix and target are embedded via model.get_input_embeddings()
and run in one forward; the returned scalar (or per-example vector, see reduction) is the logprob the model
assigns to the exact target tokens, still attached to the graph.
reduction: "mean" (mean over target tokens, mean over batch -> scalar; the reward used by rl_elicit),
"sum" (sum over target tokens, mean over batch), or "none" ([batch, T] per-token logprobs). Use
"mean" as a length-normalized elicitation reward, "sum" when total sequence likelihood matters, "none"
for custom weighting. checkpoint is forwarded to save memory when backpropagating through a large frozen B.
Source code in src/interlens/interp/grad.py
decoder_layers
¶
Return the list of transformer decoder layer modules, across common HF architectures.
Steering/patching hooks and per-layer capture all need the ordered layer stack. Qwen and Gemma both expose
it at model.model.layers; a few fallbacks cover other families so the interp layer isn't Qwen/Gemma-only.
Multimodal image-text-to-text wrappers (Qwen 3.5, Gemma 4, …) nest the text decoder one level deeper — its
layers live at model.language_model.layers (or .model.language_model.model.layers) — so those paths are
included too. PEFT/adapter wrappers (PeftModelForCausalLM) are unwrapped first so hooks land on the real
decoder layers.
Source code in src/interlens/interp/layers.py
forward_with_grad
¶
forward_with_grad(
model: "PreTrainedModel",
*,
input_ids: Tensor | None = None,
inputs_embeds: Tensor | None = None,
attention_mask: Tensor | None = None,
capture: GradCaptureSpec | None = None,
checkpoint: bool = False
) -> GradForwardOutput
One grad-connected forward pass over input_ids XOR inputs_embeds.
Exactly one of input_ids ([batch, seq] long) or inputs_embeds ([batch, seq, d_model] float,
typically the output of bridge.soft_embed or a learnable soft prompt) must be given. attention_mask is
optional ([batch, seq]). Returns logits and any requested hidden activations, all still attached to the
graph so .backward() flows back to inputs_embeds / soft-prompt params / an upstream model A.
checkpoint=True enables HF gradient checkpointing for this call (use_reentrant=False) and restores the
prior setting afterwards; it recomputes layer activations on the backward pass to trade compute for memory, and
produces identical gradients. Residual sites come from output_hidden_states (grad-connected); attn/mlp
sites come from non-detaching forward hooks on the submodules.
Source code in src/interlens/interp/grad.py
gumbel_softmax_tokens
¶
Relaxed (Gumbel-softmax) sample over the last (vocab) dim of logits, differentiable in logits.
tau is the temperature: high -> smooth mixture (biased but low-variance gradients), low -> near-one-hot
(faithful to discrete sampling but higher-variance). Anneal tau down over training to move from an easy soft
optimization toward the discrete tokens B will actually see. hard=True uses the straight-through estimator: a
true one-hot on the forward pass, softmax gradient on the backward pass — so the value B consumes is a real token
while gradients still flow. Returns [..., V] to feed straight into soft_embed.
Source code in src/interlens/interp/bridge.py
js_divergence
¶
Per-layer Jensen–Shannon divergence (symmetric, bounded by ln 2) → [n_layers].
kl_divergence
¶
Per-layer KL(p || q) between expert distributions [n_layers, n_experts] → [n_layers].
Source code in src/interlens/interp/routing.py
message_token_spans
¶
message_token_spans(
tokenizer: "PreTrainedTokenizerBase", view: list[dict]
) -> list[tuple[int, int]]
Token span (start, end) of each message of view in the fully-rendered chat-template sequence.
Method: render the string prefixes apply_chat_template(view[:i], tokenize=False) for each i and
require each to be a string-prefix of the next (raised on non-prefix-stable templates). The full string is
then tokenized once with return_offsets_mapping=True and each char boundary is mapped to the first
token whose offset starts at/after it — prefix strings are never tokenized independently, because a
tokenization of a prefix string is not in general a prefix of the full tokenization.
Note this describes the final replayed view (the whole conversation templated once). Live generation builds the sequence incrementally, but for standard chat templates the rendered text is identical, so spans match. The returned spans cover each message's rendered chunk including its role header/footer markup.
Source code in src/interlens/interp/routing.py
moe_layer_indices
¶
Decoder-layer indices that carry a sparse MoE block.
HF MoE models return router_logits only for the sparse layers, in layer order, with no index
attached. This maps that tuple back to real decoder-layer indices by checking each layer's mlp for a
router gate submodule — which handles mixed stacks (e.g. Qwen-MoE decoder_sparse_step /
mlp_only_layers leaving some layers dense) as well as fully-sparse stacks like OLMoE.
Source code in src/interlens/interp/routing.py
moe_num_experts
¶
Number of routed experts per MoE layer (config.num_experts — same field name in OLMoE/Qwen-MoE).
moe_topk
¶
routing_stats
¶
routing_stats(
captures: list[RoutingCapture],
n_experts: int,
spans: list[tuple[int, int]] | None = None,
) -> RoutingStats
Pool per-token routing into per-layer expert-usage distributions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
captures
|
list[RoutingCapture]
|
output of |
required |
n_experts
|
int
|
total routed experts ( |
required |
spans
|
list[tuple[int, int]] | None
|
optional |
None
|
Source code in src/interlens/interp/routing.py
soft_embed
¶
Mix model's input-embedding rows by a per-position distribution: [..., V] @ E[V, d] -> [..., d].
probs is a (relaxed or one-hot) distribution over model's vocab for each position — e.g. a softmax /
gumbel_softmax_tokens output of an upstream model. Returns grad-connected soft embeddings suitable to pass as
inputs_embeds to forward_with_grad / continuation_logprob. This is exact for hard one-hots: feeding
one_hot(ids) reproduces model.get_input_embeddings()(ids).
Source code in src/interlens/interp/bridge.py
token_logprobs
¶
Compute per-token logprobs / surprisal / entropy for a generation.
scores is the tuple of per-step logit tensors from model.generate(..., output_scores=True,
return_dict_in_generate=True) (one [vocab] per generated token); generated_ids are the sampled
token ids. Returns lists suitable to drop into Message.metadata — scalar-per-token, so they stay small
and don't violate the "no heavy tensors in metadata" invariant.
Surprisal = -logprob (nats); entropy is the full next-token distribution entropy at each step (a readout of the model's uncertainty, distinct from the surprisal of the token it actually emitted).
Source code in src/interlens/interp/logprobs.py
topk_expert_overlap
¶
Per-layer fraction of overlap between the k most-used experts of p and of q → [n_layers].