Skip to content

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

ActivationCache(offload: OffloadLocation = 'cpu')

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
def __init__(self, offload: OffloadLocation = "cpu"):
	self.offload = offload
	self.records: list[ActivationRecord] = []

add_batch

add_batch(records: list[ActivationRecord]) -> None

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
def add_batch(self, records: list[ActivationRecord]) -> None:
	"""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."""
	if not records:
		return
	if self.offload == "cpu":
		offloaded = offload_to_cpu([r.tensor for r in records])
		for r, t in zip(records, offloaded):
			r.tensor = t
	else:
		for r in records:
			r.tensor = r.tensor.detach()
	self.records.extend(records)

at

at(
    *,
    participant=None,
    message_idx=None,
    layer=None,
    site="residual"
) -> torch.Tensor

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
def at(self, *, participant=None, message_idx=None, layer=None, site="residual") -> torch.Tensor:
	"""Return the single matching tensor, erroring if the filters aren't unique — the ergonomic accessor for
	"give me exactly this activation"."""
	matches = self.query(participant=participant, message_idx=message_idx, layer=layer, site=site)
	if len(matches) != 1:
		raise KeyError(f"expected exactly one record, got {len(matches)} for "
		               f"participant={participant} message_idx={message_idx} layer={layer} site={site}")
	return matches[0].tensor

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

CaptureRequest(cache: ActivationCache, spec: CaptureSpec)

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

GradCaptureSpec(
    sites: tuple[Site, ...] = ("residual",),
    layers: tuple[int, ...] | None = None,
)

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

LinearBridge(d_a: int, d_b: int, bias: bool = False)

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
def __init__(self, d_a: int, d_b: int, bias: bool = False):
	super().__init__()
	self.proj = nn.Linear(d_a, d_b, bias=bias)

forward

forward(hidden: Tensor) -> torch.Tensor

[batch, seq, d_a] -> [batch, seq, d_b] soft embeddings in B's input space.

Source code in src/interlens/interp/bridge.py
def forward(self, hidden: torch.Tensor) -> torch.Tensor:
	"""``[batch, seq, d_a] -> [batch, seq, d_b]`` soft embeddings in B's input space."""
	w = self.proj.weight
	return self.proj(hidden.to(dtype=w.dtype, device=w.device))

Patch dataclass

Patch(
    activations: Tensor,
    layer: int,
    positions: tuple[int, ...],
)

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

RouterSteeringSpec(
    bias: Tensor, layers: tuple[int, ...], coef: float = 1.0
)

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(model: 'PreTrainedModel') -> list

Register the gate-bias hooks on model's sparse layers; returns handles (caller removes after use).

Source code in src/interlens/interp/routing.py
def register(self, model: "PreTrainedModel") -> list:
	"""Register the gate-bias hooks on ``model``'s sparse layers; returns handles (caller removes after use)."""
	layers = decoder_layers(model)
	sparse = set(moe_layer_indices(model))
	handles = []
	for row, li in enumerate(self.layers):
		if li not in sparse:
			raise ValueError(f"layer {li} is not a sparse MoE layer; cannot steer its router")
		handles.append(layers[li].mlp.gate.register_forward_hook(self._hook(self.bias[row])))
	return handles

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
@classmethod
def toward_load(cls, target_load: torch.Tensor, layers: tuple[int, ...], coef: float = 1.0,
                eps: float = 1e-6) -> "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)."""
	return cls(bias=torch.log(target_load + eps), layers=tuple(layers), coef=coef)

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

SteeringSpec(
    direction: Tensor,
    layers: tuple[int, ...],
    coef: float = 1.0,
    mode: Mode = "add",
)

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
@classmethod
def difference_of_means(cls, 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."""
	pos = torch.as_tensor(pos_acts, dtype=torch.float32)
	neg = torch.as_tensor(neg_acts, dtype=torch.float32)
	pm = pos if pos.ndim == 1 else pos.mean(0)
	nm = neg if neg.ndim == 1 else neg.mean(0)
	d = pm - nm
	d = d / (d.norm() + 1e-8)
	layers = (layers,) if isinstance(layers, int) else tuple(layers)
	return cls(direction=d, layers=layers, coef=coef, mode=mode)

register

register(model: 'PreTrainedModel') -> list

Register the steering hooks on model and return the handles (caller removes them after generate).

Source code in src/interlens/interp/steering.py
def register(self, model: "PreTrainedModel") -> list:
	"""Register the steering hooks on ``model`` and return the handles (caller removes them after generate)."""
	layers = decoder_layers(model)
	handles = []
	for li in self.layers:
		handles.append(layers[li].register_forward_hook(self._hook()))
	return handles

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
def capture_activations(model: "PreTrainedModel", input_ids: torch.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.
	"""
	layers = decoder_layers(model)
	want = spec.layers if spec.layers is not None else tuple(range(len(layers)))
	sites = set(spec.sites)

	results: list[CapturedSite] = []
	handles = []
	hook_store: dict[tuple[int, Site], torch.Tensor] = {}

	def make_hook(layer_idx: int, site: Site):
		def hook(module, inputs, output):
			hs = output[0] if isinstance(output, tuple) else output
			hook_store[(layer_idx, site)] = hs.detach()[0]  # [seq, d_model]
		return hook

	for li in want:
		if "attn" in sites:
			handles.append(layers[li].self_attn.register_forward_hook(make_hook(li, "attn")))
		if "mlp" in sites:
			handles.append(layers[li].mlp.register_forward_hook(make_hook(li, "mlp")))

	need_hidden = "residual" in sites
	try:
		with torch.inference_mode():
			out = model(input_ids, output_hidden_states=need_hidden, use_cache=False)
	finally:
		for h in handles:
			h.remove()

	if need_hidden:
		# hidden_states is (embeddings, layer_0_out, ..., layer_{L-1}_out); layer li output is index li+1.
		hs = out.hidden_states
		for li in want:
			results.append(CapturedSite(li, "residual", hs[li + 1][0]))
	for (li, site), tensor in hook_store.items():
		results.append(CapturedSite(li, site, tensor))

	return results

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 forward accepts output_router_logits=True (OLMoE, Qwen-MoE, Mixtral, ...).

required
input_ids Tensor

full token sequence to route, shape [1, seq] (batch of 1 — replay one view at a time).

required
layers tuple[int, ...] | None

decoder-layer indices to keep (default: all sparse layers, per moe_layer_indices).

None
top_k_only bool

drop the full [seq, n_experts] logits and keep only top-k ids/probs (int16/fp16, ~8x smaller — use for long-sequence sweeps over large MoEs like Qwen3-30B-A3B).

False
offload str

device for the returned tensors (default "cpu" so GPU memory is freed immediately).

'cpu'
Source code in src/interlens/interp/routing.py
def capture_router_logits(model: "PreTrainedModel", input_ids: torch.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:
		model: an HF MoE causal LM whose ``forward`` accepts ``output_router_logits=True`` (OLMoE, Qwen-MoE,
			Mixtral, ...).
		input_ids: full token sequence to route, shape ``[1, seq]`` (batch of 1 — replay one view at a time).
		layers: decoder-layer indices to keep (default: all sparse layers, per ``moe_layer_indices``).
		top_k_only: drop the full ``[seq, n_experts]`` logits and keep only top-k ids/probs (int16/fp16,
			~8x smaller — use for long-sequence sweeps over large MoEs like Qwen3-30B-A3B).
		offload: device for the returned tensors (default ``"cpu"`` so GPU memory is freed immediately).
	"""
	sparse = moe_layer_indices(model)
	want = set(layers if layers is not None else sparse)
	k = moe_topk(model)

	with torch.inference_mode():
		out = model(input_ids.to(model.device), output_router_logits=True, use_cache=False)
	if getattr(out, "router_logits", None) is None:
		raise ValueError(f"{type(model).__name__} did not return router_logits — not an MoE forward?")

	results: list[RoutingCapture] = []
	for layer_idx, logits in zip(sparse, out.router_logits):
		if layer_idx not in want:
			continue
		logits = logits.detach().float()  # [seq, n_experts] (HF flattens batch=1 into seq)
		if logits.dim() == 3:
			logits = logits[0]
		probs = torch.softmax(logits, dim=-1)
		topk_probs, topk_ids = probs.topk(k, dim=-1)
		results.append(RoutingCapture(
			layer=layer_idx,
			router_logits=None if top_k_only else logits.to(offload),
			topk_experts=topk_ids.to(torch.int16).to(offload),
			topk_probs=topk_probs.to(torch.float16).to(offload),
		))
	return results

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
def continuation_logprob(
	model: "PreTrainedModel",
	*,
	target_ids: torch.Tensor,
	prefix_ids: torch.Tensor | None = None,
	prefix_embeds: torch.Tensor | None = None,
	attention_mask: torch.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.
	"""
	if (prefix_ids is None) == (prefix_embeds is None):
		raise ValueError("pass exactly one of prefix_ids or prefix_embeds")
	embed = model.get_input_embeddings()
	device = embed.weight.device
	if target_ids.dim() == 1:
		target_ids = target_ids.unsqueeze(0)
	target_ids = target_ids.to(device)

	if prefix_embeds is None:
		if prefix_ids.dim() == 1:
			prefix_ids = prefix_ids.unsqueeze(0)
		prefix_embeds = embed(prefix_ids.to(device))
	prefix_embeds = prefix_embeds.to(device)
	if prefix_embeds.shape[0] == 1 and target_ids.shape[0] > 1:
		prefix_embeds = prefix_embeds.expand(target_ids.shape[0], -1, -1)

	target_embeds = embed(target_ids)
	full = torch.cat([prefix_embeds, target_embeds], dim=1)
	fout = forward_with_grad(model, inputs_embeds=full, attention_mask=attention_mask, checkpoint=checkpoint)

	P = prefix_embeds.shape[1]
	T = target_ids.shape[1]
	# Logits at position i predict token i+1; the T target tokens sit at positions P..P+T-1, so the logits that
	# predict them are at positions P-1..P+T-2.
	pred_logits = fout.logits[:, P - 1: P + T - 1, :].float()
	logp = torch.log_softmax(pred_logits, dim=-1)
	tok_logp = logp.gather(2, target_ids.unsqueeze(2)).squeeze(2)  # [batch, T]

	if reduction == "none":
		return tok_logp
	if reduction == "sum":
		return tok_logp.sum(dim=1).mean()
	if reduction == "mean":
		return tok_logp.mean()
	raise ValueError(f"unknown reduction {reduction!r}")

decoder_layers

decoder_layers(model: 'PreTrainedModel')

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
def decoder_layers(model: "PreTrainedModel"):
	"""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.
	"""
	if hasattr(model, "get_base_model"):  # peft wrapper -> underlying transformers model
		try:
			model = model.get_base_model()
		except Exception:
			pass
	for path in ("model.layers", "transformer.h", "gpt_neox.layers", "model.decoder.layers",
	             "model.language_model.layers", "language_model.layers", "model.language_model.model.layers"):
		obj = model
		try:
			for attr in path.split("."):
				obj = getattr(obj, attr)
			return obj
		except AttributeError:
			continue
	raise AttributeError(f"could not locate decoder layers on {type(model).__name__}")

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
def forward_with_grad(
	model: "PreTrainedModel",
	*,
	input_ids: torch.Tensor | None = None,
	inputs_embeds: torch.Tensor | None = None,
	attention_mask: torch.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.
	"""
	if (input_ids is None) == (inputs_embeds is None):
		raise ValueError("pass exactly one of input_ids or inputs_embeds")

	sites: set[Site] = set(capture.sites) if capture is not None else set()
	need_hidden = "residual" in sites
	need_hooks = bool(sites - {"residual"})  # attn/mlp need submodule hooks; residual comes from hidden_states

	handles = []
	hook_store: dict[tuple[Site, int], torch.Tensor] = {}

	def make_hook(layer_idx: int, site: Site):
		def hook(module, inputs, output):
			hs = output[0] if isinstance(output, tuple) else output
			hook_store[(site, layer_idx)] = hs  # keep grad — do NOT detach
		return hook

	if need_hooks:  # only touch the layer stack when a submodule hook is actually required
		layers = decoder_layers(model)
		hook_layers = capture.layers if capture.layers is not None else tuple(range(len(layers)))
		for li in hook_layers:
			if "attn" in sites:
				handles.append(layers[li].self_attn.register_forward_hook(make_hook(li, "attn")))
			if "mlp" in sites:
				handles.append(layers[li].mlp.register_forward_hook(make_hook(li, "mlp")))

	was_checkpointing = getattr(model, "is_gradient_checkpointing", False)
	try:
		if checkpoint and not was_checkpointing:
			model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
		out = model(
			input_ids=input_ids,
			inputs_embeds=inputs_embeds,
			attention_mask=attention_mask,
			output_hidden_states=need_hidden,
			use_cache=False,
		)
	finally:
		for h in handles:
			h.remove()
		if checkpoint and not was_checkpointing:
			model.gradient_checkpointing_disable()

	hidden: dict[tuple[Site, int], torch.Tensor] = {}
	if need_hidden:
		# hidden_states = (embeddings, layer_0_out, ..., layer_{L-1}_out); layer li output is index li+1.
		hs = out.hidden_states
		want = capture.layers if capture.layers is not None else tuple(range(len(hs) - 1))
		for li in want:
			hidden[("residual", li)] = hs[li + 1]
	hidden.update(hook_store)
	return GradForwardOutput(logits=out.logits, hidden=hidden)

gumbel_softmax_tokens

gumbel_softmax_tokens(
    logits: Tensor, tau: float = 1.0, hard: bool = False
) -> torch.Tensor

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
def gumbel_softmax_tokens(logits: torch.Tensor, tau: float = 1.0, hard: bool = False) -> torch.Tensor:
	"""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``.
	"""
	return F.gumbel_softmax(logits, tau=tau, hard=hard, dim=-1)

js_divergence

js_divergence(p: Tensor, q: Tensor) -> torch.Tensor

Per-layer Jensen–Shannon divergence (symmetric, bounded by ln 2) → [n_layers].

Source code in src/interlens/interp/routing.py
def js_divergence(p: torch.Tensor, q: torch.Tensor) -> torch.Tensor:
	"""Per-layer Jensen–Shannon divergence (symmetric, bounded by ln 2) → ``[n_layers]``."""
	m = 0.5 * (p + q)
	return 0.5 * kl_divergence(p, m) + 0.5 * kl_divergence(q, m)

kl_divergence

kl_divergence(
    p: Tensor, q: Tensor, eps: float = 1e-08
) -> torch.Tensor

Per-layer KL(p || q) between expert distributions [n_layers, n_experts][n_layers].

Source code in src/interlens/interp/routing.py
def kl_divergence(p: torch.Tensor, q: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
	"""Per-layer KL(p || q) between expert distributions ``[n_layers, n_experts]`` → ``[n_layers]``."""
	p = p + eps
	q = q + eps
	p = p / p.sum(-1, keepdim=True)
	q = q / q.sum(-1, keepdim=True)
	return (p * (p / q).log()).sum(-1)

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
def 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.
	"""
	renders = [tokenizer.apply_chat_template(view[:i], tokenize=False) for i in range(1, len(view) + 1)]
	for a, b in zip(renders, renders[1:]):
		if not b.startswith(a):
			raise ValueError("chat template is not prefix-stable; cannot compute message spans by prefix diffing")
	full = renders[-1]
	enc = tokenizer(full, return_offsets_mapping=True, add_special_tokens=False)
	starts = [off[0] for off in enc["offset_mapping"]]
	n = len(starts)

	def tok_at(char_pos: int) -> int:
		for ti in range(n):
			if starts[ti] >= char_pos:
				return ti
		return n

	bounds = [0] + [len(r) for r in renders]
	return [(tok_at(bounds[i]), tok_at(bounds[i + 1])) for i in range(len(view))]

moe_layer_indices

moe_layer_indices(
    model: "PreTrainedModel",
) -> tuple[int, ...]

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
def moe_layer_indices(model: "PreTrainedModel") -> tuple[int, ...]:
	"""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.
	"""
	idx = []
	for i, layer in enumerate(decoder_layers(model)):
		mlp = getattr(layer, "mlp", None)
		if mlp is not None and hasattr(mlp, "gate") and hasattr(mlp, "experts"):
			idx.append(i)
	if not idx:
		raise ValueError(f"{type(model).__name__} has no sparse MoE layers (no mlp.gate/mlp.experts found)")
	return tuple(idx)

moe_num_experts

moe_num_experts(model: 'PreTrainedModel') -> int

Number of routed experts per MoE layer (config.num_experts — same field name in OLMoE/Qwen-MoE).

Source code in src/interlens/interp/routing.py
def moe_num_experts(model: "PreTrainedModel") -> int:
	"""Number of routed experts per MoE layer (``config.num_experts`` — same field name in OLMoE/Qwen-MoE)."""
	return int(model.config.num_experts)

moe_topk

moe_topk(model: 'PreTrainedModel') -> int

Experts selected per token (config.num_experts_per_tok).

Source code in src/interlens/interp/routing.py
def moe_topk(model: "PreTrainedModel") -> int:
	"""Experts selected per token (``config.num_experts_per_tok``)."""
	return int(model.config.num_experts_per_tok)

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 capture_router_logits (all layers share one token sequence).

required
n_experts int

total routed experts (moe_num_experts(model)) — needed to size the histogram since a span may never touch some experts.

required
spans list[tuple[int, int]] | None

optional [(start, end), ...] token windows to restrict to (e.g. only the MoE's own generated messages, from message_token_spans). Default: all positions.

None
Source code in src/interlens/interp/routing.py
def 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:
		captures: output of ``capture_router_logits`` (all layers share one token sequence).
		n_experts: total routed experts (``moe_num_experts(model)``) — needed to size the histogram since a
			span may never touch some experts.
		spans: optional ``[(start, end), ...]`` token windows to restrict to (e.g. only the MoE's own generated
			messages, from ``message_token_spans``). Default: all positions.
	"""
	if not captures:
		raise ValueError("no captures given")
	seq = captures[0].topk_experts.shape[0]
	mask = torch.zeros(seq, dtype=torch.bool)
	if spans is None:
		mask[:] = True
	else:
		for s, e in spans:
			mask[s:e] = True
	n_tok = int(mask.sum())
	if n_tok == 0:
		raise ValueError(f"spans select zero tokens (seq={seq}, spans={spans})")

	k = captures[0].topk_experts.shape[1]
	load = torch.zeros(len(captures), n_experts)
	mass = torch.zeros(len(captures), n_experts) if captures[0].router_logits is not None else None
	for li, cap in enumerate(captures):
		ids = cap.topk_experts[mask].long().reshape(-1)                       # [n_tok * k]
		load[li] = torch.bincount(ids, minlength=n_experts).float() / ids.numel()
		if mass is not None:
			mass[li] = torch.softmax(cap.router_logits[mask], dim=-1).mean(0)
	return RoutingStats(expert_load=load, expert_mass=mass, layers=tuple(c.layer for c in captures),
	                    n_tokens=n_tok, top_k=k)

soft_embed

soft_embed(
    model: "PreTrainedModel", probs: Tensor
) -> torch.Tensor

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
def soft_embed(model: "PreTrainedModel", probs: torch.Tensor) -> torch.Tensor:
	"""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)``.
	"""
	weight = model.get_input_embeddings().weight  # [V, d]
	return probs.to(dtype=weight.dtype, device=weight.device) @ weight

token_logprobs

token_logprobs(
    scores: tuple[Tensor, ...], generated_ids: Tensor
) -> dict

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
def token_logprobs(scores: tuple[torch.Tensor, ...], generated_ids: torch.Tensor) -> dict:
	"""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).
	"""
	if len(scores) == 0:
		return {"logprobs": [], "surprisal": [], "entropy": []}
	# Stack once -> [steps, vocab]; batch all ops so there is a single GPU->CPU sync at the end.
	logits = torch.stack([s[0] for s in scores]).float()
	logp = torch.log_softmax(logits, dim=-1)
	toks = torch.as_tensor(generated_ids, device=logp.device, dtype=torch.long)[: logp.shape[0]]
	lp = logp.gather(1, toks.unsqueeze(1)).squeeze(1)
	ent = -(logp.exp() * logp).sum(dim=-1)
	logprobs = lp.tolist()
	return {
		"logprobs": logprobs,
		"surprisal": (-lp).tolist(),
		"entropy": ent.tolist(),
	}

topk_expert_overlap

topk_expert_overlap(
    p: Tensor, q: Tensor, k: int = 8
) -> torch.Tensor

Per-layer fraction of overlap between the k most-used experts of p and of q[n_layers].

Source code in src/interlens/interp/routing.py
def topk_expert_overlap(p: torch.Tensor, q: torch.Tensor, k: int = 8) -> torch.Tensor:
	"""Per-layer fraction of overlap between the ``k`` most-used experts of ``p`` and of ``q`` → ``[n_layers]``."""
	pi = p.topk(k, dim=-1).indices
	qi = q.topk(k, dim=-1).indices
	out = torch.zeros(p.shape[0])
	for li in range(p.shape[0]):
		out[li] = len(set(pi[li].tolist()) & set(qi[li].tolist())) / k
	return out