Facets¶
Facets are named views of a captured activation record. They provide one access
surface for structural outputs, such as an LSTM's output, h_n, and c_n,
and semantic recipe outputs, such as attention q, k, v, MLP activations,
normalization parameters, and embedding weights.
Access¶
Use item access as the canonical form:
log = tl.trace(model, x)
q = log.modules["blocks.0.attn"].facets["q"]
h_n = log.modules["lstm"].facets["h_n"]
Facets live on a Trace. tl.record takes no facet keyword and a Recording has no
modules[...].facets; cook it first with recording.to_trace() and read the facets there
(a facet whose inputs the save= predicate did not keep fails explicitly).
Attribute access is convenience sugar for valid Python identifiers that do not
collide with FacetView methods:
Item access always works for names that collide with methods, such as keys, and
for dotted structural names such as out1.0. Typed container paths are also
accepted when string names would collide.
Structural output names come from the captured ContainerSpec:
single tensor output -> out
named output -> values, indices, output, h_n, c_n
positional output -> out0, out1
nested positional -> out1.0
module.outs remains a thin alias for raw structural outputs. module.facets is
the unified named access layer. If a module was called more than once,
module.facets raises; choose an explicit pass with module.calls[n].facets.
A call's view runs the module's recipes for that call alone, and child modules
resolve to their call inside it: for an attention layer shared across depth
(ALBERT), module.calls[n].facets.q is the query projection of pass n.
trace.modules_with_facet(name) and trace.attention_blocks() still yield such a
reused Module when its recipe declares the facet and at least one of its calls has
it available now; loop over module.calls.values() to read each pass.
keys(), iteration, in, has(name), and item/attribute access are
available-now surfaces: recipes run once and only facets readable in the current
capture appear. .get(name, default) returns default for any facet that is not
available now. Use the provisional declared() accessor for declaration-level
discovery; it reports every structural and declared recipe facet with status
available_now, structurally_absent, needs_capture, or
declared_not_produced, including any enabled aliases.
Structurally absent facets are omitted and raise plain KeyError on item
access. Declared facets that need additional payload capture are omitted and
raise MissingFacetError with a recapture hint naming what to save.
Specs¶
Recipes now return FacetSpec objects rather than computed tensors. A spec
records:
home kind: op, module_output, module_input, parameter, computed
home label/address and pass/call index
output path
transform primitive chain
capability class and flags
value version
conflict or alias group
recipe id and version
The supported structural primitive vocabulary is:
spec[key]
spec.heads(n_heads, d_head)
spec.split(n_sections, dim=-1)
spec.reshape(...)
spec.transpose(dim0, dim1)
spec.select(dim, index)
Read is lazy: facet.value applies the transform chain to the home value.
Facet objects are tensor-like, so most tensor reads can use the facet directly:
Gradients¶
facet.grad applies the same transform chain to the home op's saved output
gradient. It is available only for grad-capable op-anchored structural facets and
only when the home op has a saved gradient.
Default traces do not save gradients. When a gradient is unavailable,
facet.grad returns a MissingGrad sentinel with an exact recapture
instruction. The sentinel does not raise merely because it was returned; tensor
use raises:
log = tl.trace(model, x)
missing = log.modules["attn"].facets["q"].grad
print(missing.reason)
log = tl.trace(
model,
x,
capture=tl.options.CaptureOptions(backward_ready=True, save_grads=True),
)
log.backward(log[log.output_layers[0]].out.sum())
q_grad = log.modules["attn"].facets["q"].grad
Parameter facets, such as LayerNorm gamma and Embedding weight, read the
parameter value. Their parameter gradients are not exposed through facet.grad
in this release; this path is intentionally read-only.
Registration¶
The process registry remains additive:
Registration affects future traces. Each trace captures an immutable registry snapshot with a version and provenance id, so lazy facet access is stable even if the global registry changes later.
Per-trace and context additions are also capture-time only:
log = tl.trace(model, x, facets=[my_attention])
with tl.facets.adding(my_attention):
log = tl.trace(model, x)
The guide Adding conveniences for your model
builds a custom facet next to a custom input detector (tl.preprocessing.register)
and output decoder (tl.postprocessing.register); Semantic input and output
covers the built-in routes those registries already hold.
tl.facets.reset() restores the process registry to built-ins. tl.facets.functions()
and tl.facets.describe(class_name) expose registry metadata.
Conflict resolution is deterministic: class_qualname is more specific than
class_name, which is more specific than a predicate; user recipes beat built-ins
only within the same specificity tier. True same-tier facet collisions warn.
Reading module config in a recipe (head counts, dims)¶
A recipe receives the TorchLens Module record, not the live nn.Module. The
record does not proxy arbitrary live attributes as direct attributes -- they are
snapshotted into module.custom_attributes. Use tl.facets.config_value(module, *names),
which checks record attributes and the custom_attributes snapshot, instead of
module.num_heads directly. The recipe-authoring helpers are public under tl.facets:
config_value, child_output_spec(module, child_name, recipe_id) (a child module's
output as a spec) and parameter_spec(module, name, recipe_id) (a parameter as a spec);
no private import is needed:
@tl.facets.register(class_name="MyAttention", target_scope="module")
def my_attention(module):
n_heads = tl.facets.config_value(module, "num_heads", "n_heads", "num_attention_heads", "n_head")
qkv = tl.facets.child_output_spec(module, "qkv", "my_attention")
return {"q": qkv.split(3, dim=-1)[0].heads(n_heads)} # d_head inferred from shape
FacetSpec.heads(num_heads, d_head=None) infers the head dimension from the
projection's last axis when the facet is read (last_dim // num_heads); a last
axis the head count does not divide refuses at read time. Pass d_head explicitly
when the projection carries extra columns.
Head counts are inferred automatically for any module that stores a conventionally
named count -- HF configs, torch.nn.MultiheadAttention (.num_heads), or a custom
module using one of the common names. Note that num_heads cannot be recovered
from tensor shapes alone (a q-projection's output dim is num_heads * d_head, an
unfactorable product), so a truly bespoke module with a non-standard field name must
pass the count explicitly.
Capability Classes¶
Facet transform chains fail closed. The chain capability is the intersection of its primitive flags.
bijective_view reshape, transpose, permute-style exact views; read, grad, write
selection slice, select, split; read, grad, write
aliasing_selection grouped-query or broadcast-style aliases; read and grad only
computed callable or multi-home values; read-only
Only bijective_view and selection chains are writable. aliasing_selection
facets, such as grouped-query K/V heads selected through a query-head index,
require an explicit alias policy and are refused by default. computed facets
are never writable.
Intervention¶
Facet intervention reuses the existing TorchLens hook machinery. A facet target resolves to its home op, and TorchLens installs a normal whole-output hook on that home. The wrapper reads the facet slice from the home output, calls the user hook on that slice, then scatters the edited slice back into the full home output returned by the hook.
Use tl.facet(name) for a named facet and tl.head(index, name) or
tl.facet(name).head(index) for attention-head slices:
# Facet recipes often need internal child activations, so this example
# intentionally saves all payloads. For ordinary selective capture, prefer
# save=tl.func(...) or save=tl.in_module(...).
log = tl.trace(
model,
x,
capture=tl.options.CaptureOptions(save_args=True), save="all",
)
edited = log.fork("ablated")
edited.do(tl.head(3, "q"), tl.zero_ablate(), model=model, inputs=x)
Static patching uses the same scatter-back path:
patch = torch.zeros_like(log.modules["blocks.0.attn"].facets.head(3).q)
edited = log.fork("patched")
edited.do(tl.facet("q").head(3), patch, intervention=tl.options.InterventionOptions(engine="set_only"))
edited = edited.run(model, x)
tl.head(index) without a facet name targets the default attention projection
facets q, k, and v wherever they are present:
Scatter-back preserves the home tensor dtype, device, shape, and memory format.
bijective_view primitives write through exact view operations such as reshape
and transpose. selection primitives scatter into the selected region, such as
a split third of a fused QKV projection or one selected head.
When multiple facet edits share one home op, TorchLens applies them in attach
order and checks boolean write masks before rerun. Non-overlapping writes, such
as GPT-2 c_attn edits to q head 0 and k head 1, compose on the shared
home. Overlapping same-home writes fail at attach time with a conflict error.
Facet intervention fails closed in these cases:
computed facet refused; read-only
aliasing_selection refused unless a future explicit alias policy is supplied
non-op home refused
non-raw value version refused as not intervention-safe
overlapping writes refused before rerun
Fused attention reconstruction facets are read-only. Editing internal fused facets requires recapturing with an eager attention implementation so the facet is backed by a real op.
Patching¶
Patching helpers live at tl.facets.patching. They run a clean trace and a
corrupted trace, then reuse the facet intervention path (fork,
do with model=/inputs= (a rerun), and tl.facet/tl.head selectors) to patch clean
facet activations into the corrupted run.
The metric callable receives the patched Trace and must return a scalar
tensor:
def metric(log):
return log[log.output_layers[0]].out[..., answer_id].mean()
scores = tl.facets.patching.activation_patch_attention_heads(
model, clean_tokens, corrupted_tokens, metric
)
Available activation-patching helpers:
activation_patch_residual_stream -> [layer, pos] by default, or [layer]
activation_patch_attention_output -> [layer]
activation_patch_attention_heads -> [layer, head]
activation_patch_mlp_output -> [layer]
Residual patching defaults to resid_pre and patches each position along axis
1. Attention-head patching defaults to the result facet, whose shape is
[batch, pos, head, d_model]; tl.head(i, "result") selects one head's
contribution. MLP patching defaults to the output facet on modules that expose
the built-in MLP facet family.
Attribution patching is a faster linear approximation to activation patching:
summed over the component dimensions. The helper runs one clean forward/backward
and one corrupted forward/backward, reads facet gradients through facet.grad,
and returns [layer, head]:
approx = tl.facets.patching.attribution_patch_attention_heads(
model, clean_tokens, corrupted_tokens, metric
)
Attribution patching requires gradient capture. By default the helper traces
with save_grads=True. If gradients are not saved, it raises a clear
error that includes the MissingGrad recapture instruction.
Reconstruction¶
Fused torch.nn.functional.scaled_dot_product_attention hides scores,
pattern, and z. TorchLens reconstructs these as read-only computed facets
from the exact inputs and options the fused kernel received. A default capture
already records them: the SDPA call's replay argument template holds scale,
is_causal, dropout_p and enable_gqa, and the Q, K, V and mask tensors are
the saved outputs of the ops that produced them. So a plain trace serves the
facets:
log = tl.trace(model, x)
pattern = log.modules["blocks.0.attn"].facets.pattern
z = log.modules["blocks.0.attn"].facets.z
When the default capture did not keep an input (a selective save= that does not save
the op producing Q, K, V or the mask, or a structure-only capture), the facet
fails closed: facets.declared() marks it needs_capture and access raises
MissingFacetError naming the missing input. Saving the SDPA call's arguments
verbatim lifts that requirement:
log = tl.trace(model, x, save=tl.in_module("blocks.0.attn"),
capture=CaptureOptions(reconstruction_ready=True))
CaptureOptions(reconstruction_ready=True) enables save_args=True and
save_rng=True. Either way the reconstruction uses the tensors that actually fed
the fused kernel, so RoPE models use the post-RoPE Q and K, not the
q_proj/k_proj outputs.
Validation is per facet and non-vacuous:
z compared directly to the fused SDPA op output
pattern, scores recompute z = pattern @ V, then compare to SDPA output
result sum per-head W_O contributions and compare to output projection
GQA/MQA uses PyTorch SDPA's enable_gqa repeat-interleave convention. Causal
masks and boolean/additive tensor masks are supported. Nonzero SDPA dropout is
fail-closed for now; the facet is marked needs_capture in facets.declared() and
item or attribute access raises MissingFacetError naming dropout/RNG as the
missing prerequisite rather than serving an unvalidated value.
Residuals¶
Transformer-like block recipes expose:
resid_pre block input op
resid_mid first identifiable post-attention residual add op
resid_post block output op
These are op-structural facets when the anchors exist, so .grad works under
the normal gradient contract. If a block structure does not expose a real
post-attention add, resid_mid is absent instead of guessed.
Attention modules also expose read-only per-head result when the recipe can
find an output projection. result[..., head, :] is that head's contribution to
the projected residual stream; summing heads and adding projection bias matches
the captured output-projection tensor.
Unembedding Head and Logit Lens¶
Every name in this section is experimental and may change in any release.
The language_model_head recipe matches a module with a conventional
unembedding child (lm_head, embed_out, or output_projection) and anchors
the facts a logit-lens projection needs:
logits head output op (the model's real output logits)
unembed_weight head weight parameter, shape (vocab, d_model)
unembed_bias head bias parameter, or structurally absent
final_norm_kind "layer_norm" | "rms_norm" for the norm feeding the head
final_norm_eps that norm's epsilon
final_norm_gamma that norm's weight parameter
final_norm_beta that norm's bias parameter (absent for RMSNorm)
final_norm_input op-anchored norm input, when the anchor is unambiguous
The final norm is derived structurally from the traced dataflow (the innermost classified norm module containing the head's input op), never by a name search over the module tree.
torchlens._semantic.logit_lens projects each block's residual-stream facet
through the model's own final norm + unembedding:
from torchlens._semantic import logit_lens
result = logit_lens(log) # resid_post through the model's head
print(result.summary(tokenizer=tokenizer)) # per-layer top-1 table
result.entries[3].logits # one layer's projected logits
result.stacked() # (n_layers, *logits_shape)
result.top_tokens(5, tokenizer=tokenizer) # per-layer top-k (token, prob)
The reconstructed lens is validated before use: applying it to the LAST
block's captured resid_post must reproduce the model's captured logits, so a
head the reconstruction cannot represent (nonstandard norm scaling such as a
(1 + weight) RMSNorm, an extra projection, dropout before the head) refuses
with LogitLensError instead of silently mislabeling layers. Pass
verify=False to trust the reconstruction explicitly, lens= to supply
your own callable (or per-address mapping, e.g. a tuned lens), and modules= /
facet= to control what is projected. Architecture coverage extends through
facet recipes producing the same facet names -- the appliance never special
cases architectures.
Validation proves only that the reconstruction reproduces the LAST layer; it says nothing about
how faithful the projection is at depth. The final norm and unembedding were trained on the last
layer's residual stream, and earlier layers do not use that basis, so early-layer top tokens are
weak evidence and often look like noise even when the layer carries the information
(Belrose et al., 2023, introduced the tuned lens for this reason). Read the lens as a trend
across depth, compare it with a probe or a tuned lens passed as lens=, and do not read one early
layer's top token as what that layer "thinks". A Jacobian lens (the corpus-average Jacobian
of the final residual with respect to a block's, passed as lens=) is fitted from public
primitives in the Jacobian lens recipe.
torchlens._semantic.logit_lens_predictions is the streaming reduction of the
same sweep: it projects ONE layer at a time, keeps only per-position top-k
values, the full-vocabulary logsumexp, and requested-token logits /
probabilities / one-based ranks, then discards that layer's full projection --
so long sequences never materialize every [batch, positions, vocab] tensor
at once:
from torchlens._semantic import logit_lens_predictions
preds = logit_lens_predictions(log, k=5, positions=[-1], tokens=[the_id])
preds.rows[3].top_ids # one layer's top-k token ids
preds.rows[-1].provenance # "native output" for captured logits
mask_input=True bans the model input's own token ids from every row (each
batch row's input tokens get -inf logits before the top-k, the logsumexp
denominator, probabilities and ranks are taken), the one masking that cannot be
redone on the retained rows afterwards; the trace's first input must be a saved
integer token-id tensor (logit_lens_mask_input_unavailable otherwise). For
entropy, KL to the final output, cross-entropy and rank along the trajectory in
one call, see tl.mechinterp.measure_trajectory in
docs/reference/stats.md.
It shares facet= / modules= / lens= / verify= semantics (and the
fail-closed lens validation) with logit_lens. Probabilities always use the
full-vocabulary softmax denominator. include_native=True (the default)
appends a row served directly from the model's CAPTURED output logits,
labelled "native output"; every reconstructed row is labelled "projected
through final norm/head". On a logits_to_keep-sliced capture the native
row covers only the positions the capture kept, and requested positions
outside a row's covered range are dropped from that row rather than invented.
Vision heads (experimental)¶
A vision model has no language-model head. When a trace has none, the
default lens falls back to a vision head recipe, and it is validated the same
way (the last block's captured resid_post must reproduce the captured
logits):
classification_headmatches a module with a linearclassifierchild, such asViTForImageClassification: the final norm plus the classifier applied to the one token the head reads ([CLS]).result.lens_sourceis"classifier_head"and each layer's logits are[batch, num_labels], soresult.top_tokens()answers "what does this layer already classify the image as?".contrastive_image_headmatchesCLIPModel. Capture the model on images AND the label prompts you want to score (input_idsof "a photo of a cat", "a photo of a dog", ...): each vision-tower block's[CLS]state goes throughpost_layernorm,visual_projectionand L2 normalization, and is scored against the captured text features with CLIP's ownlogit_scale.result.lens_sourceis"contrastive_head", each layer's logits are[n_images, n_prompts], and the last layer equals CLIP'slogits_per_image. Only vision-tower blocks are projected; passing a text-tower block inmodules=refuseslogit_lens_layer_outside_head.
classification_head facets contrastive_image_head facets
logits logits_per_image
classifier_weight / classifier_bias image_projection_weight
pooled_position (token read) text_features (text_projection output)
tower_address (blocks feeding it) logit_scale
final_norm_* (as above) pooled_position, tower_address, final_norm_*
Only a single-token read is reconstructed. A head that mean-pools, uses an
attention pooler, or runs a pooler MLP leaves the final norm unanchored, and
the lens refuses logit_lens_head_unavailable instead of guessing.
User lens identity (experimental)¶
A user lens may record which model it was built for: set model_identity on
the callable (or on the mapping, or on each per-layer callable) to the
model-identity record an extraction artifact carries
(manifest["signature"]["model_identity"]). With the default
validate=True:
- a measured identity that differs from the traced model (compared against
the trace's live source model) refuses with
logit_lens_identity_mismatch;verify=Falseruns the lens anyway; - a matching identity runs silently;
- a lens with no identity, or one that cannot be compared (an asserted
record, or the source model is gone), gets a last-layer agreement check:
when the lens applied to the last block's
resid_postdoes not reproduce the captured logits, the coded warninglogit_lens_identity_unrecordedreports the largest difference and the top-1 agreement.
Fallback¶
Every module has structural facets even with no semantic recipe. This gives a nnsight-style floor for arbitrary models:
Semantic names are added when a recipe classifies the module; otherwise the module path plus structural output names remain the stable addressing surface.
TransformerLens Aliases¶
TransformerLens hook-name aliases are off by default. Enable them once per process:
When enabled, aliases such as hook_pattern, hook_z, hook_result,
hook_resid_pre, hook_resid_mid, and hook_resid_post resolve to native
TorchLens facets when those native facets exist.
torchlens.facets.transformer_lens_aliases_enabled() reports the current
process-wide state.
Migration Cheat Sheet¶
TransformerLens hook_q -> facets.q
TransformerLens hook_k -> facets.k
TransformerLens hook_v -> facets.v
TransformerLens hook_pattern -> facets.pattern
TransformerLens hook_z -> facets.z
TransformerLens hook_result -> facets.result
TransformerLens hook_resid_pre -> facets.resid_pre
TransformerLens hook_resid_mid -> facets.resid_mid
TransformerLens hook_resid_post -> facets.resid_post
nnsight module.path.output -> log.modules["module.path"].facets["out"]
Coverage and Recipe Maintenance¶
torchlens.facets.coverage(log) (experimental) reports
per-module recipe coverage for a completed trace: which recipes matched, which
declared facets are readable, typed absence reasons, an inventory of
structural-only module classes (recipe candidates), and disclosed rows for
modules whose facet view refuses (e.g. multi-call reuse). It is the
machine-readable input to the facet-maintenance pipeline in
tools/facet_maintenance/ (DISCOVER_FACETS.md is the durable sweep prompt;
run_facet_audit.py is the repeatable runner). The pipeline produces
PROPOSALS FOR REVIEW only -- facet recipes are never auto-merged, because a
wrong facet label is a confidently mislabelled part of someone's model.
Recipe Plugins¶
On FIRST USE of the facet-recipe subsystem (not at import torchlens --
the loader is module-level in the facet-recipes package initializer, and
the facet subsystem is lazy), TorchLens loads installed setuptools entry
points in the torchlens.recipes group. A broken entry point therefore warns
when facets are first touched, not at import. Entry points are never scanned
from local directories.
Built-In Inventory¶
TorchLens records built-in facet capabilities as data in
BUILTIN_FACET_CAPABILITY_INVENTORY in the private facet-recipes package (no public spelling yet). Current classes
are:
Only op_structural built-in facets may claim facet-gradient capability.
Glossary¶
FacetSpec: ABI record describing a facet home, transform chain, capability
flags, value version, alias/conflict group, and recipe provenance.
tl.facet(name): selector for facet-level intervention on a named facet.
tl.head(index, name=None): selector for facet-level intervention on one
attention head. With name=None, targets default q, k, and v facets.
scatter_update: ABI method that writes an edited facet slice back into the
full home-op output and returns the edited home tensor.
MissingGrad: typed sentinel returned by facet.grad when the home op has
no saved gradient or the facet is not grad-capable.
bijective_view: transform class for exact view operations such as reshape and
transpose.
selection: transform class for slice/select/split operations.
aliasing_selection: transform class for repeated or shared storage selections,
such as grouped-query K/V head access.
computed: transform class for callable, nonlinear, or multi-home values.
Write capability: a facet may be edited only when every primitive in its chain is scatter-back capable and the home is an op raw output.
Alias policy: explicit rule required before an aliasing_selection facet can be
written; TorchLens provides no public policy and refuses these writes by default.
Conflict policy: same-home facet writes compose in attach order when their write masks do not overlap; overlapping writes raise before rerun.
facets=: tl.trace argument for additive per-trace facet recipes captured in
that trace's registry snapshot.
adding(): tl.facets.adding(...) context manager for additive context-local
recipes used only by traces captured inside the context.
reset(): tl.facets.reset() restores the process registry to built-in recipes.
Item-access naming: canonical record.facets[...] lookup for structural and
recipe facets, including dotted names and method-name collisions.