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¶
- Araujo, Norris, and Sim, Computing Receptive Fields of Convolutional Neural Networks, Distill, 2019.
- Luo, Li, Urtasun, and Zemel, Understanding the Effective Receptive Field in Deep Convolutional Neural Networks, NeurIPS 2016.
- Lehky and Sejnowski, Network model of shape-from-shading: neural function arises from both receptive and projective fields, Nature, 1988.