Reference
Locations
Naming where in a model a read or a write acts.
A document names a place in a sites entry: a
component, a layer where that component is
one per layer, and optionally which heads or
units of it. That is all the document says. Which child
module spells the place on this checkpoint, which side of that module carries
the tensor, how wide it is and where it sits in the forward pass are facts
about the architecture, and the compiler asks nnterp for them before any
weights are loaded.
There are 21 component names. Seventeen go through an nnterp accessor;
four are mini's own interior rows — a tensor that never crosses a
module boundary, addressed as one call inside a forward through nnsight's
.source. (One accessor, the attention pattern, is an operation
on nnterp's side too; mini does not have to know, because nnterp reaches it.)
The table is a floor and not a fence: a name mini has never heard of but
model.internals has is addressable as well.
At a glance #
explain compiles a document against the model, with no
weights, and prints the place every read and every write resolved to.
documents/v2/head_patching.json patches one attention head at a
time and scores the result at the model's head:
$ uv run causalab-mini explain documents/v2/head_patching.json
<root>: Plan
heads=0: Plan
score: Observe metrics=['iia/match', 'logit_diff/logit_diff'] saves=['iia.json', 'logit_diff.json']
forward 'original' on 'counterfactual' (4 rows x 11 tokens)
read 'v_cf' at attention_premix[0] pos={index:-1} features=[0]/4 via 'identity'
forward 'patched' on 'base' (4 rows x 11 tokens)
write 'patch' at attention_premix[0] pos={index:-1} features=[0]/4 swap(v_cf) via 'identity'
read 'logits' at lm_head pos={index:-1} via 'identity'
…
heads=3: Plan
score: Observe metrics=['iia/match', 'logit_diff/logit_diff'] saves=['iia.json', 'logit_diff.json']
forward 'original' on 'counterfactual' (4 rows x 11 tokens)
read 'v_cf' at attention_premix[0] pos={index:-1} features=[3]/4 via 'identity'
forward 'patched' on 'base' (4 rows x 11 tokens)
write 'patch' at attention_premix[0] pos={index:-1} features=[3]/4 swap(v_cf) via 'identity'
read 'logits' at lm_head pos={index:-1} via 'identity'
attention_premix[0] is a component and a layer;
features=[0]/4 is head 0 of four; lm_head carries no
layer because there is only one of it. The whole document, and what it
produces, is at the bottom of this page.
The site field #
| key | what it is |
|---|---|
component |
One of the 21 names below, or any accessor in this model's
model.internals. Required. |
layers |
A one-element list, at a per-layer component. What happens when it is missing or spurious is below. |
heads |
A non-empty list of distinct head indices. Only at a per-head tensor. |
units |
A non-empty list of distinct feature indices, anywhere the width
is known. Not together with heads. |
Both authoring formats spell the layer as a one-element list, and both
refuse a longer one: layers must be a one-element band; a band spanning
several layers is one address and is not implemented
. To sweep depth,
sweep the site — see Layer sweep.
A layer outside the model's stack is refused when the document is compiled,
by number: site 'target': layer 24 is outside the model's 16 layers.
The component list #
What each name is, and what the tensor at it is. Width is the model attribute nnterp publishes the feature axis as; a per-head tensor's width is that attribute times the head count, because every head is handed on side by side.
| component | the tensor | width |
|---|---|---|
input_ids |
The token ids going into the embedding table. Read-only. | — |
embeddings |
The embedding table's output, before layer 0. | hidden_size |
block_input |
The residual stream entering the block. | hidden_size |
attention_input_norm |
The norm the attention reads. | hidden_size |
attention_query |
Interior. The query handed to the attention implementation: already projected, reshaped and rotated. | qk_head_dim × query heads |
attention_key |
Interior. The same call's argument 2 — the keys before grouped-query attention repeats them, so this is key-head space. | qk_head_dim × key/value heads |
attention_scores |
Interior, eager only. The softmax's argument: QKᵀ·scale with the causal and padding masks already added. | — (the last axis is keys) |
attention_probs |
Eager only. The attention pattern the values are actually mixed with. | — (the last axis is keys) |
attention_z |
Interior. The mixer's per-head result, before the output projection. | head_dim × query heads |
attention_premix |
The output projection's input — every head's result side by
side. The same numbers as attention_z, at a module
boundary. |
head_dim × query heads |
attention_output |
The attention's additive contribution to the residual stream, never including the stream itself. | hidden_size |
block_mid |
block_input + attention_output. |
hidden_size |
mlp_input_norm |
The norm the MLP reads. Equals mlp_input. |
hidden_size |
mlp_input |
What the MLP module is called with. | hidden_size |
mlp_activation |
The activation function's output. | intermediate_size |
mlp_neuron_output |
The down projection's input: act(gate)·up on a gated
MLP, the activation itself on GPT-2, which has no gate. |
intermediate_size |
mlp_output |
The MLP's additive contribution to the residual stream. | hidden_size |
block_output |
block_mid + mlp_output. |
hidden_size |
ln_final |
The final norm's output. | hidden_size |
lm_head |
The unembedding's output. | vocab_size |
logits |
What the model predicts from, which is not always the head's output. | vocab_size |
block_mid == block_input + attention_output,
block_output == block_mid + mlp_output,
mlp_input_norm == mlp_input. These are nnterp's definitions,
asserted on its side over 26 families, and they are what makes
attention_output mean the sublayer's contribution on a family
that adds the residual inside the attention module.
Four of these are the residual stream — embeddings,
block_input, block_output, ln_final —
and a read with "view": "logits" is refused anywhere else,
because pushing a tensor through the final norm and the head only means
something for a tensor those two expect.
Where each one lands on a checkpoint #
locate is what turns a name into an address, and what it
stamps in comes from nnterp: the module this family spells the place with,
which side of it, and the rank that puts reads in forward order.
module is relative to the layer for a per-layer place and to the
model for a whole-model one; an interior has no module, and carries the
.source operation instead.
from causalab_mini import address
from causalab_mini.engine import NNterpEngine
from causalab_mini.plan.spec import Model
e = NNterpEngine.load(Model(key="hf-internal-testing/tiny-random-LlamaForCausalLM",
revision="9fb191250dd56d0ba7ec9785a025ed29c03d5998", dtype="fp32",
attn_implementation="eager"), dispatch=False)
g = NNterpEngine.load(Model(key="hf-internal-testing/tiny-random-gpt2",
revision="71034c5d8bde858ff824298bdedc65515b97d2b9", dtype="fp32",
attn_implementation="eager"), dispatch=False)
for name, row in address.describe().items():
layer = 0 if row["layered"] else None
a, b = e.locate(name, layer), g.locate(name, layer)
print(f"{name:22s} rank={str(a.rank):8s} io={str(a.io):7s} llama={a.module!r:32s} gpt2={b.module!r}")
attention_query rank=(0, 11) io=None llama=None gpt2=None
attention_key rank=(0, 12) io=None llama=None gpt2=None
attention_scores rank=(0, 15) io=None llama=None gpt2=None
attention_probs rank=(0, 20) io=output llama='self_attn' gpt2='self_attn'
attention_z rank=(0, 22) io=None llama=None gpt2=None
input_ids rank=(-1, -2) io=input llama='embed_tokens' gpt2='embed_tokens'
embeddings rank=(-1, -1) io=output llama='embed_tokens' gpt2='embed_tokens'
block_input rank=(0, 0) io=input llama='' gpt2=''
attention_input_norm rank=(0, 5) io=output llama='input_layernorm' gpt2='ln_1'
attention_premix rank=(0, 25) io=input llama='self_attn.o_proj' gpt2='self_attn.c_proj'
attention_output rank=(0, 30) io=output llama='self_attn' gpt2='self_attn'
block_mid rank=(0, 33) io=input llama='post_attention_layernorm' gpt2='ln_2'
mlp_input_norm rank=(0, 36) io=output llama='post_attention_layernorm' gpt2='ln_2'
mlp_input rank=(0, 40) io=input llama='mlp' gpt2='mlp'
mlp_activation rank=(0, 44) io=output llama='mlp.act_fn' gpt2='mlp.act'
mlp_neuron_output rank=(0, 47) io=input llama='mlp.down_proj' gpt2='mlp.c_proj'
mlp_output rank=(0, 50) io=output llama='mlp' gpt2='mlp'
block_output rank=(0, 60) io=output llama='' gpt2=''
ln_final rank=(2, 1000) io=output llama='ln_final' gpt2='ln_final'
lm_head rank=(2, 1001) io=output llama='lm_head' gpt2='lm_head'
logits rank=(2, 1002) io=output llama='' gpt2=''
Two families whose module trees share no path, one set of names. The
module column is the whole of what standardization buys here:
block_mid is the input of post_attention_layernorm
on Llama and of ln_2 on GPT-2, and the document says
block_mid either way.
rank is (layer, order), nnterp's
internals.rank, and reads are issued in that order because
nnsight cannot reach back to a value the model has already passed. The
whole-model places sort outside the stack: the embeddings at layer −1, the final
norm and the two head rows at layer N. Mini keeps its own numbering
only for the four interiors nnterp does not address, interleaved into
nnterp's.
vocab --json and side
causalab-mini vocab --json reports a side per
component. That is the row's own field, which only an interior sets; at a
boundary the side is nnterp's and arrives with locate, as the
io column above. They disagree for
input_ids, block_input, block_mid,
mlp_input, attention_premix and
mlp_neuron_output, all of which are read on the way in.
The whole-model places #
Five components are one per model and take no layers:
input_ids, embeddings, ln_final,
lm_head and logits. The protocol format checks both
directions — lm_head takes no layers,
block_output is addressed at one layer.
A plan-shaped site may carry a layers key at a whole-model
component, and it is ignored. A per-layer component with no
layers gets no refusal of mini's either: it reaches nnterp's
assertion, layers_output is per layer: say which, which names
the accessor rather than the site.
input_ids is the only read-only place: it is the model's
input, and swapping a float activation into a tensor of token ids means
nothing, so a write naming it is refused. embeddings is the
embedding table's output — the same tensor as block_input at
layer 0 on a model with nothing between them, and not on GPT-2, which adds
position embeddings.
lm_head and logits #
nnterp carries the head's output and the model's output as two rows,
because on Gemma-2 they are two tensors: final_logit_softcapping
bounds what the model predicts from and leaves the head's own output alone.
Mini has both, lm_head and logits, and a token-space
metric binds to either — the document says which.
Measured on trl-internal-testing/tiny-Gemma2ForCausalLM, whose
cap is 30.0: a swap of 100.0 into the head reads back 100.0 at
lm_head and 29.924 at logits, which is
tanh(100/30)·30.
On a family with no cap the two reads are the same tensor,
torch.equal, which is why every shipped document goes on saying
lm_head.
Both engines reach it. nnterp's logits row is the model
itself — an empty module path — with a Selection saying the
tensor is the value's logits field. The nnterp engine reads it
through model.internals; the hooks engine hooks the root and
applies the same Selection, which is the one rule it uses
everywhere, unwrapping a tuple's first element being another
Selection (FirstIfTuple) rather than a special
case.
from causalab_mini.engine.engines.hooks import HooksEngine, engine as hooks
from causalab_mini.plan.document import ModelSpec
e = HooksEngine.load(ModelSpec("hf-internal-testing/tiny-random-LlamaForCausalLM",
"9fb191250dd56d0ba7ec9785a025ed29c03d5998", "fp32"), device_map="cpu")
for name in ("lm_head", "logits"):
a = e.locate(name)
print(name, "module=", repr(a.module), "io=", a.io)
print(" resolves to", type(hooks.resolve(a, e._names)).__name__,
"| select", hooks.selection(a, e._names))
lm_head module= 'lm_head' io= output
resolves to Linear | select None
logits module= '' io= output
resolves to LlamaForCausalLM | select Index('logits',)
The attention pattern, and the scores under it #
Two places name the attention matrix, and they are on either side of the softmax.
attention_scoresis the softmax's argument: QKᵀ·scale with the causal and padding masks already added, so a masked key is −inf here and 0 after. It is mini's own interior row, reached through the.sourceof the function the attention call dispatches to.attention_probsis nnterp'sattention_probabilities: the dropout call's output rather than the softmax's, which is the pattern the values are actually mixed with on every family — after the cast, and after an attention sink has been dropped — and the identity in eval mode. nnterp carries six per-family overrides, a sink tag and a load-time validator behind that one name. A write here ismake this head attend there
.
Both exist only under eager attention: sdpa and flash never materialize the matrix, so there is no tensor to address. The refusal says so and says what to write:
component 'attention_probs' exists only under 'eager' attention, and this model runs 'sdpa': say "attn_implementation": "eager" in the document's model block
attn_implementation is a field of the plan-shaped document's
model block. The protocol format's model block takes
key, revision and dtype and rejects
anything else, so the pattern is reachable only from the plan-shaped format.
Saying eager also turns on nnterp's
enable_attention_probs, which puts nnterp's own shape, sum and
causality check behind the accessor. A document says eager for
exactly one reason, and that is the one.
At both of these the last axis is the keys of the padded batch, not a feature width. Key j of a pattern swapped in from another prompt lands on key j here, which is the same token only if the two prompts are laid out identically — the same padded width and, row by row, the same number of real tokens. The compiler checks that before any forward and refuses otherwise. It is also why neither has a width, so a featurizer at either is refused.
documents/data/weekdays_aligned exists for that reason: it is
the subset of the weekdays pairs that tokenize to the same length under this
tokenizer, and it is what
documents/v2/attention_pattern_patching.json runs on. See
Attention pattern.
Inside a forward #
Four components — attention_query,
attention_key, attention_scores,
attention_z — name a tensor that never crosses a module boundary.
The address is a module plus one operation inside its forward, which
nnsight's .source exposes. All four hang off one call: the one
that hands the query, the keys and the values to whichever attention
implementation the checkpoint runs. Three are its arguments,
attention_z is its return, and attention_scores is
an operation inside the function it dispatches to.
The operation is named by the call site — the needle
"attention_interface(" — and resolved against the loaded
checkpoint, not tabulated, because the occurrence suffix is a property of the
transformers version. nnsight gives assignments the same namespace as calls,
so the attention forward has both an
attention_interface_0 (the assignment
attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(...))
and an attention_interface_1 (the call). A name match would hit
both; a call-shaped needle hits one, and find_op refuses unless
exactly one matches, listing every operation the forward does have.
from causalab_mini.engine import NNterpEngine
from causalab_mini.plan.document import ModelSpec
engine = NNterpEngine.load(ModelSpec(
"hf-internal-testing/tiny-random-LlamaForCausalLM",
"9fb191250dd56d0ba7ec9785a025ed29c03d5998", "fp32"), dispatch=False)
print(engine.locate("block_mid", 0))
print(engine.locate("attention_z", 0))
print([n for n in engine.model.attentions[0].source.names if "attention_interface" in n])
Address(component='block_mid', layer=0, op=None, module='post_attention_layernorm', io='input', rank=(0, 33), inside=False)
Address(component='attention_z', layer=0, op='attention_interface_1', module=None, io=None, rank=(0, 22), inside=False)
['attention_interface_0', 'attention_interface_1']
attention_scores goes one level deeper: it is
nn_functional_softmax_0 inside the function
attention_interface_1 dispatches to. That inner name is resolved
in the trace, because which function the call dispatches to is a
run-time fact; unlike the outer one it is a name and not a needle.
A forward hook sees a module's boundary and nothing between, so the hooks
engine refuses all four by name rather than approximating them. The two ways
to build one — patch the module's forward, or hook
q_proj and reimplement the reshape and the rotary embedding
here — would both be a different engine.
attention_premix is the way out: it is the same numbers as
attention_z at a module boundary, which is why
documents/v2/head_patching.json uses it and runs on both
engines.
Heads and units #
heads and units are one mechanism at two grains.
Both compile into a Selection: positions along the sequence, plus
groups and take over the feature axis. The rule is
one line — view the features as (groups, -1) and keep
take of the groups.
groups is | each group is | |
|---|---|---|
heads | the head count at this address | width / heads features |
units | the width at this address | one feature |
What is gathered comes back flat:
(…, len(take) · per_group), however the model holds it. A tensor
the model keeps as two axes — the query, which is
(batch, head, seq, head_dim) — and one it keeps flat —
attention_premix, which is o_proj's input — look the
same to a featurizer, a mechanism and a metric. A scatter puts the named
groups back and leaves the others as they were.
Which axis the sequence runs along is a fact about the address too, not
about the tensor: 1 at a module boundary, 2 inside the attention, where the
tensor is (batch, head, seq, head_dim). It is passed to the
gather rather than guessed from the shape. What the positions along that axis
are is Positions.
Per-head width is head_dim (or qk_head_dim) times
the head count, and which head count depends on the address:
attention_key is per key/value head and everything else per query
head. On a model with grouped-query attention those differ. On
hf-internal-testing/tiny-random-GptOssForCausalLM — 4 query
heads, 2 key/value heads, head_dim 16 —
causalab-mini model reports attention_query at width
64 and attention_key at width 32.
A head or unit index past the end is refused when the document is compiled,
against the real count: site 'target': head 3 of a 2-head tensor.
Asking a model what it has #
causalab-mini vocab prints the table itself — the names, which
nnterp accessor each is, which are per head, which need eager, which are
read-only. It loads nothing:
$ uv run causalab-mini vocab
components:
attention_query interior [heads]
attention_key interior [heads]
attention_scores interior [heads] needs eager attention
attention_probs nnterp attention_probabilities [heads] needs eager attention
attention_z interior [heads]
input_ids nnterp embeddings_input (read-only)
embeddings nnterp embeddings_output
…
ln_final nnterp ln_final_output
lm_head nnterp lm_head_output
logits nnterp logits
…
causalab-mini model <key> asks the checkpoint. It loads
the config, the module tree and the tokenizer and no weights, and tries every
component: the width where there is one, the resolved
.source operation for an interior, and the reason where the place
is not there.
$ uv run causalab-mini model hf-internal-testing/tiny-random-LlamaForCausalLM \
--revision 9fb191250dd56d0ba7ec9785a025ed29c03d5998
hf-internal-testing/tiny-random-LlamaForCausalLM via nnterp: 2 layers, padding_side=left
attention_query ok width=16 op=attention_interface_1
attention_key ok width=16 op=attention_interface_1
attention_scores -- component 'attention_scores' exists only under 'eager' attention, and this model runs 'sdpa': say "attn_implementation": "eager" in the document's model block
attention_probs -- component 'attention_probs' exists only under 'eager' attention, and this model runs 'sdpa': say "attn_implementation": "eager" in the document's model block
attention_z ok width=16 op=attention_interface_1
input_ids ok no width (no featurizer here)
embeddings ok width=16
…
ln_final ok width=16
lm_head ok width=32000
logits ok width=32000
model verb
It asks every per-layer component at layer 0, so a model
whose layers are not all alike is only half answered. And it has no
attn_implementation flag, so it reports whatever the family
defaults to — which is why the two eager-only rows are refused above even
though the document that names them would load the model eager.
For the per-layer answer, nnterp's own
model.internals.status(layer=i) gives a reason or
None for every accessor, without running the model.
When a place is not there #
Every refusal about existence is nnterp's unavailable_on,
decided from the address and the module tree and answered before any trace.
locate passes the reason through unchanged. Three kinds happen.
A mixture of experts has no single activation. A token goes through top-k of N experts, so there is no one activation function's output and no one down projection's input. The block's boundaries are unaffected:
$ uv run causalab-mini model hf-internal-testing/tiny-random-GptOssForCausalLM
hf-internal-testing/tiny-random-GptOssForCausalLM via nnterp: 2 layers, padding_side=left
attention_query ok width=64 op=attention_interface_1
attention_key ok width=32 op=attention_interface_1
attention_scores ok no width (no featurizer here) op=attention_interface_1
attention_probs -- component 'attention_probs': Attention probabilities are disabled for this model. Set enable_attention_probs=True when loading the model to enable them.
…
mlp_input ok width=64
mlp_activation -- component 'mlp_activation': mlps_activation does not exist on a mixture-of-experts layer, where a token goes through top-k of N experts and has no single activation. mlps_input and mlps_output are the block's boundaries and work as usual.
mlp_neuron_output -- component 'mlp_neuron_output': mlps_neurons does not exist on a mixture-of-experts layer, where a token goes through top-k of N experts and has no single activation. mlps_input and mlps_output are the block's boundaries and work as usual.
mlp_output ok width=64
…
That reason is a function of the block, not a fact about the model, because
a model may be dense for its first blocks and a mixture of experts after —
DeepSeek's first_k_dense_replace is exactly that. So
mlp_activation can resolve at layer 0 and be refused at layer 12
of the same model, and the answer is per layer.
The attention_probs row above is refused for a different
reason. This family runs eager by default, which is why
attention_scores — mini's own interior — resolves; nnterp keeps
its pattern row disabled until enable_attention_probs is asked
for, and a document asks for it by saying
"attn_implementation": "eager". Loaded that way the row resolves
here too.
A hybrid's linear-attention blocks have no attention. On
Qwen3-Next and Qwen3.5 hybrids some blocks mix with Gated DeltaNet rather than
softmax attention. nnterp splits the stack into
attention_layers and linear_attention_layers, and
every attention accessor and the pattern raise on the latter: layer i
is a linear-attention layer (Mixer); attentions/attention_probabilities
are only defined on softmax-attention layers
.
A block structure that has no such place. On a parallel
block, attention and the MLP both read the block input, so there is no
residual stream between them: block_mid and
mlp_input_norm are refused. On a post-norm block each sublayer
reads the residual stream itself and the norm comes after, so
attention_input_norm and mlp_input_norm are refused.
nnterp keys those rows by block structure and holds the reason where the
structure has no such place, so they are refused with a sentence rather than
resolving to a neighbouring tensor.
If nnterp says a place is per layer and mini's table says it is not, or
the other way round, locate refuses rather than picking one:
nnterp says per_layer=… and this table says …. One of them moved; the
table is mini's to fix
. A family override that moves a place cannot
move one side only.
A name only nnterp knows #
RenameConfig(addresses={...}) is nnterp's extension point: a
row of the address table, by accessor name, either replacing where an existing
accessor reads or adding one of your own. An accessor added that way appears
in model.internals, and a document may name it — mini's table is
a floor, and a component it has never heard of is accepted as long as this
model's internals have an accessor of that name.
from nnterp.rename_utils import Address as NnterpAddress, RenameConfig
from causalab_mini.address import AddressError
from causalab_mini.engine import NNterpEngine
from causalab_mini.plan.document import ModelSpec
engine = NNterpEngine.load(
ModelSpec("hf-internal-testing/tiny-random-LlamaForCausalLM",
"9fb191250dd56d0ba7ec9785a025ed29c03d5998", "fp32"),
dispatch=False,
rename_config=RenameConfig(addresses={
"mlp_gate_output": NnterpAddress("mlp.gate_proj", order=42),
}),
)
located = engine.locate("mlp_gate_output", 0)
print(located)
try:
engine.width(located)
except AddressError as refusal:
print("--", refusal)
Address(component='mlp_gate_output', layer=0, op=None, module='mlp.gate_proj', io='output', rank=(0, 42), inside=False)
-- the width of 'mlp_gate_output' is not a fact about the model: it depends on the batch (the attention pattern's key axis) or is not a tensor's
Mini claims nothing else about such a place. It is a module boundary, read
at the accessor's own side, one per layer or not as nnterp says, not per head,
and it has no width — so a featurizer, which needs one, is refused there rather
than sized wrongly. The order in the row is what puts it in
forward order beside everything else; give it one that reflects where the place
actually is.
The rename_config has to reach the engine when the model is
loaded, and the CLI builds its engines with a fixed set of options. So a
custom row is a Python path today: NNterpEngine.load(spec,
rename_config=…), then plan.build_request(...) and
engine.execute(...) as the CLI does.
What is refused #
Every one of these happens before a forward runs — most at validation, the rest when the document is compiled against the model.
| naming | the refusal |
|---|---|
| a component neither table has | component 'attention_pattern' has no address here, and this
model's internals have no accessor of that name either — and it
lists mini's names |
layers with more than one entry |
layers must be a one-element band; a band spanning several
layers is one address and is not implemented |
| a layer past the stack | site 'target': layer 24 is outside the model's 16
layers |
heads and units together |
a site names heads or units, not both: they slice the same
axis |
heads at a tensor that is not per head |
'block_output' is not a per-head tensor; `heads` applies
at … — and it lists the ones it does |
an empty or repeating heads / units |
heads is a non-empty list of distinct head indices |
| a head or unit past the end | site 'target': head 3 of a 2-head tensor |
a write at input_ids |
'input_ids' is read-only — the model's input |
"view": "logits" off the residual stream |
view 'logits' projects the residual stream through the head,
and 'mlp_activation' is not the residual stream |
| the pattern under sdpa | exists only under 'eager' attention … say
"attn_implementation": "eager" |
| a featurizer where there is no width | the width of … is not a fact about the model |
| an interior on the hooks engine | … is an interior … a forward hook only sees the module's
boundary … run this document on the nnterp engine |
A read of four heads, and a read of the whole model #
documents/v2/head_patching.json is the interchange of
patching.json done one attention head at a time. Two sites:
target is attention_premix at layer 0 with a sweep
over which head, and head is lm_head, which names no
layer because there is only one of it.
documents/v2/head_patching.json — the whole document
{
"header": {
"description": "Head-by-head activation patching: the interchange of patching.json, but of ONE attention head's result at a time, swept over the four heads of layer 0. The site is `attention_premix` \u2014 the output projection's input, which is every head's result side by side \u2014 so this runs on the hooks engine too; `attention_z` is the same tensor held as (heads, head_dim) and gives the same numbers bit for bit on nnterp."
},
"model": {
"key": "hf-internal-testing/tiny-random-LlamaForCausalLM",
"revision": "9fb191250dd56d0ba7ec9785a025ed29c03d5998",
"dtype": "fp32"
},
"roles": {
"base": {
"field": "input"
},
"counterfactual": {
"field": "counterfactual_inputs[0]"
}
},
"sites": {
"target": {
"component": "attention_premix",
"layers": [
0
],
"heads": {
"sweep": [
[
0
],
[
1
],
[
2
],
[
3
]
]
}
},
"head": {
"component": "lm_head"
}
},
"interventions": {
"patching": {
"reads": {
"v_cf": {
"site": "target",
"pos": -1,
"model": "original",
"input": "counterfactual"
},
"logits": {
"site": "head",
"pos": -1,
"model": "patched",
"input": "base"
}
},
"writes": {
"patch": {
"site": "target",
"pos": -1,
"mechanism": "swap",
"operand": "v_cf"
}
},
"models": {
"patched": {
"input": "base",
"writes": [
"patch"
]
}
},
"metrics": {
"iia": {
"kind": "match",
"of": "logits",
"expected": "cf_answer",
"token_form": "space_prefixed"
},
"logit_diff": {
"kind": "logit_diff",
"of": "logits",
"a": "cf_answer",
"b": "base_answer",
"token_form": "space_prefixed"
}
}
}
},
"steps": {
"score": {
"kind": "observe",
"interventions": "patching",
"rows": {
"base": "weekdays/train",
"counterfactual": "weekdays/train"
},
"saves": [
{
"value": "iia",
"file_path": "iia.json"
},
{
"value": "logit_diff",
"file_path": "logit_diff.json"
}
]
}
}
}
The document's own note says why the site is attention_premix
and not attention_z: the two are the same tensor held differently,
attention_premix is a module boundary and so runs on the hooks
engine too, and on nnterp the two give the same numbers bit for bit.
$ uv run causalab-mini run documents/v2/head_patching.json --device-map cpu --out out/heads
out/heads/document.json
out/heads/run.json
out/heads/heads=0/document.json
out/heads/heads=0/iia.json
out/heads/heads=0/logit_diff.json
…
out/heads/heads=3/document.json
out/heads/heads=3/iia.json
out/heads/heads=3/logit_diff.json
Four points, one per head, each a directory named by the swept axis. One row
of out/heads/heads=1/iia.json:
{
"example_id": "0",
"metric": "iia",
"value": 0.0,
"eligible": true,
"positions": null,
"reason": "",
"unit": "fraction",
"estimand_version": "match/v1",
"produced_by": "8334fa82ec1ce2f26e9a178eebb003c9898cdb6ee445448f25c5be4d2297865a"
}
hf-internal-testing/tiny-random-LlamaForCausalLM has random
weights. It is a fixture that proves the addressing, not a model that knows
which day follows Monday. For a per-head result that means something, run the
same shape on a real checkpoint — see
Layer sweep, which does that for
depth.
See also #
- Positions — the other half of a selection: which tokens along the sequence, and why the sequence axis is a fact about the address rather than about the tensor
- Standardization — what nnterp names,
what
model.internalsholds, and what it costs an engine not to have those names - Reads, writes, metrics — what you then do at a location, and which featurizers need a width
- Attention pattern — the two eager-only places, run end to end
- Sweeps — sweeping a site's layer, heads or units, which is how a document names more than one place
- The engine —
locate,headsandwidthare three of the eight members an engine has to answer