Intervention API Reference¶
This page documents the public intervention API: selectors, edit helpers, hooks, replay and bundles.
To apply one intervention to many forwards or a generate() loop, use spec.bind(model)
(capture-free, about the cost of a plain hook) or tl.record(..., intervene=spec) (the
model output is recording.output) when you need activations too, or rerun a steered trace with
trace.run(model, x) (the guarded fast engine on a selective-save trace, with the spec applied
when it targets modules; see the glossary
entry "Guarded fast rerun"). x, like the inputs of tl.record, takes every input form
tl.trace takes: a tensor, a positional list or tuple, and for a Hugging Face model a string,
PIL image or modality dict ({"text": ..., "image": ...}), resolved with the model's own
tokenizer or AutoProcessor exactly as the trace was. Which one to pick for a loop or for generation:
Performance: choosing a fast path.
Selectors¶
Selectors resolve against completed Trace.layers records.
| Selector | Signature | Use |
|---|---|---|
tl.label |
label(name: str) |
Exact final, raw, short, or pass-qualified label. |
tl.func |
func(name: str) |
Match captured function name OR normalized layer type such as "relu" or "add". |
tl.module |
module(address: str) |
Match a module output boundary. |
tl.label_contains |
label_contains(substring: str) |
Case-insensitive label substring search. |
tl.label_matches |
label_matches(pattern: str) |
Case-sensitive re.search over labels. |
tl.all_sites |
all_sites() |
Every site; the named form of tl.label_matches(".*"). tl.all_sites() - region is the complement of region in the whole trace. |
tl.where |
where(predicate, *, name_hint=None) |
Predicate over layer pass records; non-portable. |
tl.in_module |
in_module(address: str) |
Match sites contained in a module address. |
tl.grad_fn |
grad_fn(name=None, *, label=None, is_custom=None) |
Backward grad_fn by autograd class name, label substring or custom flag (backward sites only). |
One interpreter evaluates every selector in every lifecycle (capture-time
save=, post-hoc find_sites, live hooks); label_contains is case-insensitive and
label_matches case-sensitive everywhere. Post-hoc label_contains/label_matches search the
final layer_label only; exact tl.label additionally matches raw, short,
and pass-qualified spellings. Capture-only selectors (tl.followed_by,
tl.preceded_by) and mutator-only selectors (tl.facet, tl.head) refuse
unsupported lifecycles with the typed SelectorCapabilityError (a
SiteResolutionError subclass) instead of a generic message — upfront, before
any per-site evaluation, for post-hoc resolution and live hook attachment
alike.
Composites (&, |, ~) SHORT-CIRCUIT per site in every lifecycle: a
tl.where predicate is only invoked for sites its siblings have not already
decided, so predicates must not rely on side effects from seeing every site.
&/| build nested binary composites, and deserialized target specs may
carry flat n-ary child tuples; both shapes evaluate identically. Conjunctions
are association-insensitive for the temporal sugar — a & tl.followed_by(x) & b
behaves exactly like a & b & tl.followed_by(x) and the flat three-child
spec — and degenerate arities keep identity semantics in evaluation and spec
round-trips alike: an empty and matches everything, an empty or matches
nothing, and a unary composite matches like its child.
tl.followed_by/tl.preceded_by target specs serialize a selector inner
structurally at every save level; an opaque-callable inner remains audit-only
and refuses typed at rebuild rather than being reconstructed lossily.
Selectors compose with & and | for in-memory discovery:
candidate = tl.in_module("block") & tl.func("relu")
sites = log.find_sites(candidate, max_fanout=8)
exact = tl.label(sites.labels()[0])
The same selector objects can drive capture-time save and intervention predicates:
saved = tl.trace(model, x, save=tl.func("relu"))
windowed = tl.trace(
model,
x,
save=tl.func("conv2d") & tl.followed_by(tl.func("relu")),
capture=CaptureOptions(lookback=4, lookback_payload_policy="detached_raw"),
)
patched = tl.trace(
model,
x,
save=tl.func("relu"),
intervene=tl.when(tl.func("relu"), tl.scale(0.5)),
)
conditional = tl.trace(
model,
x,
intervene=tl.when(tl.func("relu") & tl.preceded_by(tl.in_module("block")), tl.zero_ablate()),
capture=CaptureOptions(lookback=4),
)
For saved or replayed specs, prefer exact tl.label(...) or another simple
portable selector after discovery.
Forward Helpers¶
Forward helpers return Edit objects that can be passed to do (on the replay, rerun or
set-only engine), or live capture hooks=....
| Helper | Signature | Portability |
|---|---|---|
tl.zero_ablate |
zero_ablate(*, allow_shape_change=False) |
Portable built-in; append-compatible unless shape changes are forced. |
tl.mean_ablate |
mean_ablate(source=None, *, dim, allow_shape_change=False) |
The one mean door; dim is required (None for every axis, the axes, or "members" for a population's elementwise member mean). Portable for self and tensor sources; a population source is audit-only; the self mean couples the batch and blocks append. |
scramble_elements |
torchlens.intervention.scramble_elements(source=None, *, seed=None, allow_shape_change=False) (the honest rename of the removed resample_ablate spelling; elementwise iid scramble, NOT coherent resampling ablation -- see MIGRATIONS.md) |
Built-in, stochastic; seeded runs are reproducible, append-incompatible. |
tl.steer |
steer(direction, strength=1.0, *, dim=None, allow_shape_change=False) |
Portable when direction is serializable tensor data. |
tl.scale |
scale(factor, *, allow_shape_change=False) |
Portable built-in and append-compatible. |
tl.clamp |
clamp(*, low=None, high=None, allow_shape_change=False) |
Portable built-in and append-compatible. |
tl.add_noise |
add_noise(std, *, seed=None, allow_shape_change=False) |
Built-in stochastic helper; seeded runs avoid global RNG consumption. |
tl.project_onto |
project_onto(direction, *, dim=None, allow_shape_change=False) |
Portable when direction is tensor data. |
tl.project_out |
project_out(direction, *, dim=None, allow_shape_change=False) |
Portable when direction is tensor data. |
tl.splice_module |
splice_module(module, *, input_route="in", output_route="out", allow_shape_change=False) |
Executable in the same environment; not portable and not append-compatible. |
The packaged SAE splice experiment, torchlens.bridge.sae.splice(...)
(experimental), composes splice_module with fork + do: it swaps a
duck-typed SAE's reconstruction (encode/decode pair; no SAE package
required) back into the model at one site, replays downstream, and reports
reconstruction fidelity plus output-level causal effect. The optional
latents_edit= callable transforms the encoded latents before decoding --
the feature-level causal-testing knob. See
notebooks/sae_splice_tutorial.ipynb for the end-to-end experiment.
Gradient Sites¶
A gradient is addressed as a site, not chosen by a keyword. tl.grad("relu_3") names the gradient of
relu_3's OUTPUT (the tensor trace["relu_3"].grad stores), a bare "relu_3" names its activation, and
tl.grad("relu_3") | "relu_3" names both. tl.grad is an address; it never calls autograd. It wraps a
forward site once and refuses node selectors (tl.grad_fn, ...), facets and selections.
There are no gradient-only helpers. The ordinary edits apply to whichever tensor the site names:
| Edit | On tl.grad(site) |
|---|---|
tl.scale(f), tl.zero_ablate(), tl.clamp(low=lo, high=hi), tl.add_noise(std, seed=) |
Scale, zero, clamp or add noise to the gradient. |
tl.clip_norm(max_norm, norm_type=2.0) |
Rescale the gradient so its norm is at most max_norm, per tensor. Portable. |
A callable fn(grad, *, ctx) |
Receives the gradient; return a replacement or None. Audit-only when saved. |
Edits that read recorded forward state (tl.patch_from, tl.mean_ablate, tl.splice_module,
torchlens.intervention._helpers.swap_with with a site or trace donor) refuse on a gradient site with edit_signal_mismatch.
trace.do(tl.grad(site), edit, model=model, inputs=x) reruns the forward with the edit armed; the next
result.backward(loss) applies it. loss=lambda t: ... (a callable of the returned trace) does both in
one call. The audit row status is "armed" until a backward settles it. Without model= and inputs= (or
intervention=tl.options.InterventionOptions(engine="set_only") to arm in place), do refuses
replay_gradient_edit_unsupported.
Hook callables receive one positional tensor and a required keyword-only
ctx context. For an activation site the positional tensor is the activation; for
a gradient site it is the gradient. The positional parameter is matched by
position, so def hook(activation, *, ctx): ... and
lambda g, *, ctx: ... are both valid. ctx.state is the per-(site, hook) scratch dict,
ctx.run_state the run-scoped one, and ctx.signal is "forward" or "backward". Hooks must return the replacement
tensor.
Attribution Patching¶
Attribution-patching examples in this guide use the (corrupt - clean) * patched
sign convention so positive scores mean the patched activation moves the run
toward the corrupt direction under that local linearization. The more common
TransformerLens presentation is grad * (clean - corrupt). Both are valid as
long as the sign convention is named and used consistently.
Raw PyTorch Forward Hooks¶
The TorchLens intervention API is the preferred path when you want selectors,
fire records, replay, rerun, or saved intervention specs. Raw
nn.Module.register_forward_hook also works during tl.trace() when you need module-local PyTorch behavior:
def halve_relu(module, args, output):
return output * 0.5
handle = model.relu.register_forward_hook(halve_relu)
try:
log = tl.trace(model, x)
finally:
handle.remove()
If a raw hook returns a new tensor, TorchLens instruments that replacement, so
downstream op-level graph structure remains available: torch ops the hook runs
are traced as ordinary ops, and a tensor with no traceable provenance is
recorded as an interventionreplacement_* op marked is_edited=True.
A capture-time module edit (intervene=tl.when(tl.module("block"), ...)) is
always recorded as its own interventionreplacement_* op, with
is_edited=True and its fire records on .interventions, placed
between the module's last op and that op's consumers. The module's last op keeps
the value it computed, before the edit. The edited value is the replacement
op's .out, and it is what tl.module("block") reads as the module's output.
Every later op is renumbered (a label's last number is its position), so compare
traces through Bundle site reads, which pair ops by structure, not by label.
A post-capture module edit (fork.do(tl.module("block"), edit)) is recorded in the
same shape: after the push the edited value sits in a new interventionreplacement_*
op spliced after the module's last op, the last op keeps the value it computed, the
edited module and the calls enclosing it report the replacement as their output, and a
submodule whose output is that same last op keeps reporting the pre-edit value, as a
plain forward hook on the outer module would leave it. Nothing is renumbered after
capture: the replacement takes the next unused position number and every other op keeps
its label. A later push upstream passes the parent's new value through the replacement
and fires the module's hooks there again.
Comparison reads report a module edit (either kind) at the edited module's last op:
tl.debug.compare and tl.debug.first_divergence, Bundle.compare,
Bundle.rank_changes and the bundle diff read the replacement's value at that op's
row, and the replacement op has no row of its own, so the unedited trace (which has no
replacement op) is compared against the edited value at the site the edit named.
Capture-Time Recording¶
tl.record(..., save=...) is the sparse capture sibling of tl.trace(..., save=...).
It returns a Recording, not a Trace; call Recording.to_trace() when you need the full
postprocessed graph structure later.
recording = tl.record(model, x, save=tl.func("relu"))
trace = recording.to_trace()
streamed = tl.trace(model, x, save=tl.in_module("encoder"), storage=tl.to_disk("run.tlspec"))
record(save=...) is the only predicate spelling; the old keep_op= /
keep_module= alias kwargs are removed and raise TypeError. Module-boundary
event recording is gated by default_module=, which records ALL module
enter/exit events uniformly — predicate-gated module-event selection has no
public spelling (see docs/reference/deprecations.md for the honest
capability statement).
Forward exceptions keep the historical behavior unless you opt in. With
on_forward_error="attach_partial", TorchLens attaches exc.partial_recording and re-raises
the original exception. With on_forward_error="return_partial", tl.record(...) returns a
failed partial Recording; its output refuses with output_forward_failed because there is
no valid model output. Failed partials carry status="partial_error", failed=True,
string-only error metadata, num_committed_ops, and best-effort last_event_* fields. user-op
failures exclude the failing call; TL-side capture failures may include a skipped/partial
current-call event. Failed partials are not topology-complete, so Recording.to_trace() and
Recording.backward() reject them.
A full tl.trace(...) whose forward raises raises tl.errors.TraceCaptureError, chained
from the forward's own exception (exc.__cause__); inspect the partial capture on
exc.partial_trace.
Trace Mutators¶
| Method | Use |
|---|---|
log.do(site, value, intervention=tl.options.InterventionOptions(engine="set_only")) |
Record a one-shot tensor or callable replacement and mark the recipe stale, without propagating it. |
log.do(site, edit) |
The one editing verb: stage the edit (a value sets, a hook or helper attaches as a sticky rule) and propagate it. Without model=/inputs= the replay engine propagates over the saved DAG without calling model.forward; with both, the rerun engine re-executes the model; engine="set_only" stages without propagating (a hook or helper stays a sticky hook under it). |
log.do({site_a: edit_a, site_b: edit_b}) |
Apply several edits in one propagation. |
log.do(site, edit, intervention=InterventionOptions(differentiable=True)) |
Replay onto a new fork whose outputs stay differentiable and return it (replay engine only). |
log.clear_edits(site) |
Remove the sticky hooks staged at site (or by handle id with log.clear_edits(handle=...)); do returns the trace, not a handle. Removal takes effect at once, like a torch hook handle: a rerun or backward pass in progress skips the removed hooks from the next hook site it reaches, and the site firing at that moment finishes its queued hooks first. |
log.fork(name=None) |
Create an isolated branch for experiments. |
log.run(model, x) |
Re-execute the model under the active spec (the former rerun() alias is removed). |
log.append(x, model=model) |
Run one more compatible batch and add its rows to log in place. |
log.save_intervention(path, level=...) |
Write a .tlspec/ intervention recipe. |
recipe.save(path, level=...) |
Write an authored tl.Recipe on its own (kind: "recipe"); tl.load(path) returns the Recipe. |
There are two distinct intervention paths, and they do not mix implicitly:
(1) edit a SAVED value and push the effect downstream on the captured DAG
(do() on a site or a resolved selection, direct writes), and (2)
intervene on a FRESH execution (do(..., model=..., inputs=..., intervention=InterventionOptions(engine="rerun"))
or a new capture with intervene=...). A new-input run_transaction(inputs=...) on a
trace carrying path-1 value-edits is a fresh execution — the edits do NOT
apply to it, and TorchLens discloses that at the run door with
PendingValueEditsWarning (experimental name) rather than
silently returning an un-edited verified run. The run itself proceeds:
this is a disclosure, never a refusal.
log.do([(selection, edit), ...]) applies a list of selection and edit pairs
as one transaction with one intervention event. Activation (ACT) pairs attach
together and push once. A batch may also carry parameter selections
(tl.param(...)) and dataflow edge selections (from log.graph.edges); on the
replay engine the pairs then run in order exactly as one do() per pair would
(each run of consecutive activation pairs attaches and pushes together; each
parameter or edge pair goes through its own substitution path), and the result
equals those calls made one at a time. The batch is atomic: if any pair is
refused, every value, hook, audit row and history record the batch wrote is
put back, and the trace keeps only the one error event for the failed call.
Parameter and edge edits apply on the replay engine only, so a mixed batch on
another engine refuses with selection_batch_cross_kind before anything runs;
pass intervention=tl.options.InterventionOptions(engine="replay"). Leaf sites
(inputs and buffers) still cannot join a batch (selection_batch_leaf_unsupported).
The two engines differ sharply in cost when you sweep many edits over one
captured input. On the 144-head ablation sweep of a 12-layer, 12-head GPT-2
configuration (585 captured ops; benchmarks/head_sweep_engine_cost.py), the
replay engine (engine="replay") took about 2.1 minutes for all 144 heads
(129 s and 125 s in two runs; median 0.60-0.62 s per head) and the rerun
engine (engine="rerun") about 34 minutes (2052 s and 2008 s; median
13.5-13.7 s per head), on CPU with four torch threads (torch 2.13) on a shared
32-core x86-64 Linux host. Both engines produce bitwise-identical ablated
traces on all 144 heads, so the difference is cost only. A rerun re-executes
every captured op, and from an intervention-ready source it also runs under
the host RNG monitor, so its cost is the same for every site; replay cost grows
with the edited site's downstream cone. Prefer replay for many edits on one
captured input, and use rerun when the edit must see a fresh execution.
A rerun from an intervention-ready source also snapshots the model state
before its forward pass, as every intervention-ready capture does; each state
entry whose bytes are unchanged since the source capture shares the source's
snapshot instead of being cloned again, so on this sweep a rerun copies about
54 MB of tensors instead of about 706 MB, and an entry that changed (an
in-place weight edit, say) is cloned afresh. Every edit on either engine
starts with trace.fork(), whose cost is fixed by the size of the trace, not
by the edit: on this 585-op trace a fork takes about 0.08-0.09 s and makes
about 419,000 Python calls (about 580,000 before its label tables were copied
directly), which is the floor under the fastest replay edits. A rerun that
refreshes its fork in place releases the capture it discarded before it
returns, so a fork made right after a rerun costs the same as any other
(about 420,000 calls; about 1.25 million before, when that capture's cleanup
ran inside whatever came next, typically the next fork).
Trace.draw(intervention_mode=...) visualizes the planned intervention
recipe stored on an intervention-ready trace, such as sites registered with
log.do(...). Each edited node gets a purple ring and a
last row naming the edit (Δ zero_ablate), and the header names the edits;
show_cone=True (off by default) adds a translucent purple underlay
under the arrows and node outlines the edit changed downstream. Capture-time intervene=... calls record
fire metadata on matched layers, but they are not replay plans and are not
drawn as planned sites.
Bundle¶
Construct with tl.Bundle(...):
Common operations:
| Operation | Use |
|---|---|
bundle.names |
Member names in order. |
bundle["clean"] |
Access one Trace. |
bundle.at(site) |
Return the Super* view of one site (a label or a selector) across members after relationship checks. |
bundle.at(site).compare() |
Pairwise comparison matrix at a shared site, after the comparison gate. |
fn(bundle) |
A metric over the whole bundle is a plain call; bundle.map(fn) is the per-member door. |
bundle.do(...), bundle.run(model, x) |
Apply an edit (do applies and replays it) or a re-run to each member. |
bundle.fork(name=None) |
Fork all members into a new bundle. |
Site reads (most_changed, compare) pair ops across members through the
bundle's structural alignment, not by literal label, so an op renumbered by an
inserted edit op still meets its counterpart. A site that only some members have
(such as the replacement op a module edit inserts) is not scored.
Relationship gates are intentional, and they check two predicates separately:
a model-axis floor (node needs same_param_shapes; comparison reads need
shared_graph) and, for comparison reads, SITE LINE-UP. Runs on different
inputs or checkpoints compare (clean vs. patched, two prompts, two weight
states) whenever their graphs line up: the pair either captured the same input
VALUES (derived from retained input payloads) or has equal graph-shape hashes.
A pair on different inputs whose graphs differ (data-dependent control flow
took another branch) refuses bundle_gate_input_values_differ; with no input
payloads to prove identity and graphs that differ it refuses
bundle_gate_input_identity_unproven. An identity relationship is never
accepted as proof of input equality or of line-up. Inputs of different shapes
(prompts of different token counts) refuse one of two ways: when their graphs
still line up, at the first site whose shapes differ (metric_shape_mismatch,
naming the site); when the length changed the graph (GPT-2 does), with
bundle_gate_input_values_differ, whose message names both input shapes. Members joined by an ordering
relation (successor_of/forked_from/escalates) are not comparison
operands regardless of rank (bundle_gate_ordering_topology). Pair-scoped
reads (compare(a, b), most_changed baseline pairs) gate only the
operands they actually read.
Cohort Migration¶
| TransformerLens pattern | TorchLens pattern |
|---|---|
act_patch attribution patching |
Attach a tl.observers.tap(tl.grad(site)) gradient observer (or a callable on tl.grad(site)), run log.run(model, x) under the active spec, call result.backward(loss) and score from the captured gradients/outs. |