Intervention Explainers¶
Which engine when¶
TorchLens can apply the same edit (tl.when(site, action)) in three ways. They answer different
questions, so pick by what you need back, not by habit.
| You want to | Use | Runs model.forward? |
What you get back |
|---|---|---|---|
| See every op of one edited run, with a record of where the edit fired | tl.trace(model, x, intervene=spec) |
yes, once | a Trace of the edited run |
| Ask "what if this saved value had been different" on an input you already traced | trace.fork().do(site, action) |
no: the edit is pushed down the recorded graph from saved values | the fork, with its saved values updated |
| The same question when the edit may change control flow, randomness or module state | trace.fork().do(site, action, model=model, inputs=x), or trace.run(model, x) |
yes | the fork, re-executed under the edit |
Apply one edit to many forwards or to HF generate(), with no trace |
spec.bind(model), and spec.bind(model).generate(...) for HF generate() |
yes, every call | the model's own outputs, plus last_report fire counts |
| Apply an edit and keep a few activations on every forward | tl.record(model, x, save=..., intervene=spec) |
yes, every call | a sparse Recording; the output is recording.output |
do() picks its engine from its arguments: with neither model= nor inputs= it pushes over the saved
graph; with both it re-executes the model. Pass
intervention=tl.options.InterventionOptions(engine=...) to name it explicitly. What each path
costs, and what each one refuses, is in
Performance: choosing a fast path.
The four engines agree on a graph-stable edit:
# docs-test: expected=[True, True, True, True]
import torch
from torch import nn
import torchlens as tl
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 2)).eval()
x = torch.randn(3, 4)
site = tl.module("1") # the ReLU's output
spec = tl.when(site, tl.zero_ablate())
head = tl.module("2")
# intervene=: edit during a fresh capture; the trace records the edited run.
live = tl.trace(model, x, save=head, intervene=spec)
live_out = live.find_sites(head).first().out
# do() with no model: edit a saved value and push it down the recorded graph.
saved = tl.trace(model, x)
pushed = saved.fork("pushed")
pushed.do(site, tl.zero_ablate())
pushed_out = pushed.find_sites(head).first().out
# do() with model and input: re-execute the model under the edit.
executed = saved.fork("executed")
executed.do(site, tl.zero_ablate(), model=model, inputs=x)
executed_out = executed.find_sites(head).first().out
# bind: no trace at all; the edit runs as a hook on every call.
bound_out = spec.bind(model)(x)
head_layer = model[2]
expected = head_layer(torch.zeros(3, 8)) # the head sees an all-zero ReLU output
RESULT = [
torch.equal(live_out, expected),
torch.equal(pushed_out, expected),
torch.equal(executed_out, expected),
torch.equal(bound_out, expected),
]
They stop agreeing when the edit changes what the forward does. A replay recomputes the downstream ops exactly as they were recorded, so a branch the edit would have flipped, a dropout mask, or a module that reads its own state takes the recorded path, not the new one. Use the re-executing form whenever that can happen.
Replay vs run¶
trace.do(site, edit) stages the edit and, on the replay engine (no model=/inputs=), walks the
saved TorchLens graph and recomputes the affected downstream cone without calling model.forward
again. It is fast and right for graph-stable interventions, where the saved op metadata is enough to
recompute the children. A mapping, trace.do({site_a: edit_a, site_b: edit_b}), applies several
edits in one propagation; intervention=tl.options.InterventionOptions(engine="set_only") stages
an edit without propagating it, and trace.clear_edits(site) detaches a sticky edit.
trace.run(model, x) re-executes the model under the trace's active intervention spec. Use it when
the intervention may affect Python control flow, module behavior, randomness, or anything else the
saved graph cannot reconstruct. On a trace that saved a selection of sites the rerun takes the
guarded fast engine (a native forward with the module-targeted spec applied at its modules) and
falls back to the capture engine when a guard refuses; trace.last_run says which engine ran.
The older replay() and rerun() spellings are removed; do() and run() replace them, and do
is also the one verb that stages and propagates an edit (docs/reference/deprecations.md maps the
removed spellings).
Mutate In Place and Fork First¶
do (including do with the set_only engine) mutates the Trace it is called on. A root trace warns once
before this kind of mutation. For branched experiments, fork first:
# docs-test: expected=True
import torch
from torch import nn
import torchlens as tl
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 4), nn.ReLU(), nn.Linear(4, 2)).eval()
x = torch.randn(2, 4)
clean = tl.trace(model, x)
before = clean.find_sites(tl.module("2")).first().out.clone()
candidate = clean.fork("candidate")
candidate.do(tl.func("relu"), tl.zero_ablate())
# The fork changed; the trace it came from did not.
RESULT = torch.equal(clean.find_sites(tl.module("2")).first().out, before)
Forked traces have independent intervention specs and intervention logs while sharing immutable captured evidence where safe.
Direct-Write Dirty¶
Writing directly to a saved value, for example trace[label].out = value, bypasses the
intervention recipe. TorchLens marks the owning trace DIRECT_WRITE_DIRTY and warns once. A later
replay overlays recipe state; it does not treat the direct write as a portable, auditable
intervention. Use do when the edit should be part of the recipe.
Subspace edits: the component along a basis¶
do(tl.subspace(site, basis, origin=...), edit) edits the component of the site's value inside
the span of the basis rows and leaves the orthogonal complement unchanged. With Q the basis
rows, the edit acts on the coordinates c = Q x (one per row) and the site gets
x + Q^T (edit(c) - c). This is the addressing distributed alignment search uses.
- Coordinate verbs act on
c:tl.zero_ablate(),tl.scale(...),tl.clamp(...),tl.add_noise(...),tl.mean_ablate(dim=...)withoutsource=,scramble_elements(seed=...)withoutsource=,tl.add(scalar),splice_module(module, input_route="out"), and any hook callablefn(c, *, ctx), which receives the coordinates with the bound axis resized tok. - Value verbs compute a value in activation space and write its component:
tl.patch_from(source)is the interchange (the component becomes the source run's), and likewiseswap_with,replace_with,tl.add(tensor),steer,permute_batch,resample_rows_fromandtl.mean_ablate(population, dim=...). - A source verb maps the whole site value to new coordinates:
tl.intervention.set_affine_coordinates(W, bias=b)sets them toW x + b(LoReFT; see "A learned affine source" below). - Every other verb refuses
subspace_edit_unsupportedand names the spelling that works: the projection edits themselves (project_out,project_onto,set_direction_mean,subspace_ablate,subspace_interchange),compose(apply the edits one at a time),at_positions(restrict positions throughwithin=), asource=that is an activation value,splice_module(input_route="in")and the backward verbs.
An orthonormal basis is used exactly as given, so the coordinates are yours. Any other full-rank
basis is orthonormalised by Gram-Schmidt in row order with each row's sign kept, so one direction
v edits the component along v / |v|; the audit record says when that happened. A
rank-deficient basis refuses subspace_basis_invalid. Weights at or below atol= are dropped
first.
resolve() still answers a set question: the basis's support set, the elements a nonzero weight
touches, which is where the edit can land (a dense direction supports the whole axis). Restrict
positions with within=, for example tl.subspace(tl.units(site, mask), basis, origin=...) with
mask True on every feature of the chosen positions; a mask that covers only part of the support
at a position refuses subspace_projection_partial_axis. A subspace combined with | & - ~, or
already resolved, refuses subspace_projection_composed; to edit the support set itself, pass
tl.units(site, resolved[0].mask). Every subspace do() stamps the projection, the basis origin
and its sha256 into the audit record.
# docs-test: expected=[True, True, True]
import torch
from torch import nn
import torchlens as tl
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 4), nn.ReLU(), nn.Linear(4, 2)).eval()
base = tl.trace(model, torch.randn(3, 4))
source = tl.trace(model, torch.randn(3, 4))
site = "linear_1_1"
basis = torch.tensor([[1.0, 1.0, 0.0, 0.0]]) / 2**0.5 # one unit direction
fork = base.fork()
fork.do(tl.subspace(site, basis, origin="hand-picked direction"), tl.patch_from(source))
x, s, edited = base[site].out, source[site].out, fork[site].out
projector = basis.T @ basis
RESULT = [
# The component along the direction is the source run's (the interchange) ...
torch.allclose(edited @ projector, s @ projector, atol=1e-6),
# ... and the orthogonal complement is the base run's.
torch.allclose(edited - edited @ projector, x - x @ projector, atol=1e-6),
"projection" in fork.intervention_audit[-1]["sites"][0]["source"],
]
For a fixed direction without a selection, tl.project_out(direction, dim=...) removes
the component and tl.project_onto(direction, dim=...) keeps only it:
# docs-test: expected=[True, True]
import torch
from torch import nn
import torchlens as tl
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 4), nn.ReLU()).eval()
x = torch.randn(2, 4)
direction = torch.tensor([1.0, 1.0, 0.0, 0.0])
unit = direction / direction.norm()
site = tl.module("0")
clean_trace = tl.trace(model, x, save=site)
clean = clean_trace.find_sites(site).first().out
off_trace = tl.trace(
model, x, save=site, intervene=tl.when(site, tl.project_out(direction, dim=-1))
)
off = off_trace.find_sites(site).first().out
# project_out leaves no component along the direction and keeps the rest.
RESULT = [
torch.allclose(off @ unit, torch.zeros(2), atol=1e-6),
torch.allclose(off, clean - (clean @ unit)[:, None] * unit, atol=1e-6),
]
A rotation learned elsewhere¶
TorchLens applies a rotation you trained elsewhere (distributed alignment search, ReFT); it does
not fit one. Fit a causal basis on GPT-2 small fits one with a plain
PyTorch loop over captured activations and applies it with the builtin edit. The first k rows of the learned rotation R are the basis, so
do(tl.subspace(site, R[:k], origin=...), edit) edits the learned subspace. With a builtin verb
the staged edit is the builtin subspace_edit, which stores the verb, the float64 orthonormal rows
it applies, the bound axis, the rows' sha256 and the basis provenance (origin, method, the
original basis digest and its dtype). tl.io.save_intervention(trace, path, level="portable")
saves it, and the loaded spec rebuilds the same projection, never the plain verb over the whole
site. A stored basis that is malformed refuses subspace_spec_invalid at load, rows changed after
saving refuse subspace_spec_basis_tampered, and rows that are not orthonormal refuse
subspace_spec_basis_not_orthonormal. The portable interchange is tl.replace_with(value) with
the source run's value at the site; tl.patch_from(source) and hook callables are session-time
and save only at the audit level.
# docs-test: expected=[True, True, True]
import tempfile
from pathlib import Path
import torch
from torch import nn
import torchlens as tl
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 4), nn.ReLU(), nn.Linear(4, 2)).eval()
x, x_source = torch.randn(3, 4), torch.randn(3, 4)
site = "linear_1_1"
rotation = torch.linalg.qr(torch.randn(4, 4))[0].T # stands in for a learned rotation
source_trace = tl.trace(model, x_source)
source_value = source_trace[site].out
fork = tl.trace(model, x).fork()
fork.do(
tl.subspace(site, rotation[:2], origin="DAS rotation trained elsewhere", method="das"),
tl.replace_with(source_value),
)
path = Path(tempfile.mkdtemp()) / "rotation.tlspec"
tl.io.save_intervention(fork, path, level="portable")
spec = tl.io.load_intervention_spec(path)
fresh = tl.trace(model, x).fork()
reloaded = fresh.do(spec, intervention=tl.options.InterventionOptions())
helper = spec.hook_specs[0].helper
RESULT = [
torch.equal(reloaded[site].out, fork[site].out), # the same interchange, bit for bit
helper.name == "subspace_edit",
dict(helper.kwargs)["origin"] == "DAS rotation trained elsewhere",
]
A learned affine source: LoReFT¶
LoReFT (pyreft) learns a rotation R ([d, k], orthonormal columns) and an affine map W h + b
(W is [k, d]), and writes h + R (W h + b - R^T h): the coordinates along R become an affine
function of the whole site value h. That is a source verb:
tl.intervention.set_affine_coordinates(W, bias=b) under tl.subspace(site, R.T, origin=...). The
projection hands it the site value and it returns the new k coordinates, so the site gets
x + Q^T (W x + b - Q x) with Q = R^T. The basis rows and W must have the same shape [k, d]
(else affine_source_invalid, as for a non-finite or non-float argument). Outside a subspace a
[k, d] map has no meaning, so the verb refuses affine_source_needs_subspace with this spelling.
# docs-test: expected=[True, True]
import torch
from torch import nn
import torchlens as tl
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 4), nn.ReLU(), nn.Linear(4, 2)).double().eval()
x = torch.randn(3, 4, dtype=torch.float64)
site = "linear_1_1"
rotation = torch.linalg.qr(torch.randn(4, 2, dtype=torch.float64))[0] # R, [d, k]
weight, bias = torch.randn(2, 4, dtype=torch.float64), torch.randn(2, dtype=torch.float64)
base = tl.trace(model, x)
fork = base.fork()
fork.do(
tl.subspace(site, rotation.T, origin="LoReFT trained elsewhere", method="loreft"),
tl.intervention.set_affine_coordinates(weight, bias=bias),
)
h = base[site].out
loreft = h + (h @ weight.T + bias - h @ rotation) @ rotation.T
RESULT = [
torch.allclose(fork[site].out, loreft, rtol=0.0, atol=1e-12), # LoReFT, by hand
fork.intervention_audit[-1]["edit"] == "set_affine_coordinates",
]
LoReFT edits one position (pyreft's unit_locations, usually the last prompt token). Restrict the
subspace with within=tl.units(site, mask), mask True on every feature of that position. A
subspace edit with a builtin verb stores its mask and the mask's sha256, so a one-position edit
still saves at level="portable" and reloads as the same edit; a stored mask that is malformed
refuses subspace_spec_invalid at load and one changed after saving refuses
subspace_spec_mask_tampered. A plain builtin verb under an element mask (fork.do(tl.units(site,
mask), tl.zero_ablate()), the heads head_ablation_candidates returns) stores its mask and the
mask's sha256 the same way and saves at level="portable"; an edit that needs user code still
refuses intervention_spec_not_portable.
tl.bridge.pyreft.read(directory) reads a saved pyreft artifact into these two pieces
(artifact.subspace(site, positions=...) and artifact.edit); see
from pyvene.
# docs-test: expected=[True, True, True]
import tempfile
from pathlib import Path
import torch
from torch import nn
import torchlens as tl
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 4), nn.ReLU(), nn.Linear(4, 2)).double().eval()
tokens = torch.randn(1, 5, 4, dtype=torch.float64) # [batch, positions, features]
site = "linear_1_1"
rotation = torch.linalg.qr(torch.randn(4, 2, dtype=torch.float64))[0]
weight, bias = torch.randn(2, 4, dtype=torch.float64), torch.randn(2, dtype=torch.float64)
last = torch.zeros(1, 5, 4, dtype=torch.bool)
last[:, -1, :] = True # every feature of the last position
base = tl.trace(model, tokens)
fork = base.fork()
fork.do(
tl.subspace(tl.units(site, last), rotation.T, origin="LoReFT trained elsewhere"),
tl.intervention.set_affine_coordinates(weight, bias=bias),
)
edited = fork # do() already replayed the edit downstream
path = Path(tempfile.mkdtemp()) / "loreft.tlspec"
tl.io.save_intervention(fork, path, level="portable")
spec = tl.io.load_intervention_spec(path)
fresh = tl.trace(model, tokens).fork()
fresh.do(spec, intervention=tl.options.InterventionOptions())
RESULT = [
torch.equal(fresh[site].out, edited[site].out), # the same edit, bit for bit
torch.equal(edited[site].out[:, :-1], base[site].out[:, :-1]), # other positions untouched
spec.hook_specs[0].helper.portability == "builtin",
]
Which verbs keep the autograd graph, under which door, is the gradient contract.
The population-based edits, set_direction_mean among them, are on the
stochastic and population edits page.
Save Levels¶
save_intervention(path, level=...) writes a .tlspec/ directory.
| Level | Intended use | Executable? |
|---|---|---|
audit |
Record what was attempted, including non-portable pieces. | Not guaranteed. |
executable_with_callables |
Re-run in the same code environment when importable callables resolve. | Yes, unless opaque callables block execution. |
portable |
Share built-in-helper recipes and tensor sidecars with no opaque local callable dependency. | Yes for supported built-ins. |
Portable saves reject opaque local callables. Use built-in helpers such as zero_ablate,
steer, scale or project_out for reproducible publication.
.tlspec/ Directory¶
A .tlspec/ directory contains:
manifest.json: the artifact manifest,kind: "intervention".intervention.json: the recipe: each rule's selector and built-inEditidentity as typed JSON; a user callable is a placeholder, never pickled.blobs/recipe-<nnnn>.safetensors: the recipe's tensors, one entry per tensor object however many places the recipe references it (a sticky hook's edit and its fire records share one).
Load with torchlens.io.load_intervention_spec(path) and check a fresh capture with
torchlens.intervention.check_spec_compat(spec, new_trace).
Append Constraints¶
Append mode is for memory-constrained evaluation over compatible chunks:
The appended run must match the original graph shape, labels, dtypes, and non-batch dimensions. Helpers must be batch-independent and append-compatible. Batch-dependent helpers such as resampling from the current batch are rejected. Training-mode batch normalization warns because cross-batch statistics can make chunked evaluation semantically different from one full batch.
Gradient Sites¶
Gradient edits (tl.grad(site) with an ordinary edit or a callable) are live/run-only in this
release. A callable stays audit-only in a saved artifact, and gradient edits are not pushed post hoc
over a saved forward graph (replay refuses replay_gradient_edit_unsupported). Use them when the
experiment explicitly re-executes the model and backward pass:
trace.do(tl.grad(site), edit, model=model, inputs=x, loss=...).
Attribution-Patching Note¶
TorchLens exposes the ingredients for attribution-style patching: capture clean and corrupted
traces, choose a target site, attach a hook, and compute a metric over a Bundle. Turnkey
attribution-patching row and formula helpers are not shipped; keep those formulas in analysis code
and record the exact selectors and metric definitions next to saved specs.
Two sign conventions are in use. The TransformerLens presentation is the first-order estimate
grad * (clean - corrupt), with grad taken on the corrupted run. The
intervention API reference examples use
(corrupt - clean) * patched. Both are valid; name the convention next to the metric so higher
and lower scores stay clear.