Skip to content

Receptive & projective fields

TorchLens answers an influence question on the graph that actually executed: which source positions can affect this target position, and which target positions can a source position affect? The first is a receptive field; the second is a projective field. Both can be queried between model endpoints or between any two reachable graph points.

This matters for modern networks. A sequential receptive-field formula can silently misstate a ResNet, an encoder-decoder, or a model with branches. TorchLens follows the captured DAG, so skip connections and merges are part of the answer rather than an afterthought.

Start with an operation

import torch
import torchlens as tl
from torchvision.models import resnet18

model = resnet18(weights=None).eval()
x = torch.randn(1, 3, 224, 224, requires_grad=True)
# Gradient-bearing actions need BOTH backward_ready=True and
# save_mode="reference": the default detaching save mode frees the autograd
# history the empirical probes consume.
trace = tl.trace(
    model, x, capture=tl.options.CaptureOptions(backward_ready=True, save_mode="reference")
)

op = trace.ops["layer4.1.conv2"]
rf = op.receptive_field
print(rf.size, rf.jump, rf.center0)

# .at() uses coordinates over only the target's derived windowed axes.
box = rf.at((3, 3))

# Gradient-bearing actions use a complete target-element index, including batch.
# .probe_grad() and .check() FREE the armed autograd graph by default, so pass
# retain_graph=True to every gradient-bearing action except the last.
unit = rf.center_unit(batch_index=0)
empirical = rf.probe_grad(unit, retain_graph=True)
checked = rf.check(unit, retain_graph=True)
overlay = rf.show(unit, grad=True)
overlay.save("resnet18_rf.png")

ReceptiveFieldView.at() is the cheap geometric query. .probe_grad() probes exactly one output element and returns its empirical support mask. .check() is the zero-tolerance containment tripwire, and .show() renders an input overlay; with grad=True, it also overlays the empirical heatmap. Gradient operations require a backward-ready trace and are deliberately explicit because they consume autograd work. Both .probe_grad() and .check() FREE the armed autograd graph by default; pass retain_graph=True to either when a later gradient-bearing action must run on the same armed capture.

Two complementary methods

Method Use it for Cost What it returns
Geometric Convolutions, pooling, common transforms, trace-wide tables One cached DAG solve Exact geometry where the captured rules prove it; otherwise an honest status or bound
Gradient Arbitrary differentiable behavior, holes, masks, and auditing One or more autograd probes The observed nonzero support mask for one seeded output element

The geometric method retains exact rational geometry when it can. The gradient method is more general, but a support mask is local to the captured inputs, parameters, and control path. Use the two together: the empirical support must stay inside the geometric box.

Status is part of the answer

Never read a numeric box without its status.

ReceptiveFieldStatus Meaning
EXACT Exact integer hull of potential support for the captured execution.
WHOLE_INPUT The whole far endpoint is the exact structural envelope. In a projective query, the display means the whole output/target even though the stable enum name remains WHOLE_INPUT.
UPPER_BOUND Sound envelope, not an exact claim.
DATA_DEPENDENT Routing depends on data; use .probe_grad().
UNKNOWN The operation is recognized, but captured facts or axis roles are insufficient.
UNSUPPORTED A contributing operation has no registered geometric rule.

This is deliberately conservative. Skip connections are composed over the real DAG; norms that couple examples, unmasked attention, and global mixing can widen an answer to the whole relevant extent. Causally masked attention (is_causal=True, or a softmax whose saved weights are zero above the diagonal) narrows the sequence axis in .of() set queries: position i reaches positions i and later, and is reached by positions i and earlier. The axis-level descriptor keeps the sequence axis a whole-extent upper bound through the score product, because one input axis feeds both the query and the key side there. Transposed convolutions, interpolation, gathers, and data-dependent routing are never promoted to EXACT without a proof. A gradient mask can preserve sparse or masked structure that an interval box cannot express.

Channel mixing keeps spatial geometry exact. A dense layer (linear) reads the whole feature axis at each position, and a layer_norm reads its whole normalized slice; with parameter weights both are exact whole-axis dependencies, so a ConvNeXt-style block that runs its MLP on channels-last tensors (permute, layer_norm, linear, permute back) stays EXACT through the block. A traced (input-derived) weight or bias makes the relation a whole-extent UPPER_BOUND, as does a layer_norm slice of a single element. The identity node TorchLens records at an nn.Identity module, or at a module that returns its input, passes the field through unchanged.

An empty endpoint (a model input or output with a zero extent, such as a zero-size split chunk) has no units, so its field is empty: it seeds no geometry and no descriptor names it.

Receptive, projective, and layer-to-layer queries

The receptive view is target-anchored by default and looks backward to model input(s). The projective sibling is source-anchored and looks forward to model output(s).

# Backward-looking: input positions that can affect this target unit.
incoming = op.receptive_field.at((3, 3))

# Forward-looking: output positions reached by this source unit.
outgoing = op.projective_field.at((3, 3))

# State the same direction through the hybrid view when composing a workflow.
outgoing_again = op.receptive_field.at((3, 3), direction="projective")

# Restrict the far endpoint. source= selects an ancestor for a receptive query;
# target= selects a descendant for a projective query.
early = trace.ops["layer1.0.conv1"]
late = trace.ops["layer4.1.conv2"]
between = late.receptive_field.at((3, 3), source=early)
forward_between = early.projective_field.at((3, 3), target=late)

source=, direction=, and target= make the endpoints explicit. For receptive queries, source= names the ancestor result grid; for projective queries, target= names the descendant result grid. The unit always belongs to the view owner: the target for receptive queries and the source for projective queries.

A projective query with no target= needs one reachable model output. When the source also reaches cache tensors, as a decoder with use_cache=True returns its past_key_values, the query answers the primary output: the output role whose last segment is logits, else the first non-cache output in model output order. An output role counts as a cache when a path segment is past_key_values, presents or mems, or contains cache. Two real outputs and no cache still raise AmbiguousTargetError, and target= selects any role, a cache tensor included:

# GPT-2 with use_cache=True returns output.logits and every cached key and value.
logits_side = op.projective_field.at((3, 0))                       # same as target="output.logits"
cached_side = op.projective_field.at((3, 0), target="output.past_key_values.layers.1.keys")

Through causal attention the logits answer is the window refusal that points at probe_grad(), since a causal sequence axis is not a uniform window; .of() and .status answer per target.

Trace tables are geometric and do not invoke backward:

incoming_table = trace.receptive_fields(level="layer")
outgoing_table = trace.projective_fields(level="layer")
print(incoming_table.to_pandas()[["name", "status", "size", "jump"]])

Op, Layer, ModuleCall, and Module all expose the two sibling properties. A multi-pass layer, multi-call module, or multi-output module call is intentionally ambiguous rather than silently selecting a representative; use the trace table to enumerate boundary outputs.

From one unit to a set of units

.at() answers for one unit. .of(units, within=) answers for many at once: it takes the same units tl.units(site, indices) takes, either a bool mask over the view owner's full output or coordinate tuples over its full shape (batch and channel included), and returns a trace-bound tl.Selection with one region per site named in within=.

# Where does a 2x2 patch of conv1 reach in layer2 and layer3?
patch = torch.zeros(1, 64, 112, 112, dtype=torch.bool)  # conv1's output shape
patch[0, :, 50:52, 60:62] = True
reach = trace["conv1"].projective_field.of(patch, within=["layer2", "layer3"])

# Which conv1 units can affect these layer3 units?
sources = trace["layer3"].receptive_field.of([(0, 0, 3, 3), (0, 0, 3, 4)], within="conv1")

# The answer is a Selection: combine it, cut the graph to it, or intervene on it.
other = trace["conv1"].projective_field.of([(0, 0, 80, 80)], within=["layer2", "layer3"])
shared = reach & other
sub = trace.subgraph(reach)

within= takes one region or a list: an op or layer label, a module address (its output op), an Op, Layer, Module or ModuleCall, or a selector. It defaults to the model outputs for the projective direction and the model inputs for the receptive direction. Each region's provenance.relation, provenance.basis and provenance.field_status state what kind of answer it is. method="geometric" (the default) unions the per-unit boxes and refuses a site with no per-unit geometry (influence_geometry_unavailable); method="gradient" measures the set with autograd probes on a backward-ready trace and refuses above probe_budget= probes (influence_probe_budget).

Namespace reference

tl.receptive_field is lazy and deliberately outside tl.__all__. Its result vocabulary is GridLayout, ReceptiveField, ReceptiveFieldAxis, ReceptiveFieldBox, ReceptiveFieldBoxAxis, GradReceptiveField, ReceptiveFieldProfile, ReceptiveFieldValidation, ReceptiveFieldValidationStatus, ReceptiveFieldViolation, ReceptiveFieldStatus, ReceptiveFieldAlignment, and ReceptiveFieldDirection. The query view type is ReceptiveFieldView.

For extension, use register_rf_rule() and rules(); ReceptiveFieldRule and ReceptiveFieldRuleContext are the corresponding rule types, and rules() is a read-only snapshot. node_spec() creates a Trace.draw(node_spec_fn=...) callback from an Op, a single-pass Layer, or a single-call ModuleCall or Module such as trace["layer3"]. verify() runs the model-facing diagnostics: it returns a ReceptiveFieldVerification that pairs geometric containment checks with sampled EmpiricalAdjointCheck comparisons (the receptive/projective empirical derivatives at a shared unit). Its .verdict property is the tri-state summary: FAIL reports a real violation, INDETERMINATE means the empirical half never armed (see below), and PASS requires every containment check to pass with no adjoint mismatch; .passed is true only for PASS. cross_validate() gives the batch sweep. The typed error surface is ReceptiveFieldError, ReceptiveFieldUnavailableError, ReceptiveFieldConfigurationError (invalid query or register_rf_rule() arguments, e.g. a source= op that does not belong to the trace, or duplicate/empty rule names), ReceptiveFieldValidationError, AmbiguousInputError, AmbiguousPassError, AmbiguousCallError, AmbiguousTargetError, NoInfluencePathError, and BackendUnsupportedError.

Verify the tripwire

The empirical half only arms on a trace captured with gradient-ready inputs, a backward-ready graph, and reference-saved payloads; anything less is reported as INDETERMINATE, never as a failure:

trace = tl.trace(
    model,
    x.requires_grad_(True),
    capture=tl.options.CaptureOptions(backward_ready=True, save_mode="reference"),
)

# Exhaustive/sampled trace sweep over center units (and optionally corners).
results = tl.receptive_field.cross_validate(trace, units="center")

# The named model-facing convenience entry point.
results = tl.receptive_field.verify(trace, units="center")

# Capture a backward-ready trace internally, then sample both directions.
results = tl.validate(model, x, scope="receptive_field")

Each check is PASS, FAIL, or INDETERMINATE. There is no coordinate tolerance: a gradient support position outside a claimed geometric box is a bug to investigate, not a value to relax. INDETERMINATE is honest when geometry cannot support a containment claim.

The tl.validate(..., scope="receptive_field") scope is the sampled validator entry point. It is useful in model integration checks; use cross_validate() or verify() when choosing the operations, endpoints, or units yourself.

The scope captures with save_mode="reference" and checks its metadata, so a model whose forward writes its own buffers in place is refused with MutatedReferenceError: the saved buffer references no longer hold the values the operations read. A train-mode BatchNorm, which updates its running statistics during the forward, is the common case; validate it in eval mode.

Visual audit: cone, box, and heatmap

unit = op.receptive_field.center_unit(batch_index=0)

# Highlight the ancestor cone in the graph drawing.
trace.draw(
    node_spec_fn=tl.receptive_field.node_spec(op, unit=unit),
    file_format="svg",
)

# Save the source image overlay and the empirical gradient heatmap together.
op.receptive_field.show(unit, grad=True).save("rf_overlay.png")

Use a complete index for .probe_grad(), .check(), and .show(); .at() alone takes the shorter coordinate tuple over windowed axes. This separation prevents a guessed batch coordinate from becoming an apparently precise answer.

References