Stochastic and population edits¶
Causal scrubbing, resampling ablation and mean ablation replace an activation with values drawn
from, or averaged over, other inputs. TorchLens builds them from one population noun and one
sampling plan, records every draw, and makes each draw reproducible from its seed. Every name on
this page lives in torchlens.intervention (not at the tl. root), except tl.mean_ablate (the root
helper); SamplingRecord is exported there beside FireRecord. All are
experimental: the names may change in any release.
The pieces¶
| Name | What it does |
|---|---|
population(source, origin=...) |
The population: a tensor whose axis-0 slices are the members, a list of same-shape tensors, or a list of captured traces. origin= is a required note of where the population came from. |
sample_from(population, seed=...) |
The one stochastic primitive: an immutable plan that draws one whole donor per firing. Apply it with tl.patch_from(plan). |
resample_rows_from(population, seed=..., axis=...) |
Resampling ablation: each batch row is replaced by one whole donor row, drawn with replacement, independently per row. |
permute_batch(seed=..., axis=...) |
Exchanges rows within the current batch (a draw without replacement). |
tl.mean_ablate(source=None, *, dim) |
The one mean door. dim= is required: None averages every axis to one scalar, an int or tuple of ints averages those axes. source= is what is averaged: the site's own value (default), a tensor, or a population, whose members are averaged elementwise first (dim="members" fills with that member-shaped mean; dim=None reduces it to one scalar like every other source). A mean is a reduction, never a draw, so it takes no seed. |
set_direction_mean(direction, population, dim=...) |
Replaces the component along direction with the population's mean coefficient and keeps the orthogonal part. |
scramble_elements(...) |
An elementwise random scramble. It is not a resampling ablation: each element moves on its own, so rows are no longer coherent inputs. |
at_step(*steps) |
A selector qualifier for episode captures: tl.when(site & at_step(2), edit) edits only decoding step 2. |
FireRecord.sampling |
The recorded draws of one firing, saved with the trace: a tuple of SamplingRecord (seeds, share rule, firing coordinate, population counts and identity, the full draw indices, the draw digest). Read it from the op's edits. |
One spelling is a false friend. The removed resample_ablate was renamed scramble_elements
because it never resampled whole inputs; resampling ablation is resample_rows_from.
Seeds and draws¶
seed= is required on every drawing edit: an integer, or "auto", which derives the seed once
per capture from the trace's own random seed and records it. Each firing derives its own seed
from that base and from where it fired, so re-running the same declared experiment reproduces
every draw, and two sites or two passes never silently share one draw. To share a draw on
purpose, pass share_draw="per_rule" or "per_group" to sample_from; the default is
"per_firing".
Axes are never guessed from a tensor's rank: resample_rows_from and permute_batch take an
explicit axis=, and set_direction_mean an explicit dim=.
Resampling ablation¶
Each row of the site is replaced by a whole donor row, and the recorded donor ids reproduce the result:
# docs-test: expected=[True, True]
import torch
from torch import nn
import torchlens as tl
from torchlens.intervention import population, resample_rows_from
class Toy(nn.Module):
def __init__(self):
super().__init__()
self.lin = nn.Linear(4, 4)
def forward(self, x):
return torch.relu(self.lin(x))
torch.manual_seed(0)
trace = tl.trace(Toy(), torch.randn(5, 4))
site = tl.label("relu_1_2")
donors = population(torch.randn(3, 4), origin="three donor rows from another dataset")
resampled = trace.fork("resampled")
resampled.do(site, resample_rows_from(donors, seed=2, axis=0))
donor_ids = resampled.ops["relu_1_2"].edits[-1].sampling[-1].draw_indices
oracle = torch.stack([donors.members[index] for index in donor_ids])
again = trace.fork("again")
again.do(site, resample_rows_from(donors, seed=2, axis=0))
RESULT = [
torch.equal(resampled["relu_1_2"].out, oracle),
torch.equal(again["relu_1_2"].out, resampled["relu_1_2"].out),
]
group_by= and matching= restrict the donors to the subject's agreement class, the
agreement-conditioned resample that causal scrubbing needs. A class with one member is disclosed
with a warning, and refused under strict=True.
Means and directional means¶
# docs-test: expected=[True, True]
import torch
from torch import nn
import torchlens as tl
from torchlens.intervention import population, set_direction_mean
class Toy(nn.Module):
def __init__(self):
super().__init__()
self.lin = nn.Linear(4, 4)
def forward(self, x):
return torch.relu(self.lin(x))
torch.manual_seed(0)
trace = tl.trace(Toy(), torch.randn(5, 4))
site = tl.label("relu_1_2")
clean = trace["relu_1_2"].out
evidence = population(torch.stack([clean, clean + 2.0]), origin="two evidence runs")
# mean_ablate with a population and dim="members": the elementwise mean over the population.
meaned = trace.fork("meaned")
meaned.do(site, tl.mean_ablate(evidence, dim="members"))
# set_direction_mean: only the component along the direction changes.
direction = torch.tensor([1.0, -1.0, 0.0, 0.5])
unit = direction / direction.norm()
directed = trace.fork("directed")
directed.do(site, set_direction_mean(direction, evidence, dim=1))
mean_coefficient = (torch.stack(evidence.members) @ unit).mean()
oracle = clean - (clean @ unit)[:, None] * unit + mean_coefficient * unit
RESULT = [
torch.allclose(meaned["relu_1_2"].out, clean + 1.0),
torch.allclose(directed["relu_1_2"].out, oracle, atol=1e-5),
]
set_direction_mean is the edit to reach for when you mean "the component along a direction".
Under a tl.subspace(...) selection, tl.mean_ablate(population, dim="members") sets the component in the basis span to the
population mean's component (see
Subspace edits).
tl.project_out and tl.project_onto are the projection helpers without a population.
Whole-event donors¶
sample_from builds a plan that draws one whole donor per firing; tl.patch_from(plan) applies
it:
# docs-test: expected=True
import torch
from torch import nn
import torchlens as tl
from torchlens.intervention import population, sample_from
class Toy(nn.Module):
def __init__(self):
super().__init__()
self.lin = nn.Linear(4, 4)
def forward(self, x):
return torch.relu(self.lin(x))
torch.manual_seed(0)
trace = tl.trace(Toy(), torch.randn(5, 4))
donors = population(torch.randn(6, 5, 4), origin="six recorded activations of this site")
patched = trace.fork("patched")
patched.do(tl.label("relu_1_2"), tl.patch_from(sample_from(donors, seed=13)))
RESULT = any(torch.equal(patched["relu_1_2"].out, member) for member in donors.members)
A population of captured traces (population([trace_a, trace_b], origin=...)) resolves each
donor value at fire time from the matching site of the drawn trace.
Where these edits run¶
They run on the torch backend, through do() on a trace or fork and through intervene= at
capture. When an attribution method's zero fill is not the counterfactual you mean, patch the
site with one of these edits and score the fork yourself (see
Reading attribution results honestly).