Commit cc2c54e
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
- tests
- integration
- unit/tools
- transformer_lens/tools/analysis
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
236 | 324 | | |
237 | 325 | | |
238 | 326 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
426 | 426 | | |
427 | 427 | | |
428 | 428 | | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
21 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
22 | 26 | | |
23 | 27 | | |
24 | 28 | | |
| |||
1278 | 1282 | | |
1279 | 1283 | | |
1280 | 1284 | | |
| 1285 | + | |
| 1286 | + | |
| 1287 | + | |
| 1288 | + | |
| 1289 | + | |
| 1290 | + | |
| 1291 | + | |
| 1292 | + | |
| 1293 | + | |
| 1294 | + | |
| 1295 | + | |
| 1296 | + | |
| 1297 | + | |
| 1298 | + | |
| 1299 | + | |
| 1300 | + | |
| 1301 | + | |
| 1302 | + | |
| 1303 | + | |
| 1304 | + | |
| 1305 | + | |
| 1306 | + | |
| 1307 | + | |
| 1308 | + | |
| 1309 | + | |
| 1310 | + | |
| 1311 | + | |
| 1312 | + | |
| 1313 | + | |
| 1314 | + | |
| 1315 | + | |
| 1316 | + | |
| 1317 | + | |
| 1318 | + | |
| 1319 | + | |
| 1320 | + | |
| 1321 | + | |
| 1322 | + | |
| 1323 | + | |
| 1324 | + | |
| 1325 | + | |
| 1326 | + | |
| 1327 | + | |
| 1328 | + | |
| 1329 | + | |
| 1330 | + | |
| 1331 | + | |
| 1332 | + | |
| 1333 | + | |
| 1334 | + | |
| 1335 | + | |
| 1336 | + | |
| 1337 | + | |
| 1338 | + | |
| 1339 | + | |
| 1340 | + | |
| 1341 | + | |
| 1342 | + | |
| 1343 | + | |
| 1344 | + | |
| 1345 | + | |
| 1346 | + | |
| 1347 | + | |
| 1348 | + | |
| 1349 | + | |
| 1350 | + | |
| 1351 | + | |
| 1352 | + | |
| 1353 | + | |
| 1354 | + | |
| 1355 | + | |
| 1356 | + | |
| 1357 | + | |
| 1358 | + | |
| 1359 | + | |
| 1360 | + | |
| 1361 | + | |
| 1362 | + | |
| 1363 | + | |
| 1364 | + | |
| 1365 | + | |
| 1366 | + | |
| 1367 | + | |
| 1368 | + | |
| 1369 | + | |
| 1370 | + | |
| 1371 | + | |
| 1372 | + | |
| 1373 | + | |
| 1374 | + | |
| 1375 | + | |
| 1376 | + | |
| 1377 | + | |
| 1378 | + | |
| 1379 | + | |
| 1380 | + | |
| 1381 | + | |
| 1382 | + | |
| 1383 | + | |
| 1384 | + | |
| 1385 | + | |
| 1386 | + | |
| 1387 | + | |
| 1388 | + | |
| 1389 | + | |
| 1390 | + | |
| 1391 | + | |
| 1392 | + | |
| 1393 | + | |
0 commit comments