Skip to content

Commit 488c9f8

Browse files
committed
Merge upstream/dev-4.x into fix/bridge-adapter-coverage
2 parents 96c73a0 + 8b2ab80 commit 488c9f8

156 files changed

Lines changed: 9558 additions & 683 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/checks.yml

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,23 @@ jobs:
9494
~/.cache/huggingface/hub/models--ArthurConmy--redwood_attn_2l
9595
~/.cache/huggingface/hub/models--stas--tiny-random-llama-2
9696
~/.cache/huggingface/hub/models--microsoft--DialoGPT-medium
97-
key: ${{ runner.os }}-huggingface-models-v4
97+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-gpt2
98+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-Olmo2ForCausalLM
99+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-MptForCausalLM
100+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-Gemma2ForCausalLM
101+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-Gemma3ForCausalLM
102+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-GraniteForCausalLM
103+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-OlmoForCausalLM
104+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-OlmoeForCausalLM
105+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-DeepseekV3ForCausalLM
106+
~/.cache/huggingface/hub/models--katuni4ka--tiny-random-deepseek-v3
107+
~/.cache/huggingface/hub/models--trl-internal-testing--tiny-MistralForCausalLM-0.2
108+
~/.cache/huggingface/hub/models--trl-internal-testing--tiny-BloomForCausalLM
109+
~/.cache/huggingface/hub/models--trl-internal-testing--tiny-Qwen2ForCausalLM-2.5
110+
key: ${{ runner.os }}-huggingface-models-v6
98111
restore-keys: |
112+
${{ runner.os }}-huggingface-models-v5
113+
${{ runner.os }}-huggingface-models-v4
99114
${{ runner.os }}-huggingface-models-v2
100115
${{ runner.os }}-huggingface-models-v1
101116
${{ runner.os }}-huggingface-models
@@ -137,7 +152,22 @@ jobs:
137152
with:
138153
path: |
139154
~/.cache/huggingface/hub/models--roneneldan--TinyStories-1M*
140-
key: ${{ runner.os }}-huggingface-models-mps-v1
155+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-gpt2
156+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-Olmo2ForCausalLM
157+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-MptForCausalLM
158+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-Gemma2ForCausalLM
159+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-Gemma3ForCausalLM
160+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-GraniteForCausalLM
161+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-OlmoForCausalLM
162+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-OlmoeForCausalLM
163+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-DeepseekV3ForCausalLM
164+
~/.cache/huggingface/hub/models--katuni4ka--tiny-random-deepseek-v3
165+
~/.cache/huggingface/hub/models--trl-internal-testing--tiny-MistralForCausalLM-0.2
166+
~/.cache/huggingface/hub/models--trl-internal-testing--tiny-BloomForCausalLM
167+
~/.cache/huggingface/hub/models--trl-internal-testing--tiny-Qwen2ForCausalLM-2.5
168+
key: ${{ runner.os }}-huggingface-models-mps-v2
169+
restore-keys: |
170+
${{ runner.os }}-huggingface-models-mps-v1
141171
- name: Install dependencies
142172
run: |
143173
uv lock --check
@@ -286,8 +316,23 @@ jobs:
286316
~/.cache/huggingface/hub/models--ArthurConmy--redwood_attn_2l
287317
~/.cache/huggingface/hub/models--stas--tiny-random-llama-2
288318
~/.cache/huggingface/hub/models--microsoft--DialoGPT-medium
289-
key: ${{ runner.os }}-huggingface-models-v4
319+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-gpt2
320+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-Olmo2ForCausalLM
321+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-MptForCausalLM
322+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-Gemma2ForCausalLM
323+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-Gemma3ForCausalLM
324+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-GraniteForCausalLM
325+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-OlmoForCausalLM
326+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-OlmoeForCausalLM
327+
~/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-DeepseekV3ForCausalLM
328+
~/.cache/huggingface/hub/models--katuni4ka--tiny-random-deepseek-v3
329+
~/.cache/huggingface/hub/models--trl-internal-testing--tiny-MistralForCausalLM-0.2
330+
~/.cache/huggingface/hub/models--trl-internal-testing--tiny-BloomForCausalLM
331+
~/.cache/huggingface/hub/models--trl-internal-testing--tiny-Qwen2ForCausalLM-2.5
332+
key: ${{ runner.os }}-huggingface-models-v6
290333
restore-keys: |
334+
${{ runner.os }}-huggingface-models-v5
335+
${{ runner.os }}-huggingface-models-v4
291336
${{ runner.os }}-huggingface-models-v2
292337
${{ runner.os }}-huggingface-models-v1
293338
${{ runner.os }}-huggingface-models
@@ -336,11 +381,10 @@ jobs:
336381
with:
337382
path: |
338383
~/.cache/huggingface/hub/models--gpt2
339-
key: ${{ runner.os }}-huggingface-models-v4
340-
restore-keys: |
341-
${{ runner.os }}-huggingface-models-v2
342-
${{ runner.os }}-huggingface-models-v1
343-
${{ runner.os }}-huggingface-models
384+
# Distinct key: this job needs only gpt2, and sharing the full-suite
385+
# key let whichever job saved first pin the cache contents for all
386+
# three (a one-path save then serves the suites that need the rest).
387+
key: ${{ runner.os }}-huggingface-models-benchmark-v1
344388
- name: Install dependencies
345389
run: |
346390
uv lock --check

docs/source/content/adapter_development/adapter-specification.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,32 @@ Standard positional embeddings + LayerNorm + standard MLP + combined QKV. Used b
276276

277277
Similar to Llama-like but with `MoEBridge` replacing the MLP. Used by: Mixtral, GraniteMoE, OLMoE.
278278

279+
**Interleaved / dense-prefix stacks.** When the config can make some layers a plain
280+
gated MLP under the same attribute name (`first_k_dense_replace`,
281+
`mlp_only_layers`/`decoder_sparse_step`, `moe_layer_start_index`,
282+
`num_dense_layers`, `mlp_layer_types`), also declare the dense projections as
283+
optional submodules named `dense_gate` / `dense_in` / `dense_out`:
284+
285+
```python
286+
"mlp": MoEBridge(
287+
name="mlp",
288+
config=self.cfg,
289+
submodules={
290+
"gate": MoERouterBridge(name="gate", optional=True),
291+
"dense_gate": LinearBridge(name="gate_proj", optional=True),
292+
"dense_in": LinearBridge(name="up_proj", optional=True),
293+
"dense_out": LinearBridge(name="down_proj", optional=True),
294+
},
295+
),
296+
```
297+
298+
`MoEBridge` detects that binding per layer and gives the dense layers the
299+
gated-MLP hook set (`hook_pre`, `hook_pre_linear`, `hook_post`) and weight
300+
accessors (`W_gate`/`W_in`/`W_out`), while sparse layers keep MoE semantics.
301+
Without the declaration those layers report `d_model` boundary tensors under
302+
neuron-hook names (#1645). The keys are `dense_*` rather than `gate`/`in`/`out`
303+
because `gate` already means the *router* on the sparse layers of the same model.
304+
279305
### Pattern 4: Multimodal
280306

281307
Extends a text-only pattern with vision encoder and projection bridges. Used by: LLaVA, LLaVA-Next, Gemma3 Multimodal.

docs/source/content/compatibility_mode.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ After `enable_compatibility_mode()`, these HT hook names fire on the **pre-norm
6464
- **Post-norm architectures** (OLMo 2, BERT-style) read the **post-attention residual** instead, because the norm semantically lives elsewhere in the block.
6565
- **MLA blocks** (DeepSeek V2 / V3 / R1) do **not** expose the split-qkv aliases — MLA's compressed K/V doesn't have a clean split.
6666

67+
On post-norm architectures (Gemma 2/3's `ln1_post`/`ln2_post`, OLMo 2/3's `ln1`/`ln2`),
68+
`blocks.{i}.hook_attn_out` / `hook_mlp_out` fire **after** the post-sublayer norm, so
69+
that they capture the tensor added to the residual stream and the identities
70+
`resid_pre + attn_out == resid_mid` and `resid_mid + mlp_out == resid_post` hold
71+
([issue #1648](https://github.com/TransformerLensOrg/TransformerLens/issues/1648)).
72+
One consequence for head-level direct logit attribution: per-head contributions from
73+
`attn.hook_result` no longer sum to `hook_attn_out`, because the norm sits between
74+
them. This is inherent to post-norm — decompose heads on the pre-norm side
75+
(`attn.hook_out` for the raw module output) or attribute through the norm explicitly.
76+
The Granite family has the same raw-vs-contribution split for a different reason:
77+
HF scales each sublayer output by `residual_multiplier` before the residual add, so
78+
`hook_attn_out` / `hook_mlp_out` fire on the scaled contribution while
79+
`attn.hook_out` / `mlp.hook_out` stay raw — `attn.hook_result` sums to the raw
80+
output, off from the contribution by the multiplier.
81+
6782
An adapter author for a new post-norm or MLA-style architecture must handle these carve-outs in `setup_hook_compatibility`. The Gemma1/Gemma2 adapters are exemplars of when **not** to override `setup_hook_compatibility``GemmaTextScaledWordEmbedding` already scales internally, so any added `hook_conversion` would double-scale `embed.hook_out`.
6883

6984
## The four-quadrant test matrix

docs/source/content/jacobian_lens_fitting.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,94 @@ To propose a short-name entry in TransformerLens, open a pull request that adds
233233
published file to `transformer_lens/tools/analysis/jacobian_lens_registry.json` and
234234
include the fitting provenance and validation results.
235235

236+
## Sparse decomposition (J-space coordinates)
237+
238+
A fitted lens also decomposes an activation into the concepts it is *disposed to say*.
239+
`JacobianLens.decompose` writes an activation `x` at layer ℓ as a sparse **nonnegative**
240+
combination of J-lens vectors `v_t = J_ℓ^T W_U[:, t]` (one direction per vocabulary token),
241+
selected greedily. `k` is an **upper bound**, not a target: selection stops early once no
242+
unselected vector is materially positively correlated with the residual (under nonnegativity a
243+
negatively-correlated vector cannot reduce it), so fewer than `k` vectors may be selected and
244+
fewer still may be numerically active.
245+
246+
```python
247+
from transformer_lens.model_bridge import TransformerBridge
248+
from transformer_lens.tools.analysis import JacobianLens
249+
250+
model = TransformerBridge.boot_transformers("gpt2", device="cpu")
251+
lens = JacobianLens.from_pretrained("gpt2-small", model=model)
252+
253+
# decompose the activation at a prompt position ...
254+
result = lens.decompose(model, "The Eiffel Tower is in the city of", layer=6, position=-1, k=8)
255+
# ... or a raw [d_model] activation you already have (leave position=None):
256+
# result = lens.decompose(model, activation, layer=6, k=8)
257+
258+
tokens = [model.to_string(int(t)) for t in result.support] # the (up to k) *active* J-lens vectors
259+
coordinates = result.coordinates # their nonnegative coefficients
260+
```
261+
262+
The result exposes **two supports**, because the paper uses two inconsistent operationalizations
263+
(a main-text sparse nonnegative reconstruction and an appendix projection onto a selected span):
264+
265+
- `support` -- the numerically **active** vectors: the selected vectors whose contribution
266+
`coordinates[i] * ||v_t||` is a materially nonzero fraction of `||x||`. `coordinates` is aligned
267+
with `support`, and `reconstruction = sum(coordinates * v_t)` over `support`.
268+
- `selected_support` -- **every** greedily selected vector, including any whose coordinate the
269+
nonnegativity constraint drove to zero. It defines the span for `j_space_component`. Hence
270+
`len(support) <= len(selected_support) <= k`.
271+
272+
So two vector outputs also need not coincide:
273+
274+
- `reconstruction` -- the nonnegative combination over the active `support`.
275+
- `j_space_component` (the *J-space component*) -- the orthogonal projection of the activation onto
276+
the span of `selected_support` -- with `non_j_space_component = x - j_space_component`, the
277+
residual the interventions leave unchanged.
278+
279+
For the default exact NNLS re-solve the `reconstruction` equals the projection onto the *active*
280+
support (KKT stationarity), so it differs from `j_space_component` exactly when a selected vector
281+
has a zero coordinate (the projection then uses a strictly larger span).
282+
283+
```
284+
x in R^d_model
285+
|-- decompose(x, layer, k)
286+
|-- support / coordinates a_t >= 0 (active vectors; reconstruction = sum a_t v_t)
287+
|-- selected_support S (all selected vectors; defines the span below)
288+
|-- j_space_component Pi_S x (orthogonal projection onto span of selected v_t)
289+
\-- non_j_space_component x - Pi_S x (orthogonal to the selected vectors)
290+
```
291+
292+
Two algorithms are available via `algorithm=`. The default,
293+
`"nonnegative_orthogonal_matching_pursuit"`, solves a nonnegative least-squares (NNLS) problem
294+
over the selected atoms in float64 after each step. It checks the result against the KKT
295+
conditions and raises `RuntimeError` if the check fails. `"gradient_pursuit"` skips that solve
296+
and uses the directional update from Blumensath & Davies (2008), matching the update used in
297+
the paper; its projected step is accepted only when it does not increase the residual. The two
298+
algorithms share the same greedy selection *rule* but, because their coefficient residuals
299+
differ, may select different vectors at later steps and so return a different `support` and
300+
`reconstruction`.
301+
302+
### Interpreting the numbers honestly
303+
304+
The quantitative findings below are from Gurnee et al. (2026) and were measured on **closed
305+
Anthropic models** (Sonnet / Haiku / Opus); on open-weight models the *shape* may hold but the
306+
exact values will not necessarily transfer.
307+
308+
- The decomposition is **not** a top-k logit-lens readout: because the J-lens vectors are
309+
overcomplete and non-orthogonal, it gives "a different (and typically less redundant) set of
310+
active concepts than simply taking the top-k by inner product."
311+
- The J-space is a **small fraction** of the activation: the paper's span projection (the
312+
`selected_support` operationalization here) "never [exceeds] more than 10%" of total activation
313+
variance, and for concept vectors carries "a median of only 6-7% ... the remaining ~93% lying
314+
outside the J-space." Those figures are the paper's own measurements on its models; do not read
315+
them off this implementation's `j_space_component` without matching the operationalization.
316+
- `k` defaults to 25 because the paper "typically choose[s] it to be no more than 25, which we
317+
empirically observed to be the number of J-lens vectors that are meaningfully active at a given
318+
time." Here `k` is an **upper bound**: `support` returns *at most* `k` active vectors (often
319+
fewer), never `k` padded with zero-coefficient slots.
320+
321+
The full-vocabulary dictionary is cached on the model's device and is vocabulary-sized
322+
(gigabytes for large models); release it with `lens.clear_device_cache()`.
323+
236324
## Importing an existing lens
237325

238326
`JacobianLens.load()` accepts two file schemas: the standard artifact format (four

0 commit comments

Comments
 (0)