Skip to content

Commit cc2c54e

Browse files
feat(jacobian_lens): J-space sparse decomposition (#1596)
* feat(jacobian_lens): add J-space sparse-decomposition solver - Add `get_sparse_decomposition` to decompose an activation into a k-sparse nonnegative combination of J-lens vectors (Gurnee et al., 2026). - Support `nonnegative_orthogonal_matching_pursuit` (default, exact NNLS re-solve) and `gradient_pursuit` algorithms. - Return both the nonnegative coordinates and the orthogonal-projection J-space component. - Distinguish the projection from the coefficient reconstruction; the projection residual matches `swap_hooks`. - Keep the implementation model-free by operating directly on the raw dictionary tensor. - Add tests covering both algorithms, exact-resolve NNLS correctness, a brute-force optimum oracle, and input validation. Part of #1539 (Tier 2). * feat(jacobian_lens): add full-vocabulary lens-vector dictionary - Add `JacobianLens.lens_vector_dictionary(model, layer)` returning the `[d_vocab, d_model]` dictionary whose rows are the J-lens vectors `v_t = J[layer]^T W_U[:, t]`. - Cache the dictionary per (layer, device) and release it in `clear_device_cache`, so a sparse decomposition can reuse it; document its vocabulary-sized memory cost. - Add tests asserting the dictionary matches `lens_vectors` over every token, is cached and invalidated by `clear_device_cache`, and rejects an unfitted layer. * feat(jacobian_lens): add JacobianLens.decompose wrapper and exports - Add `JacobianLens.decompose(model, activation_or_prompt, layer, *, position, k, algorithm)` decomposing either a raw activation vector or the `blocks.{layer}.hook_out` activation at a prompt position, validating inputs before building the dictionary. - Build and cache the layer dictionary via `lens_vector_dictionary` and solve with `get_sparse_decomposition`. - Export `JSpaceDecomposition` and `get_sparse_decomposition` from `transformer_lens.tools.analysis`. - Add end-to-end tests for the raw-activation and prompt paths, the algorithm passthrough, and the input-validation error paths. * test(jacobian_lens): add real-model decompose tests and docs - Add a GPT-2 integration test (regular CI): `decompose` on a real `blocks.6.hook_out` activation returns k nonnegative atoms, the non-J-space residual is orthogonal to every selected J-lens vector, and the J-space component plus residual recover the activation. - Add a slow gemma-2-2b-it integration test validating `decompose` on the published lens artifact: support size, nonnegative coordinates, in-vocabulary token ids, and component-plus-residual reconstruction. - Document J-space sparse decomposition in `jacobian_lens_fitting.md`: the `decompose` API, local coordinates versus the orthogonal-projection J-space component, and the paper's variance facts with closed-model caveats. * docs(jacobian_lens): cite the decomposition algorithm sources Add a References section to the decomposition module docstring: Gurnee et al. (2026) for the J-space method, Pati et al. (1993) for the greedy orthogonal-matching-pursuit selection, Blumensath & Davies (2008) for the gradient-pursuit update, and Lawson & Hanson (1974) for the active-set nonnegative least-squares re-solve. * fix(jacobian_lens): complete and independently validate NNLS Resolve PR #1596 review comments 1 and 3 as one numerical-correctness unit: the drop-only active-set approximation could strand an atom that is optimal later, returning a non-KKT point (the reviewer measured 4/288 GPT-2 decompositions off, relative dual violation up to 0.55). Solver (`_nonnegative_least_squares`): - Replace the drop-only loop with the full Lawson-Hanson active-set method, so a released atom can re-enter. Solve the passive set in float64 with an explicit pseudoinverse rank threshold. - Fail closed: the feasibility corrections use the classical `3 * num_active` budget with a separate admission safeguard; an exhausted budget, an invalid line-search step, a stalled correction, or a failed KKT check raises `RuntimeError` rather than clamping and returning an unverified vector. - Enforce the passive-set invariant and guard the blocking-ratio step against zero denominators and zero-current/zero-candidate degeneracy. - Use one scale- and dtype-aware tolerance policy (`_nnls_tolerances`) for dual feasibility, coefficient cleanup, and the KKT test. - Validate the KKT conditions (`_validate_nnls_kkt`) before returning, in both the float64 work dtype and the caller's result dtype. Independent validation (tests): - Add `_reference_nnls`, a brute-force support-enumeration NNLS that shares no code with the solver, and compare objectives *two-sidedly* over many shapes. - Add an independent `_assert_nnls_kkt` certificate (primal/dual feasibility, stationarity, complementarity) with a deliberately looser, scale-aware tolerance, used on rank-deficient, duplicate-column, near-collinear, boundary, zero-target, and jointly-rescaled systems. - Add a fail-closed test proving the safeguard raises instead of returning an unverified vector, plus dtype/device, zero-row-invariance, and realistic width (768x25) checks. Document the new public contract: `decompose` now raises `RuntimeError` on a KKT-uncertifiable solve, and the fitting docs describe the float64 re-solve and KKT check. Verification: - tests/unit/tools/test_jacobian_lens_decomposition.py: 127 passed. - KKT reproduction over 935 real GPT-2 decompositions on the full branch (commits 6-7; the NNLS solver added here is unchanged by commit 7): 0 KKT failures, max relative dual violation 2.77e-08 (old drop-only solver: up to 0.55 on 4/288). - black/isort/pycln clean; mypy clean on the touched source. * fix(jacobian_lens): distinguish active and selected support Resolve PR #1596 review comment 2: the greedy loop ran exactly `k` times and kept every selected atom in `support`, so a coordinate the NNLS solve drove to zero still consumed a support slot and was never reconsidered. On GPT-2 this returned `support` of size 25 with only ~9 nonzero coordinates ("dead slots"), and the docs presented all 25 as active concepts. Make `k` an upper bound and separate the two supports the paper conflates: - Early stopping: selection stops once no unselected atom is materially positively correlated with the residual (under nonnegativity a negatively-correlated atom cannot reduce it). The stop threshold sits at the float32 residual noise floor, so a full-rank target stops instead of selecting noise atoms -- this makes the selected support scale-invariant. - `support` is now the numerically *active* set: selected atoms whose contribution `c_i * ||v_i||` is a materially nonzero fraction of `||x||` (a scale-invariant activity threshold matching the NNLS coefficient-zeroing scale, so `support` equals the strictly-positive NNLS coordinates). `coordinates` is aligned with it and every entry is strictly positive. - New `selected_support` holds every greedily selected atom and defines the span for `j_space_component`. Hence `support <= selected_support <= k`. - `reconstruction` is the nonnegative combination over the active support; for the exact NNLS re-solve it equals the projection onto that support (KKT stationarity), so it differs from `j_space_component` exactly when a selected atom has a zero coordinate. Empty sets (zero target, or a target orthogonal to every atom) are handled explicitly. - `gradient_pursuit`: its projected line-search step uses a backtracking line search -- the exact unconstrained step is projected onto the nonnegative orthant, then halved until the projected update no longer increases the residual (falling back to the feasible incoming point if the bounded search finds none), so the objective is monotonically non-increasing. Its `support` is filtered to the final active coordinates like the default algorithm. - Autograd: the scalars used only for control flow (the target norm, the early-stop correlation gate, and the gradient-pursuit residual comparison) are detached, so the model-free primitive never pulls caller-owned tensors out of the graph; the autograd-contract test backpropagates through the reconstruction and asserts finite gradients on both inputs. - Keep `support`/token tensors on CPU and vector outputs on the compute device. Propagate the contract through the wrapper, tests, and docs: `decompose` and `jacobian_lens_fitting.md` document `k` as an upper bound and the active vs selected/projection distinction, naming which operationalization the paper's variance figures measure. Integration and unit assertions check `support <= selected_support <= k`, subset, and all-active instead of `support == k`. Verification (conda env, python 3.12, H100): - tests/unit/tools/test_jacobian_lens_decomposition.py + test_jacobian_lens.py: 219 passed. - tests/integration/test_jacobian_lens.py (GPT-2): 13 passed. - mypy clean on the touched source. The only `mypy .` error is pre-existing and unrelated (olmo_hybrid.py, from transformers 5.15.0 vs the locked 5.13.0). - black/isort/pycln clean on the touched files. - KKT reproduction over 935 real GPT-2 decompositions (3 prompts x 11 source layers x all positions, k=25): 0 dead slots (support == strictly-positive coordinates), 322/935 with selected > active, 0 KKT failures, max relative dual violation 2.77e-08 (old drop-only solver: up to 0.55). - Scale-invariance of support verified across many seeds; CUDA device-safety checked. --------- Co-authored-by: Jonah Larson <jonahalarson@comcast.net>
1 parent 13a28dc commit cc2c54e

7 files changed

Lines changed: 1613 additions & 3 deletions

File tree

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

tests/integration/test_jacobian_lens.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,3 +426,70 @@ def assert_valid_topk(
426426
)
427427
torch.testing.assert_close(result.model_logits[0], expected_final_logits, atol=1e-5, rtol=1e-5)
428428
torch.testing.assert_close(result.lens_logits[final_layer], result.model_logits)
429+
430+
431+
def test_decompose_gpt2_activation_reconstructs_and_is_orthogonal(published_gpt2_lens, gpt2_bridge):
432+
"""decompose on a real GPT-2 activation: up to k active nonnegative atoms, the non-J-space
433+
residual is orthogonal to the selected J-lens vectors, and component + residual recover the
434+
activation."""
435+
layer, k = 6, 8
436+
result = published_gpt2_lens.decompose(gpt2_bridge, PROMPT, layer=layer, position=-1, k=k)
437+
438+
# ``k`` is an upper bound: ``support`` holds only numerically active atoms, a subset of the
439+
# selected set. Do not assert a closed-model active count -- just record it on failure.
440+
assert (
441+
result.support.numel() <= result.selected_support.numel() <= k
442+
), f"active={result.support.numel()} selected={result.selected_support.numel()} k={k}"
443+
assert set(result.support.tolist()).issubset(set(result.selected_support.tolist()))
444+
assert result.coordinates.numel() == result.support.numel()
445+
assert (result.coordinates > 0).all() # every returned coordinate is active
446+
assert (result.support >= 0).all() and (result.support < gpt2_bridge.cfg.d_vocab).all()
447+
448+
# the decomposed activation is the model's blocks.{layer}.hook_out at the last position
449+
tokens = gpt2_bridge.to_tokens(PROMPT)
450+
hook = f"blocks.{layer}.hook_out"
451+
_, cache = gpt2_bridge.run_with_cache(tokens, names_filter=lambda name: name == hook)
452+
activation = cache[hook][0, -1, :].float()
453+
assert torch.allclose(
454+
result.j_space_component + result.non_j_space_component, activation, atol=1e-3
455+
)
456+
457+
# the non-J-space residual is orthogonal to every selected J-lens vector (the projection
458+
# span is the whole selected support, not only the active atoms); cosine ~ 0
459+
dictionary = published_gpt2_lens.lens_vector_dictionary(gpt2_bridge, layer)
460+
residual = result.non_j_space_component
461+
for atom_id in result.selected_support.tolist():
462+
atom = dictionary[atom_id]
463+
cosine = torch.dot(residual, atom) / (residual.norm() * atom.norm())
464+
assert cosine.abs().item() < 1e-3
465+
466+
467+
@pytest.mark.slow
468+
def test_decompose_gemma_activation_is_valid():
469+
"""Decompose a real gemma-2-2b-it activation via its published lens (slow: real download)."""
470+
from transformer_lens.model_bridge import TransformerBridge
471+
from transformer_lens.tools.analysis import JacobianLens
472+
473+
device = "cuda" if torch.cuda.is_available() else "cpu"
474+
model = TransformerBridge.boot_transformers(GEMMA_MODEL, dtype=torch.bfloat16, device=device)
475+
lens = JacobianLens.from_pretrained(
476+
LENS_REPO, filename=GEMMA_LENS_FILE, revision=LENS_REVISION, model=model
477+
)
478+
layer = lens.source_layers[len(lens.source_layers) // 2]
479+
k = 16
480+
result = lens.decompose(model, PROMPT, layer=layer, position=-1, k=k)
481+
482+
assert (
483+
result.support.numel() <= result.selected_support.numel() <= k
484+
), f"active={result.support.numel()} selected={result.selected_support.numel()} k={k}"
485+
assert set(result.support.tolist()).issubset(set(result.selected_support.tolist()))
486+
assert (result.coordinates > 0).all()
487+
assert (result.support >= 0).all() and (result.support < model.cfg.d_vocab).all()
488+
489+
tokens = model.to_tokens(PROMPT)
490+
hook = f"blocks.{layer}.hook_out"
491+
_, cache = model.run_with_cache(tokens, names_filter=lambda name: name == hook)
492+
activation = cache[hook][0, -1, :].float()
493+
assert torch.allclose(
494+
result.j_space_component + result.non_j_space_component, activation, atol=1e-2
495+
)

tests/unit/tools/test_jacobian_lens.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
from transformer_lens.model_bridge.supported_architectures.deepseek_v4 import (
1919
DeepseekV4BlockBridge,
2020
)
21-
from transformer_lens.tools.analysis import JacobianLens
21+
from transformer_lens.tools.analysis import (
22+
JacobianLens,
23+
JSpaceDecomposition,
24+
get_sparse_decomposition,
25+
)
2226
from transformer_lens.utilities.activation_functions import apply_softcap
2327

2428
D_MODEL = 6
@@ -1278,3 +1282,112 @@ def fake_retry(function: Any, **kwargs: Any) -> str:
12781282

12791283
assert len(set(filenames)) == 1, "All three aliases should resolve to the same filename"
12801284
assert filenames[0].endswith("gpt2_jacobian_lens.pt")
1285+
1286+
1287+
def test_lens_vector_dictionary_matches_lens_vectors_and_caches(toy_model: _ToyBridge) -> None:
1288+
"""The full-vocabulary dictionary equals lens_vectors over every token, is cached per
1289+
(layer, device), and is released by clear_device_cache."""
1290+
torch.manual_seed(0)
1291+
d_model = toy_model.cfg.d_model
1292+
layer = 1
1293+
lens = JacobianLens({layer: torch.randn(d_model, d_model)}, n_prompts=1, d_model=d_model)
1294+
d_vocab = toy_model.W_U.shape[1]
1295+
1296+
dictionary = lens.lens_vector_dictionary(toy_model, layer)
1297+
assert dictionary.shape == (d_vocab, d_model)
1298+
1299+
all_vectors = lens.lens_vectors(toy_model, list(range(d_vocab)), layer)
1300+
assert torch.allclose(dictionary, all_vectors, atol=1e-5)
1301+
1302+
# cached: the same object is returned on a repeat call, and clearing releases it
1303+
assert lens.lens_vector_dictionary(toy_model, layer) is dictionary
1304+
lens.clear_device_cache()
1305+
assert lens.lens_vector_dictionary(toy_model, layer) is not dictionary
1306+
1307+
1308+
def test_lens_vector_dictionary_rejects_unfitted_layer(toy_model: _ToyBridge) -> None:
1309+
"""Requesting a layer the lens was not fitted at raises (delegated to _matrix_on)."""
1310+
d_model = toy_model.cfg.d_model
1311+
lens = JacobianLens({1: torch.randn(d_model, d_model)}, n_prompts=1, d_model=d_model)
1312+
with pytest.raises(ValueError):
1313+
lens.lens_vector_dictionary(toy_model, 0) # layer 0 was not fitted
1314+
1315+
1316+
def test_decompose_raw_activation_returns_jspace_decomposition(
1317+
toy_model: _ToyBridge, fitted_lens: JacobianLens
1318+
) -> None:
1319+
"""decompose on a raw activation vector runs the solver against the layer's dictionary."""
1320+
torch.manual_seed(0)
1321+
activation = torch.randn(toy_model.cfg.d_model)
1322+
result = fitted_lens.decompose(toy_model, activation, layer=0, k=3)
1323+
assert isinstance(result, JSpaceDecomposition)
1324+
# ``k`` is an upper bound: only active atoms are returned, as a subset of the selected set.
1325+
assert result.support.numel() <= result.selected_support.numel() <= 3
1326+
assert set(result.support.tolist()).issubset(set(result.selected_support.tolist()))
1327+
assert result.coordinates.numel() == result.support.numel()
1328+
assert (result.coordinates > 0).all()
1329+
assert result.j_space_component.shape == (toy_model.cfg.d_model,)
1330+
1331+
1332+
def test_decompose_prompt_matches_manual_activation(
1333+
toy_model: _ToyBridge, fitted_lens: JacobianLens
1334+
) -> None:
1335+
"""decompose(prompt, position) decomposes the blocks.{layer}.hook_out activation at that
1336+
position -- identical to fetching it manually and decomposing directly."""
1337+
layer, position, k = 0, -1, 3
1338+
result = fitted_lens.decompose(toy_model, "a toy prompt", layer=layer, position=position, k=k)
1339+
1340+
tokens = toy_model.to_tokens("a toy prompt")
1341+
hook = f"blocks.{layer}.hook_out"
1342+
_, cache = toy_model.run_with_cache(tokens, names_filter=lambda name: name == hook)
1343+
activation = cache[hook][0, position, :]
1344+
dictionary = fitted_lens.lens_vector_dictionary(toy_model, layer)
1345+
expected = get_sparse_decomposition(activation.float(), dictionary, k)
1346+
1347+
assert torch.equal(result.support, expected.support)
1348+
assert torch.equal(result.selected_support, expected.selected_support)
1349+
assert torch.allclose(result.coordinates, expected.coordinates, atol=1e-5)
1350+
1351+
1352+
def test_decompose_rejects_bad_inputs(toy_model: _ToyBridge, fitted_lens: JacobianLens) -> None:
1353+
d_model = toy_model.cfg.d_model
1354+
# a string with no position is neither a raw activation nor a positioned prompt
1355+
with pytest.raises(ValueError):
1356+
fitted_lens.decompose(toy_model, "a toy prompt", layer=0, k=3)
1357+
# raw activation of the wrong width
1358+
with pytest.raises(ValueError):
1359+
fitted_lens.decompose(toy_model, torch.randn(d_model + 1), layer=0, k=3)
1360+
# a batched prompt
1361+
with pytest.raises(ValueError):
1362+
fitted_lens.decompose(
1363+
toy_model, torch.zeros(2, 3, dtype=torch.long), layer=0, position=1, k=3
1364+
)
1365+
# a raw activation paired with a position is ambiguous
1366+
with pytest.raises(ValueError):
1367+
fitted_lens.decompose(toy_model, torch.randn(d_model), layer=0, position=0, k=3)
1368+
1369+
1370+
def test_decompose_passes_algorithm_through(
1371+
toy_model: _ToyBridge, fitted_lens: JacobianLens
1372+
) -> None:
1373+
"""The wrapper forwards ``algorithm`` to the solver."""
1374+
torch.manual_seed(0)
1375+
activation = torch.randn(toy_model.cfg.d_model)
1376+
result = fitted_lens.decompose(
1377+
toy_model, activation, layer=0, k=3, algorithm="gradient_pursuit"
1378+
)
1379+
dictionary = fitted_lens.lens_vector_dictionary(toy_model, 0)
1380+
expected = get_sparse_decomposition(
1381+
activation.float(), dictionary, 3, algorithm="gradient_pursuit"
1382+
)
1383+
assert torch.equal(result.support, expected.support)
1384+
assert torch.equal(result.selected_support, expected.selected_support)
1385+
assert torch.allclose(result.coordinates, expected.coordinates, atol=1e-5)
1386+
1387+
1388+
def test_decompose_rejects_unfitted_layer(toy_model: _ToyBridge, fitted_lens: JacobianLens) -> None:
1389+
"""Decomposing at a layer the lens was not fitted at raises (the final layer is never fit)."""
1390+
with pytest.raises(ValueError):
1391+
fitted_lens.decompose(
1392+
toy_model, torch.randn(toy_model.cfg.d_model), layer=N_LAYERS - 1, k=3
1393+
)

0 commit comments

Comments
 (0)