Coverage for transformer_lens/tools/analysis/jacobian_lens.py: 96%

676 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-10-23 20:45 +0000

1"""Jacobian Lens (J-lens). 

2 

3The Jacobian lens characterizes an intermediate residual-stream activation by its 

4first-order causal effect on the model's output, averaged over a corpus of contexts. 

5For each layer :math:`\\ell` it fits a single :math:`d_{model} \\times d_{model}` matrix 

6 

7.. math:: 

8 

9 J_\\ell = \\mathbb{E}_{\\text{prompt}}\\left[ 

10 \\frac{1}{|V|}\\sum_{t \\in V}\\sum_{t' \\in V,\\,t' \\geq t} 

11 \\frac{\\partial h_{\\text{final},t'}}{\\partial h_{\\ell,t}} 

12 \\right] 

13 

14where ``V`` is the set of valid positions after the configured leading skip 

15and final-position exclusion. Target-position effects are summed for each 

16source; only source positions and prompts are averaged. 

17 

18mapping the output of block :math:`\\ell` to the final block's output (pre final 

19norm). Reading the lens applies the model's own final norm and unembedding: 

20:math:`\\text{lens}(h_\\ell) = W_U\\,\\mathrm{norm}(J_\\ell h_\\ell)`. 

21The logit lens is the special case :math:`J_\\ell = I`. The rows of 

22:math:`W_U J_\\ell` ("J-lens vectors") are residual-stream directions associated 

23with single vocabulary tokens, and support causal interventions: steering, 

24ablation, and exchanging one concept for another via a pseudoinverse coordinate 

25swap. 

26 

27Introduced in `Verbalizable Representations Form a Global Workspace in Language 

28Models <https://transformer-circuits.pub/2026/workspace/index.html>`_ (Gurnee et 

29al., Transformer Circuits Thread, 2026). The fitting estimator and the artifact 

30format follow Anthropic's Apache-2.0 reference implementation 

31(`anthropics/jacobian-lens <https://github.com/anthropics/jacobian-lens>`_), so 

32lenses fitted here interoperate with artifacts published on the Hugging Face Hub 

33(e.g. `neuronpedia/jacobian-lens 

34<https://huggingface.co/neuronpedia/jacobian-lens>`_); the interventions are 

35implemented from the paper's Methods section. 

36 

37Warning: 

38 Published lens artifacts are fitted on **raw** HuggingFace activations. 

39 Jacobian lens supports only a freshly booted 

40 ``TransformerBridge.boot_transformers`` model, whose weights are raw by 

41 default. Compatibility mode and direct ``process_weights`` calls change the 

42 residual basis and are refused rather than returning silently wrong 

43 readouts. The model must also be a causal decoder whose adapter supports 

44 text generation, use single-stream block outputs, and expose the standard 

45 direct ``ln_final -> d_model-width unembed`` output path. 

46 

47Example:: 

48 

49 import torch 

50 from transformer_lens.model_bridge import TransformerBridge 

51 from transformer_lens.tools.analysis import JacobianLens 

52 

53 model = TransformerBridge.boot_transformers("gpt2", device="cpu") 

54 lens = JacobianLens.from_pretrained( 

55 "neuronpedia/jacobian-lens", 

56 filename="gpt2-small/jlens/Salesforce-wikitext/gpt2_jacobian_lens.pt", 

57 model=model, 

58 ) 

59 result = lens.readout(model, "The Eiffel Tower is in the city of") 

60 print(result.top_tokens(model.tokenizer, k=5)[8][-1]) # layer 8, final position 

61""" 

62 

63import warnings 

64from dataclasses import dataclass 

65from importlib.metadata import version 

66from typing import ( 

67 Any, 

68 Callable, 

69 Dict, 

70 List, 

71 Mapping, 

72 MutableMapping, 

73 Optional, 

74 Sequence, 

75 Tuple, 

76 Type, 

77 TypeVar, 

78 Union, 

79) 

80 

81import torch 

82from jaxtyping import Float, Int 

83from tqdm.auto import tqdm 

84 

85from transformer_lens.ActivationCache import ActivationCache 

86from transformer_lens.tools.analysis._model_state import require_eval_mode, to_token_ids 

87from transformer_lens.tools.analysis.jacobian_lens_coordinate_patch import ( 

88 CoordinatePatch, 

89 solve_coordinate_patch, 

90 solve_coordinate_patch_positions, 

91) 

92from transformer_lens.tools.analysis.jacobian_lens_decomposition import ( 

93 DEFAULT_K, 

94 JSpaceDecomposition, 

95 JSpaceOccupancy, 

96 JSpaceVarianceProfile, 

97 _diagnose_intervention_pair, 

98 estimate_occupancy, 

99 get_sparse_decomposition, 

100) 

101from transformer_lens.tools.analysis.logit_lens import logit_readout 

102from transformer_lens.tools.analysis.projection_kernel import orthonormal_subspace 

103from transformer_lens.utilities.hf_utils import call_hf_with_retry 

104 

105ConceptToken = Union[str, int] 

106# Former name, kept so existing imports keep working. 

107TokenInput = ConceptToken 

108_LensT = TypeVar("_LensT", bound="JacobianLens") 

109 

110# Backward-provider seam for the fit drive loop. A provider takes the target 

111# residual, the source residuals it differentiates against, one batched one-hot 

112# cotangent, and the ``retain_graph`` flag, and returns one gradient per source 

113# (the same tuple ``torch.autograd.grad`` returns). Keeping the backward step 

114# behind this narrow callable lets the estimator-independent driver run an 

115# alternate estimator without touching its capture / cotangent-batching / 

116# averaging machinery; the ordinary J-lens path passes :func:`_ordinary_vjp`. 

117BackwardProvider = Callable[ 

118 [torch.Tensor, List[torch.Tensor], torch.Tensor, bool], 

119 Tuple[torch.Tensor, ...], 

120] 

121 

122# --------------------------------------------------------------------------- 

123# Registry helpers 

124# --------------------------------------------------------------------------- 

125 

126_REGISTRY_CACHE: Optional[Dict[str, Any]] = None 

127 

128 

129def _load_registry() -> Dict[str, Any]: 

130 """Return the bundled artifact registry, loading it once on first call.""" 

131 global _REGISTRY_CACHE 

132 if _REGISTRY_CACHE is None: 

133 import json 

134 import pathlib 

135 

136 registry_path = pathlib.Path(__file__).with_name("jacobian_lens_registry.json") 

137 with registry_path.open() as fh: 

138 _REGISTRY_CACHE = json.load(fh) 

139 return _REGISTRY_CACHE 

140 

141 

142def _resolve_registry_entry(name_or_path: str) -> Optional[Tuple[str, str]]: 

143 """Return ``(repo_id, filename)`` if *name_or_path* matches the registry. 

144 

145 Matching is tried in two passes: 

146 1. Direct key match against short model names (e.g. ``"gemma-2-2b"``). 

147 2. Alias match against Hugging Face model IDs (e.g. ``"google/gemma-2-2b"``). 

148 

149 Returns ``None`` when there is no match so callers can fall through to the 

150 generic Hub download path. 

151 """ 

152 registry = _load_registry() 

153 entry = registry.get(name_or_path) 

154 if isinstance(entry, dict): 

155 return entry["repo_id"], entry["filename"] 

156 for entry in registry.values(): 

157 if isinstance(entry, dict) and name_or_path in entry.get("aliases", []): 

158 return entry["repo_id"], entry["filename"] 

159 return None 

160 

161 

162def _registry_revision() -> str: 

163 """Hub commit that every registry entry is downloaded at unless a caller pins another.""" 

164 return _load_registry()["revision"] 

165 

166 

167# Fitting excludes early positions (attention sinks with atypical residual statistics) 

168# and the final position (no next-token target), matching the reference implementation. 

169DEFAULT_SKIP_FIRST_POSITIONS = 16 

170DEFAULT_TOP_K = 10 

171 

172# Recorded on every fitted lens; merge() refuses shards whose estimators differ. 

173ESTIMATOR_JACOBIAN = "jacobian_lens" 

174 

175# Keys written by fit() that must not appear in converted-lens metadata so that 

176# merge() can refuse to mix TL-fitted lenses with externally converted ones. 

177# Note: "target_layer" is intentionally NOT listed here — it must survive 

178# conversion so that validate_model() can detect and refuse checkpoints that 

179# were fitted against a non-final target layer. 

180_FIT_RESERVED_KEYS: frozenset = frozenset( 

181 { 

182 "transformer_lens_fit", 

183 "transformer_lens_version", 

184 "model_system", 

185 "processing", 

186 "hook_convention", 

187 "fit_dtype", 

188 "dim_batch", 

189 "max_seq_len", 

190 "skip_first_positions", 

191 } 

192) 

193 

194# Top-level payload keys that some checkpoint writers store as flat provenance 

195# (rather than nested under a "metadata" key). 

196_CHECKPOINT_FLAT_PROVENANCE: frozenset = frozenset({"model_name", "model_revision", "corpus"}) 

197 

198 

199@dataclass 

200class JacobianLensReadout: 

201 """Result of a :meth:`JacobianLens.readout` call. 

202 

203 Attributes: 

204 lens_topk_values: 

205 Per-layer retained top-k pre-softmax values, on CPU. 

206 lens_topk_indices: 

207 Per-layer retained top-k vocabulary ids, on CPU. 

208 model_topk_values: 

209 The model output's retained top-k pre-softmax values, on CPU. 

210 model_topk_indices: 

211 The model output's retained top-k vocabulary ids, on CPU. 

212 lens_logits: 

213 Optional full per-layer logits, on CPU. Present only when 

214 ``readout(return_full_logits=True)`` was requested. 

215 model_logits: 

216 Optional full model logits, on CPU. Present only when 

217 ``readout(return_full_logits=True)`` was requested. 

218 tokens: 

219 The token ids of the run prompt, ``[seq]``. 

220 positions: 

221 The (normalized, non-negative) positions the readout covers, aligned 

222 with the ``pos`` axis of retained top-k and optional full logits. 

223 use_jacobian: 

224 Whether the Jacobian transport was applied (``False`` = logit lens). 

225 """ 

226 

227 lens_topk_values: Dict[int, Float[torch.Tensor, "pos k"]] 

228 lens_topk_indices: Dict[int, Int[torch.Tensor, "pos k"]] 

229 model_topk_values: Float[torch.Tensor, "pos k"] 

230 model_topk_indices: Int[torch.Tensor, "pos k"] 

231 tokens: Int[torch.Tensor, "seq"] 

232 positions: List[int] 

233 use_jacobian: bool = True 

234 lens_logits: Optional[Dict[int, Float[torch.Tensor, "pos d_vocab"]]] = None 

235 model_logits: Optional[Float[torch.Tensor, "pos d_vocab"]] = None 

236 

237 def top_tokens(self, tokenizer: Any, k: int = 5) -> Dict[int, List[List[str]]]: 

238 """Decode the top-``k`` tokens per layer and position. 

239 

240 Args: 

241 tokenizer: The model's tokenizer (``model.tokenizer``). 

242 k: Number of top tokens to decode per (layer, position). 

243 

244 Returns: 

245 ``{layer: [ [top-k strings] per position ]}``, positions aligned with 

246 :attr:`positions`. 

247 """ 

248 out: Dict[int, List[List[str]]] = {} 

249 retained = next(iter(self.lens_topk_indices.values())).shape[-1] 

250 if not 1 <= k <= retained: 

251 raise ValueError(f"k must be between 1 and the retained top_k={retained}, got {k}") 

252 for layer, ids in self.lens_topk_indices.items(): 

253 out[layer] = [[tokenizer.decode([t]) for t in row[:k].tolist()] for row in ids] 

254 return out 

255 

256 

257class JacobianLens: 

258 """A fitted Jacobian lens: one transport matrix per source layer. 

259 

260 Layer convention (matching the reference implementation and the published 

261 artifacts): index ``l`` refers to the **output of block** ``l`` at the 

262 Bridge-native hook ``blocks.{l}.hook_out``. ``J[l]`` maps that activation 

263 to the final block's output, pre final norm. The final layer itself is never 

264 fitted (its transport is the identity), so 

265 ``source_layers == [0, ..., n_layers - 2]`` for a full fit. 

266 

267 Attributes: 

268 jacobians: ``{layer: [d_model, d_model]}`` transport matrices, fp32, CPU. 

269 n_prompts: Number of prompts averaged into the fit. 

270 d_model: Residual stream width the lens was fitted for. 

271 metadata: Optional provenance (model name, TransformerLens version, fit 

272 hyperparameters). Preserved by :meth:`save`/:meth:`load`; artifacts 

273 from the reference implementation load with empty metadata. 

274 """ 

275 

276 def __init__( 

277 self, 

278 jacobians: Dict[int, Float[torch.Tensor, "d_model d_model"]], 

279 *, 

280 n_prompts: int, 

281 d_model: int, 

282 metadata: Optional[Dict[str, Any]] = None, 

283 ) -> None: 

284 if not jacobians: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

285 raise ValueError("jacobians must contain at least one layer") 

286 for layer, matrix in jacobians.items(): 

287 if matrix.shape != (d_model, d_model): 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true

288 raise ValueError( 

289 f"jacobians[{layer}] has shape {tuple(matrix.shape)}, " 

290 f"expected ({d_model}, {d_model})" 

291 ) 

292 self.jacobians: Dict[int, torch.Tensor] = { 

293 int(layer): matrix.detach().float().cpu() for layer, matrix in jacobians.items() 

294 } 

295 self.n_prompts = int(n_prompts) 

296 self.d_model = int(d_model) 

297 self.metadata: Dict[str, Any] = dict(metadata or {}) 

298 recorded_estimator = self.metadata.get("estimator") 

299 self.estimator: str = ( 

300 recorded_estimator if isinstance(recorded_estimator, str) else ESTIMATOR_JACOBIAN 

301 ) 

302 self._device_jacobians: Dict[Tuple[int, torch.device], torch.Tensor] = {} 

303 self._dictionary_cache: Dict[Tuple[int, torch.device], torch.Tensor] = {} 

304 self._unembedding_snapshots: Dict[torch.device, torch.Tensor] = {} 

305 

306 @property 

307 def source_layers(self) -> List[int]: 

308 """Sorted list of layers this lens has transport matrices for.""" 

309 return sorted(self.jacobians) 

310 

311 def __repr__(self) -> str: 

312 layers = self.source_layers 

313 return ( 

314 f"JacobianLens(layers={layers[0]}..{layers[-1]} ({len(layers)}), " 

315 f"d_model={self.d_model}, n_prompts={self.n_prompts})" 

316 ) 

317 

318 # ------------------------------------------------------------------ # 

319 # persistence # 

320 # ------------------------------------------------------------------ # 

321 

322 def save(self, path: str, *, dtype: torch.dtype = torch.float16) -> None: 

323 """Save the lens in the reference implementation's artifact format. 

324 

325 The four official keys (``J``, ``n_prompts``, ``source_layers``, 

326 ``d_model``) are written unchanged so the file stays loadable by the 

327 reference package; TransformerLens provenance is stored under an 

328 additive ``metadata`` key. 

329 

330 Args: 

331 path: Destination ``.pt`` path. 

332 dtype: Storage dtype. Defaults to fp16 like the reference 

333 implementation — Jacobian entries are order-one, so the smaller 

334 dtype costs little precision and halves the artifact on disk. 

335 """ 

336 _validate_metadata(self.metadata) 

337 payload: Dict[str, Any] = { 

338 "J": {layer: matrix.to(dtype) for layer, matrix in self.jacobians.items()}, 

339 "n_prompts": self.n_prompts, 

340 "source_layers": self.source_layers, 

341 "d_model": self.d_model, 

342 } 

343 if self.metadata: 

344 payload["metadata"] = self.metadata 

345 torch.save(payload, path) 

346 

347 @classmethod 

348 def load(cls: Type[_LensT], path: str) -> _LensT: 

349 """Load a lens artifact or fit checkpoint saved in a supported schema. 

350 

351 Two file schemas are accepted: 

352 

353 **Artifact** (the reference format, written by :meth:`save` or the 

354 Anthropic reference package): must contain a ``J`` key mapping layer 

355 indices to transport matrices, plus ``n_prompts``, ``d_model``, and an 

356 optional ``metadata`` dict. 

357 

358 **Fit checkpoint** (running-sum format, written by the reference 

359 implementation's ``write_checkpoint()`` during fitting): must contain 

360 a ``jacobian_sum`` key mapping layer indices to *running-sum* matrices 

361 (i.e. the sum over prompts, not yet divided by the prompt count), plus 

362 ``n_done``. ``d_model`` is inferred from the first matrix's shape; 

363 no explicit ``d_model`` key is required or expected. The per-layer 

364 means are reconstructed on load. A 

365 ``converted_from: "jacobian_lens_checkpoint"`` key is added to 

366 metadata so :meth:`merge` refuses to silently combine checkpoints with 

367 natively TL-fitted lenses. Fit-reserved provenance keys 

368 (``transformer_lens_fit``, etc.) are stripped; scalar fields that can 

369 be serialised under ``weights_only=True`` are preserved. 

370 Tensor-valued metadata fields that would fail :func:`_validate_metadata` 

371 are recorded by name in a ``dropped_fields`` list. 

372 

373 Fit checkpoint schema (reference ``write_checkpoint()`` format) 

374 --------------------------------------------------------------- 

375 The reference implementation writes exactly six top-level keys; all 

376 other keys in the payload are ignored:: 

377 

378 { 

379 "jacobian_sum": {<layer int>: <float32 tensor [d, d]>, ...}, 

380 "n_done": <int>, # prompts accumulated into jacobian_sum 

381 "next_idx": <int>, # next prompt index (informational) 

382 "source_layers": [<int>, ...], # documented layer indices (informational) 

383 "target_layer": <int>, # target layer — harvested into metadata 

384 "skip_first": <int>, # leading positions skipped (informational) 

385 # optional flat provenance accepted from alternative checkpoint writers: 

386 "model_name": <str>, 

387 "model_revision": <str>, 

388 "corpus": <str>, 

389 # optional nested provenance accepted from alternative writers: 

390 "metadata": {<str>: <scalar/list/dict>, ...}, 

391 } 

392 

393 Args: 

394 path: Path to the ``.pt`` file. 

395 

396 Raises: 

397 ValueError: If the file lacks both a ``J`` key (artifact) and a 

398 ``jacobian_sum`` key (checkpoint), or if a checkpoint records a 

399 non-positive ``n_prompts``. 

400 """ 

401 payload = torch.load(path, map_location="cpu", weights_only=True) 

402 if "J" in payload: 

403 return cls( 

404 {int(layer): matrix for layer, matrix in payload["J"].items()}, 

405 n_prompts=int(payload.get("n_prompts", 0)), 

406 d_model=int(payload["d_model"]), 

407 metadata=payload.get("metadata"), 

408 ) 

409 if "jacobian_sum" in payload: 

410 return cls._from_checkpoint_payload(path, payload) 

411 raise ValueError( 

412 f"{path} does not look like a Jacobian lens artifact or fit checkpoint. " 

413 "Expected a 'J' key (artifact) or 'jacobian_sum' key (fit checkpoint). " 

414 "See JacobianLens.load() for the supported file schemas." 

415 ) 

416 

417 @classmethod 

418 def _from_checkpoint_payload(cls: Type[_LensT], path: str, payload: Dict[str, Any]) -> _LensT: 

419 """Reconstruct a JacobianLens from a fit-checkpoint payload. 

420 

421 Divides the running Jacobian sums by ``n_prompts``, strips fit-reserved 

422 provenance keys, harvests safe scalar metadata, records dropped tensor 

423 fields, and marks the result as converted so :meth:`merge` refuses to 

424 mix it with natively TL-fitted lenses. 

425 """ 

426 n_prompts = int(payload.get("n_done", payload.get("n_prompts", 0))) 

427 if n_prompts <= 0: 

428 raise ValueError( 

429 f"{path} is a fit checkpoint with n_prompts={n_prompts}; " 

430 "a positive prompt count is required to reconstruct the Jacobian mean." 

431 ) 

432 if not payload["jacobian_sum"]: 

433 raise ValueError( 

434 f"{path} is a fit checkpoint with an empty jacobian_sum; " 

435 "at least one layer matrix is required to reconstruct d_model." 

436 ) 

437 first_matrix = next(iter(payload["jacobian_sum"].values())) 

438 d_model = first_matrix.shape[0] 

439 jacobians = { 

440 int(layer): matrix.float() / n_prompts 

441 for layer, matrix in payload["jacobian_sum"].items() 

442 } 

443 

444 # Collect raw provenance: first from nested "metadata", then supplement 

445 # with flat top-level keys that some checkpoint writers place directly 

446 # in the payload (model_name, model_revision, corpus). 

447 raw_meta: Dict[str, Any] = dict(payload.get("metadata") or {}) 

448 for key in _CHECKPOINT_FLAT_PROVENANCE: 

449 if key in payload and key not in raw_meta: 

450 raw_meta[key] = payload[key] 

451 # target_layer lives at the top level in the reference checkpoint format; 

452 # harvest it into metadata so validate_model() can check the fitting target. 

453 if "target_layer" in payload and "target_layer" not in raw_meta: 

454 raw_meta["target_layer"] = payload["target_layer"] 

455 

456 # Build clean metadata: drop fit-reserved keys, record tensor-valued 

457 # fields that _validate_metadata would reject (they cannot survive a 

458 # weights_only=True reload), and keep everything else that validates. 

459 dropped_fields: List[str] = [] 

460 clean_meta: Dict[str, Any] = {} 

461 for key, value in raw_meta.items(): 

462 if key in _FIT_RESERVED_KEYS: 

463 continue 

464 if isinstance(value, torch.Tensor): 

465 dropped_fields.append(f"{key}: shape={tuple(value.shape)} dtype={value.dtype}") 

466 continue 

467 try: 

468 _validate_metadata({key: value}) 

469 clean_meta[key] = value 

470 except ValueError: 

471 dropped_fields.append(key) 

472 

473 clean_meta["converted_from"] = "jacobian_lens_checkpoint" 

474 if dropped_fields: 

475 clean_meta["dropped_fields"] = dropped_fields 

476 

477 return cls(jacobians, n_prompts=n_prompts, d_model=d_model, metadata=clean_meta) 

478 

479 @classmethod 

480 def from_pretrained( 

481 cls, 

482 name_or_path: str, 

483 *, 

484 filename: str = "lens.pt", 

485 revision: Optional[str] = None, 

486 model: Any = None, 

487 ) -> "JacobianLens": 

488 """Load a lens from a local path, a short model name, or a Hub repo. 

489 

490 Resolution order 

491 ---------------- 

492 1. **Local file** — if *name_or_path* is an existing ``.pt`` file, 

493 load it directly. 

494 2. **Local directory** — if *name_or_path* is a directory, load 

495 ``<name_or_path>/<filename>``. 

496 3. **Registry short name or HF model ID** — if *name_or_path* matches 

497 a key or alias in the bundled ``jacobian_lens_registry.json`` (e.g. 

498 ``"gemma-2-2b"`` or ``"google/gemma-2-2b"``), the corresponding 

499 artifact in ``neuronpedia/jacobian-lens`` is fetched automatically, 

500 at the commit the registry pins unless *revision* is given. 

501 The *filename* argument is ignored in this case because the registry 

502 already encodes the correct subpath. 

503 4. **Explicit Hub repo** — otherwise *name_or_path* is treated as a Hub 

504 repo id and *filename* is used as-is, preserving full backward 

505 compatibility (e.g. ``from_pretrained("neuronpedia/jacobian-lens", 

506 filename="gpt2-small/jlens/...")``). 

507 

508 Args: 

509 name_or_path: A local ``.pt`` file, a local directory, a short 

510 model name such as ``"gemma-2-2b"`` or ``"llama3.1-8b"``, a 

511 Hugging Face model ID such as ``"google/gemma-2-2b"``, or an 

512 explicit Hub repo id paired with *filename*. 

513 filename: File (or subpath) inside a local directory or an explicit 

514 Hub repo. Ignored when *name_or_path* resolves via the 

515 registry. 

516 revision: Optional Hub revision (branch, tag, or commit). When 

517 omitted, registry names resolve at the registry's pinned commit 

518 and an explicit Hub repo follows its mutable default branch; 

519 pin a commit hash for reproducible analyses of the latter. 

520 model: If given, :meth:`validate_model` is called so dimension or 

521 weight-processing mismatches fail here rather than at first use. 

522 

523 Returns: 

524 The loaded (and, if ``model`` was given, validated) lens. 

525 

526 Examples:: 

527 

528 # Short model name — no need to remember the HF subpath 

529 lens = JacobianLens.from_pretrained("gemma-2-2b", model=model) 

530 

531 # HF model ID also works 

532 lens = JacobianLens.from_pretrained("google/gemma-2-2b", model=model) 

533 

534 # Explicit Hub repo + subpath (backward-compatible) 

535 lens = JacobianLens.from_pretrained( 

536 "neuronpedia/jacobian-lens", 

537 filename="gpt2-small/jlens/Salesforce-wikitext/gpt2_jacobian_lens.pt", 

538 model=model, 

539 ) 

540 """ 

541 import os 

542 

543 if os.path.isfile(name_or_path): 543 ↛ 544line 543 didn't jump to line 544 because the condition on line 543 was never true

544 lens = cls.load(name_or_path) 

545 elif os.path.isdir(name_or_path): 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true

546 lens = cls.load(os.path.join(name_or_path, filename)) 

547 else: 

548 from huggingface_hub import hf_hub_download 

549 

550 resolved = _resolve_registry_entry(name_or_path) 

551 if resolved is not None: 

552 repo_id, resolved_filename = resolved 

553 revision = _registry_revision() if revision is None else revision 

554 else: 

555 repo_id, resolved_filename = name_or_path, filename 

556 

557 local_path = call_hf_with_retry( 

558 hf_hub_download, 

559 repo_id=repo_id, 

560 filename=resolved_filename, 

561 revision=revision, 

562 ) 

563 lens = cls.load(local_path) 

564 if model is not None: 

565 lens.validate_model(model) 

566 return lens 

567 

568 @classmethod 

569 def merge(cls, lenses: Sequence["JacobianLens"]) -> "JacobianLens": 

570 """Combine lenses fitted on disjoint prompt slices. 

571 

572 The per-layer matrices are averaged weighted by each lens's 

573 ``n_prompts``, matching the reference implementation, so fitting can be 

574 parallelized across processes or machines and merged afterwards. 

575 Provenance must match across shards (apart from ``n_prompts``), so a 

576 merge cannot silently relabel matrices fitted with different models, 

577 corpora, dtypes, or recorded estimator settings. The merged count 

578 replaces the per-shard count. 

579 

580 Args: 

581 lenses: Lenses that agree exactly on ``source_layers`` and 

582 ``d_model``. 

583 

584 Raises: 

585 ValueError: On an empty sequence, mismatched lenses, or shards from 

586 different estimators. 

587 """ 

588 if not lenses: 

589 raise ValueError("cannot merge an empty sequence of lenses") 

590 invalid_counts = [ 

591 (index, lens.n_prompts) for index, lens in enumerate(lenses) if lens.n_prompts <= 0 

592 ] 

593 if invalid_counts: 

594 raise ValueError( 

595 "every lens passed to merge() must have positive n_prompts; " 

596 f"invalid shards: {invalid_counts}" 

597 ) 

598 first = lenses[0] 

599 for lens in lenses: 

600 _validate_metadata(lens.metadata) 

601 first_provenance = { 

602 key: value for key, value in first.metadata.items() if key != "n_prompts" 

603 } 

604 for other in lenses[1:]: 

605 if other.source_layers != first.source_layers or other.d_model != first.d_model: 

606 raise ValueError( 

607 "all lenses being merged must share the same source_layers and d_model" 

608 ) 

609 other_provenance = { 

610 key: value for key, value in other.metadata.items() if key != "n_prompts" 

611 } 

612 if other_provenance != first_provenance: 

613 raise ValueError( 

614 "all lenses being merged must share the same provenance metadata " 

615 "apart from n_prompts" 

616 ) 

617 total = sum(lens.n_prompts for lens in lenses) 

618 merged = { 

619 layer: torch.stack([lens.jacobians[layer] * lens.n_prompts for lens in lenses]).sum( 

620 dim=0 

621 ) 

622 / total 

623 for layer in first.source_layers 

624 } 

625 metadata = dict(first.metadata) 

626 if metadata: 

627 metadata["n_prompts"] = total 

628 return cls(merged, n_prompts=total, d_model=first.d_model, metadata=metadata) 

629 

630 # ------------------------------------------------------------------ # 

631 # model validation # 

632 # ------------------------------------------------------------------ # 

633 

634 def validate_model(self, model: Any) -> "JacobianLens": 

635 """Check that ``model`` matches this lens; raise loudly if not. 

636 

637 Requires a raw causal ``TransformerBridge`` with the standard direct 

638 final-norm/unembed path, verifies recorded model provenance, residual 

639 width and layer range, and enforces the published final-block target 

640 convention. 

641 

642 Args: 

643 model: A raw ``TransformerBridge``. 

644 

645 Returns: 

646 ``self``, for chaining. 

647 

648 Raises: 

649 TypeError: If model is not a ``TransformerBridge``. 

650 ValueError: On model provenance or ``d_model`` mismatch, 

651 out-of-range source layers, compatibility mode, unsupported 

652 attention/output paths, or a non-final target convention. 

653 """ 

654 _require_raw_bridge(model, estimator=type(self).__name__) 

655 artifact_model_name = self.metadata.get("model_name") 

656 current_model_name = getattr(model.cfg, "model_name", None) 

657 if artifact_model_name is not None and artifact_model_name != current_model_name: 

658 raise ValueError( 

659 f"lens was fitted for model {artifact_model_name!r}, but the supplied " 

660 f"model is {current_model_name!r}." 

661 ) 

662 artifact_revision = self.metadata.get("model_revision") 

663 current_revision = _get_model_revision(model) 

664 if artifact_revision is not None and artifact_revision != current_revision: 

665 raise ValueError( 

666 f"lens was fitted for model revision {artifact_revision!r}, but the " 

667 f"supplied model revision is {current_revision!r}." 

668 ) 

669 d_model = model.cfg.d_model 

670 if d_model != self.d_model: 

671 raise ValueError( 

672 f"lens was fitted for d_model={self.d_model}, but the model has " 

673 f"d_model={d_model} — this lens belongs to a different model." 

674 ) 

675 n_layers = model.cfg.n_layers 

676 final_layer = n_layers - 1 

677 out_of_range = [layer for layer in self.source_layers if not 0 <= layer < final_layer] 

678 if out_of_range: 

679 raise ValueError( 

680 f"lens has source layers {out_of_range} outside the model's " 

681 f"0..{final_layer - 1} source range — this lens belongs to a different model." 

682 ) 

683 target_layer = int(self.metadata.get("target_layer", final_layer)) 

684 if target_layer != final_layer: 

685 raise ValueError( 

686 f"lens targets layer {target_layer}, but readout supports only the " 

687 f"published final-layer convention ({final_layer}); refit without " 

688 "a custom target layer." 

689 ) 

690 return self 

691 

692 # ------------------------------------------------------------------ # 

693 # reading # 

694 # ------------------------------------------------------------------ # 

695 

696 def clear_device_cache(self) -> None: 

697 """Release cached Jacobians, dictionaries, and unembedding snapshots on devices.""" 

698 self._device_jacobians.clear() 

699 self._dictionary_cache.clear() 

700 self._unembedding_snapshots.clear() 

701 

702 def _matrix_on(self, layer: int, device: Union[str, torch.device]) -> torch.Tensor: 

703 """Return one cached fp32 Jacobian copy for a layer/device pair.""" 

704 if layer not in self.jacobians: 

705 raise ValueError( 

706 f"layer {layer} is not in this lens's source layers " 

707 f"({self.source_layers[0]}..{self.source_layers[-1]})" 

708 ) 

709 resolved_device = torch.device(device) 

710 key = (layer, resolved_device) 

711 matrix = self._device_jacobians.get(key) 

712 if matrix is None: 

713 matrix = self.jacobians[layer].to(device=resolved_device, dtype=torch.float32) 

714 self._device_jacobians[key] = matrix 

715 return matrix 

716 

717 def transport( 

718 self, 

719 residual: Float[torch.Tensor, "... d_model"], 

720 layer: int, 

721 ) -> Float[torch.Tensor, "... d_model"]: 

722 """Map layer-``layer`` activations into the final block's output basis. 

723 

724 Computes ``J[layer] @ h`` per activation vector, in fp32. 

725 

726 Args: 

727 residual: Activations from the output of block ``layer``. 

728 layer: Source layer index. 

729 """ 

730 matrix = self._matrix_on(layer, residual.device) 

731 return residual.float() @ matrix.T 

732 

733 @torch.no_grad() 

734 def readout( 

735 self, 

736 model: Any, 

737 input: Union[str, Int[torch.Tensor, "batch seq"]], 

738 *, 

739 layers: Optional[Sequence[int]] = None, 

740 positions: Optional[Sequence[int]] = None, 

741 use_jacobian: bool = True, 

742 top_k: int = DEFAULT_TOP_K, 

743 return_full_logits: bool = False, 

744 ) -> JacobianLensReadout: 

745 """Read per-layer vocabulary logits for a prompt. 

746 

747 Runs the model once with caching, transports the residual stream at each 

748 requested layer through ``J[layer]`` (or the identity when 

749 ``use_jacobian=False`` — the logit lens), and applies the model's own 

750 final norm, unembedding, architecture logit scaling, and logit soft cap. 

751 

752 Args: 

753 model: A raw ``TransformerBridge``. 

754 input: A prompt string, or a ``[1, seq]`` token tensor. 

755 layers: Layers to read. Defaults to every fitted layer plus the 

756 final layer. The final layer (``n_layers - 1``) is always read 

757 with the identity transport — by construction its lens equals 

758 the model's own output distribution. 

759 positions: Token positions to read (negative indices allowed). 

760 Defaults to all positions. 

761 use_jacobian: Apply the Jacobian transport. ``False`` gives the 

762 logit-lens baseline through the identical code path. 

763 top_k: Number of values and vocabulary ids retained per layer and 

764 position. Defaults to 10. 

765 return_full_logits: Also retain full vocabulary tensors on CPU. 

766 This is opt-in because a 64-token Gemma readout across all 

767 layers is roughly 1.7 GB. 

768 

769 Returns: 

770 A :class:`JacobianLensReadout`. 

771 

772 Raises: 

773 ValueError: If the model fails :meth:`validate_model`, ``input`` is 

774 batched, ``top_k`` is invalid, a requested layer has no 

775 transport matrix, or the model is in training mode. 

776 """ 

777 self.validate_model(model) 

778 tokens = model.to_tokens(input) if isinstance(input, str) else input 

779 if tokens.ndim != 2 or tokens.shape[0] != 1: 779 ↛ 780line 779 didn't jump to line 780 because the condition on line 779 was never true

780 raise ValueError(f"readout expects a single prompt; got shape {tuple(tokens.shape)}") 

781 n_layers = model.cfg.n_layers 

782 final_layer = n_layers - 1 

783 if layers is None: 

784 layers = self.source_layers + [final_layer] 

785 layers = [_normalize_layer(layer, n_layers) for layer in layers] 

786 for layer in layers: 

787 if use_jacobian and layer != final_layer and layer not in self.jacobians: 787 ↛ 788line 787 didn't jump to line 788 because the condition on line 787 was never true

788 raise ValueError( 

789 f"layer {layer} is not in this lens's source layers; " 

790 f"available: {self.source_layers} (+{final_layer} as identity)" 

791 ) 

792 

793 if top_k < 1: 793 ↛ 794line 793 didn't jump to line 794 because the condition on line 793 was never true

794 raise ValueError(f"top_k must be at least 1, got {top_k}") 

795 seq_len = tokens.shape[1] 

796 norm_positions = _normalize_positions(positions, seq_len) 

797 require_eval_mode(model, operation=f"{type(self).__name__}.readout()") 

798 

799 hook_names = { 

800 layer: _resid_post_hook_name(layer) for layer in layers if layer != final_layer 

801 } 

802 wanted = set(hook_names.values()) 

803 logits, cache = model.run_with_cache(tokens, names_filter=lambda name: name in wanted) 

804 selected_model_logits = logits[0, norm_positions, :].float() 

805 if top_k > selected_model_logits.shape[-1]: 805 ↛ 806line 805 didn't jump to line 806 because the condition on line 805 was never true

806 raise ValueError( 

807 f"top_k={top_k} exceeds the model vocabulary size " 

808 f"{selected_model_logits.shape[-1]}" 

809 ) 

810 model_topk = selected_model_logits.topk(top_k, dim=-1) 

811 full_model_logits = selected_model_logits.cpu() if return_full_logits else None 

812 lens_topk_values: Dict[int, torch.Tensor] = {} 

813 lens_topk_indices: Dict[int, torch.Tensor] = {} 

814 full_lens_logits: Optional[Dict[int, torch.Tensor]] = {} if return_full_logits else None 

815 for layer in layers: 

816 if layer == final_layer: 

817 layer_logits = selected_model_logits 

818 layer_topk = model_topk 

819 else: 

820 activation = cache[hook_names[layer]] 

821 _validate_residual_activation( 

822 activation, 

823 d_model=model.cfg.d_model, 

824 hook_name=hook_names[layer], 

825 ) 

826 residual = activation[0, norm_positions, :] 

827 transported = self.transport(residual, layer) if use_jacobian else residual.float() 

828 # fp32 only after the output transform: the model soft-caps in W_U's dtype. 

829 layer_logits = logit_readout(model, transported, apply_ln=True).values.float() 

830 layer_topk = layer_logits.topk(top_k, dim=-1) 

831 lens_topk_values[layer] = layer_topk.values.cpu() 

832 lens_topk_indices[layer] = layer_topk.indices.cpu() 

833 if full_lens_logits is not None: 

834 if layer == final_layer: 

835 assert full_model_logits is not None 

836 full_lens_logits[layer] = full_model_logits 

837 else: 

838 full_lens_logits[layer] = layer_logits.cpu() 

839 return JacobianLensReadout( 

840 lens_topk_values=lens_topk_values, 

841 lens_topk_indices=lens_topk_indices, 

842 model_topk_values=model_topk.values.cpu(), 

843 model_topk_indices=model_topk.indices.cpu(), 

844 tokens=tokens[0].cpu(), 

845 positions=norm_positions, 

846 use_jacobian=use_jacobian, 

847 lens_logits=full_lens_logits, 

848 model_logits=full_model_logits, 

849 ) 

850 

851 @torch.no_grad() 

852 def lens_vectors( 

853 self, 

854 model: Any, 

855 tokens: Union[ConceptToken, Sequence[ConceptToken]], 

856 layer: int, 

857 ) -> Float[torch.Tensor, "n d_model"]: 

858 """Residual-stream directions for vocabulary tokens at a layer. 

859 

860 The J-lens vector for token ``t`` is row ``t`` of ``W_U J[layer]`` 

861 expressed in layer-``layer`` residual coordinates: 

862 ``v_t = J[layer]^T W_U[:, t]``. 

863 

864 Args: 

865 model: The model supplying ``W_U``. 

866 tokens: A token string / id, or a sequence of them. Strings must 

867 encode to a single token. 

868 layer: Source layer for the vectors. 

869 

870 Returns: 

871 One vector per token, fp32, on the model's device. 

872 """ 

873 self.validate_model(model) 

874 layer = _normalize_layer(layer, model.cfg.n_layers) 

875 token_ids = to_token_ids(model, tokens) 

876 unembed_columns = model.W_U[:, token_ids].float() # [d_model, n] 

877 matrix = self._matrix_on(layer, unembed_columns.device) 

878 return (matrix.T @ unembed_columns).T 

879 

880 @torch.no_grad() 

881 def lens_vector_dictionary( 

882 self, model: Any, layer: int 

883 ) -> Float[torch.Tensor, "d_vocab d_model"]: 

884 """Full-vocabulary J-lens dictionary at ``layer``: ``[d_vocab, d_model]``. 

885 

886 Row ``t`` is the J-lens vector ``v_t = J[layer]^T W_U[:, t]`` -- this is 

887 :meth:`lens_vectors` over the entire vocabulary. The result is cached while the 

888 model's unembedding is unchanged so a sparse decomposition can reuse it; 

889 :meth:`clear_device_cache` releases it. 

890 

891 The dictionary is vocabulary-sized and cached on the model's device 

892 (``d_vocab * d_model`` fp32 values, on the order of gigabytes for a large 

893 vocabulary), one entry per requested layer. One detached copy of ``W_U`` is 

894 retained per device to detect changes without transferring weights to the host. 

895 

896 Args: 

897 model: The model supplying ``W_U``. 

898 layer: Source layer for the dictionary (must be a fitted source layer). 

899 

900 Returns: 

901 The dictionary, fp32, on the model's device. 

902 """ 

903 self.validate_model(model) 

904 layer = _normalize_layer(layer, model.cfg.n_layers) 

905 unembed = model.W_U 

906 device = torch.device(unembed.device) 

907 snapshot = self._unembedding_snapshots.get(device) 

908 if snapshot is None or not torch.equal(snapshot, unembed): 

909 self._unembedding_snapshots[device] = unembed.detach().clone() 

910 stale_keys = [key for key in self._dictionary_cache if key[1] == device] 

911 for key in stale_keys: 

912 del self._dictionary_cache[key] 

913 

914 key = (layer, device) 

915 dictionary = self._dictionary_cache.get(key) 

916 if dictionary is None: 

917 matrix = self._matrix_on(layer, device) # [d_model, d_model] 

918 dictionary = (matrix.T @ unembed.float()).T # [d_vocab, d_model] 

919 self._dictionary_cache[key] = dictionary 

920 return dictionary 

921 

922 @torch.no_grad() 

923 def decompose( 

924 self, 

925 model: Any, 

926 activation_or_prompt: Union[torch.Tensor, str], 

927 layer: int, 

928 *, 

929 position: Optional[int] = None, 

930 k: int = DEFAULT_K, 

931 algorithm: str = "nonnegative_orthogonal_matching_pursuit", 

932 ) -> JSpaceDecomposition: 

933 """Decompose an activation into its J-space content at ``layer``. 

934 

935 ``activation_or_prompt`` is either: 

936 

937 - a raw activation vector of shape ``[d_model]`` (leave ``position`` as ``None``), or 

938 - a prompt -- a string or a ``[1, seq]`` token tensor -- in which case ``position`` 

939 selects the token whose ``blocks.{layer}.hook_out`` activation is decomposed. 

940 

941 The full-vocabulary dictionary at ``layer`` is built (and cached) via 

942 :meth:`lens_vector_dictionary`, then :func:`get_sparse_decomposition` solves for a 

943 ``k``-sparse nonnegative combination of J-lens vectors. 

944 

945 Args: 

946 model: A raw ``TransformerBridge``. 

947 activation_or_prompt: An activation vector, or a prompt (string / token tensor). 

948 layer: Source layer (must be a fitted source layer). 

949 position: Token position when a prompt is given; must be ``None`` for a raw 

950 activation vector. 

951 k: Upper bound on the number of J-lens vectors to select; selection stops early 

952 once no unselected vector is materially positively correlated, so fewer may be 

953 returned. 

954 algorithm: Coefficient-update rule; see :func:`get_sparse_decomposition`. 

955 

956 Returns: 

957 A :class:`JSpaceDecomposition`. Its ``support`` (token ids here) holds only the 

958 numerically active J-lens vectors and ``selected_support`` every selected vector, 

959 with ``support.numel() <= selected_support.numel() <= k``. ``support`` and its 

960 token-decoding tensors are on CPU; the vector-valued outputs stay on the model's 

961 device. 

962 

963 Raises: 

964 ValueError: On an invalid model, a mismatched activation shape, a batched prompt, 

965 a prompt while the model is in training mode, an unfitted layer, or an invalid 

966 ``k`` / ``algorithm``. 

967 RuntimeError: If the default nonnegative least-squares solver cannot validate its 

968 result against the KKT conditions. 

969 """ 

970 activation, resolved_layer = self._resolve_activation( 

971 model, activation_or_prompt, layer, position, operation="decompose" 

972 ) 

973 dictionary = self.lens_vector_dictionary(model, resolved_layer) 

974 return get_sparse_decomposition( 

975 activation.float().to(dictionary.device), dictionary, k, algorithm=algorithm 

976 ) 

977 

978 @torch.no_grad() 

979 def coordinate_patch( 

980 self, 

981 model: Any, 

982 activation_or_prompt: Union[torch.Tensor, str], 

983 layer: int, 

984 source_token: ConceptToken, 

985 target_token: ConceptToken, 

986 *, 

987 position: Optional[int] = None, 

988 decomposition: Optional[JSpaceDecomposition] = None, 

989 k: int = DEFAULT_K, 

990 mode: str = "substitute", 

991 alpha: float = 1.0, 

992 algorithm: str = "nonnegative_orthogonal_matching_pursuit", 

993 ) -> CoordinatePatch: 

994 """Patch sparse J-space coordinates for an activation at ``layer``. 

995 

996 The activation may be a raw ``[d_model]`` vector or a prompt paired with ``position``, 

997 exactly as in :meth:`decompose`. ``source_token`` must occur in the active sparse support. 

998 ``substitute`` replaces the target coordinate with the source coordinate and zeros the 

999 source; ``swap`` exchanges them. Other coordinates and ``x - reconstruction`` are fixed. 

1000 

1001 A supplied ``decomposition`` avoids repeating the vocabulary-scale sparse solve after its 

1002 activation and dictionary compatibility have been validated. 

1003 

1004 Args: 

1005 model: A raw ``TransformerBridge``. 

1006 activation_or_prompt: An activation vector, or a prompt (string / token tensor). 

1007 layer: Source layer (must be a fitted source layer). 

1008 source_token: Active source concept, as a single-token string or token id. 

1009 target_token: Distinct target concept, as a single-token string or token id. 

1010 position: Token position when a prompt is given; ``None`` for a raw activation. 

1011 decomposition: Optional compatible decomposition of this activation and dictionary. 

1012 k: Sparse-solver upper bound when ``decomposition`` is not supplied. 

1013 mode: ``"substitute"`` or ``"swap"``. 

1014 alpha: Finite interpolation strength; zero is an exact no-op. 

1015 algorithm: Sparse coefficient-update rule when ``decomposition`` is not supplied. A 

1016 supplied decomposition is accepted if it fits as either algorithm's output. 

1017 

1018 Returns: 

1019 A :class:`CoordinatePatch` containing the edited frame, diagnostics, and activation. 

1020 """ 

1021 activation, resolved_layer = self._resolve_activation( 

1022 model, activation_or_prompt, layer, position, operation="coordinate_patch" 

1023 ) 

1024 dictionary = self.lens_vector_dictionary(model, resolved_layer) 

1025 source_id, target_id = to_token_ids(model, [source_token, target_token]) 

1026 return solve_coordinate_patch( 

1027 activation.float().to(dictionary.device), 

1028 dictionary, 

1029 source_id, 

1030 target_id, 

1031 decomposition=decomposition, 

1032 k=k, 

1033 mode=mode, 

1034 alpha=alpha, 

1035 algorithm=algorithm, 

1036 ) 

1037 

1038 def _resolve_activation( 

1039 self, 

1040 model: Any, 

1041 activation_or_prompt: Union[torch.Tensor, str], 

1042 layer: int, 

1043 position: Optional[int], 

1044 *, 

1045 operation: str, 

1046 ) -> Tuple[torch.Tensor, int]: 

1047 """Validate the model and layer, then resolve either a raw ``[d_model]`` activation or a 

1048 prompt plus ``position`` to the activation vector to analyse. 

1049 

1050 Returns ``(activation, resolved_layer)``. Shared by :meth:`decompose`, 

1051 :meth:`coordinate_patch`, and :meth:`occupancy` so all accept the same input forms with 

1052 identical validation. ``operation`` names the caller in the training-mode refusal, 

1053 which a raw activation (no forward) never triggers. 

1054 """ 

1055 self.validate_model(model) 

1056 resolved_layer = _normalize_layer(layer, model.cfg.n_layers) 

1057 if resolved_layer not in self.jacobians: 

1058 raise ValueError( 

1059 f"layer {layer} is not in this lens's source layers; " 

1060 f"available: {self.source_layers}" 

1061 ) 

1062 

1063 if position is None: 

1064 if not isinstance(activation_or_prompt, torch.Tensor): 

1065 raise ValueError( 

1066 "analysis expects a raw activation tensor when position is None; pass a " 

1067 "prompt together with a position to analyze a model activation" 

1068 ) 

1069 activation = activation_or_prompt 

1070 if activation.ndim != 1 or activation.shape[0] != self.d_model: 

1071 raise ValueError( 

1072 f"activation must be 1-D of length d_model={self.d_model}, " 

1073 f"got shape {tuple(activation.shape)}" 

1074 ) 

1075 else: 

1076 if isinstance(activation_or_prompt, torch.Tensor) and activation_or_prompt.ndim == 1: 

1077 raise ValueError( 

1078 "position is only valid with a prompt (string or [1, seq] tokens); " 

1079 "pass a raw 1-D activation with position=None instead" 

1080 ) 

1081 tokens = ( 

1082 model.to_tokens(activation_or_prompt) 

1083 if isinstance(activation_or_prompt, str) 

1084 else activation_or_prompt 

1085 ) 

1086 if tokens.ndim != 2 or tokens.shape[0] != 1: 

1087 raise ValueError( 

1088 f"analysis expects a single prompt; got shape {tuple(tokens.shape)}" 

1089 ) 

1090 require_eval_mode(model, operation=f"{type(self).__name__}.{operation}()") 

1091 hook_name = _resid_post_hook_name(resolved_layer) 

1092 _, cache = model.run_with_cache(tokens, names_filter=lambda name: name == hook_name) 

1093 norm_position = _normalize_positions([position], tokens.shape[1])[0] 

1094 activation = cache[hook_name][0, norm_position, :] 

1095 return activation, resolved_layer 

1096 

1097 @torch.no_grad() 

1098 def occupancy( 

1099 self, 

1100 model: Any, 

1101 activation_or_prompt: Union[torch.Tensor, str], 

1102 layer: int, 

1103 *, 

1104 position: Optional[int] = None, 

1105 max_atoms: int = DEFAULT_K, 

1106 num_control_dictionaries: int = 32, 

1107 seed: int = 0, 

1108 ) -> JSpaceOccupancy: 

1109 """Estimate how many J-lens vectors are meaningfully active in an activation at ``layer``. 

1110 

1111 Resolves ``activation_or_prompt`` (a raw ``[d_model]`` vector, or a prompt plus 

1112 ``position``) exactly as :meth:`decompose`, builds the cached full-vocabulary dictionary 

1113 via :meth:`lens_vector_dictionary`, and calls :func:`estimate_occupancy`. 

1114 

1115 Control dictionaries are ``[d_vocab, d_model]`` CPU draws (for cross-device reproducibility) 

1116 that dominate run time on an accelerator; lower ``num_control_dictionaries`` for speed. 

1117 

1118 Args: 

1119 model: A raw ``TransformerBridge``. 

1120 activation_or_prompt: An activation vector, or a prompt (string / token tensor). 

1121 layer: Source layer (must be a fitted source layer). 

1122 position: Token position when a prompt is given; ``None`` for a raw activation. 

1123 max_atoms: Maximum number of J-lens vectors to consider. 

1124 num_control_dictionaries: Number of random control dictionaries to average over. 

1125 seed: Seed for the random control dictionaries (reproducibility). 

1126 

1127 Returns: 

1128 A :class:`JSpaceOccupancy`. 

1129 """ 

1130 activation, resolved_layer = self._resolve_activation( 

1131 model, activation_or_prompt, layer, position, operation="occupancy" 

1132 ) 

1133 dictionary = self.lens_vector_dictionary(model, resolved_layer) 

1134 return estimate_occupancy( 

1135 activation.float().to(dictionary.device), 

1136 dictionary, 

1137 max_atoms=max_atoms, 

1138 num_control_dictionaries=num_control_dictionaries, 

1139 seed=seed, 

1140 ) 

1141 

1142 @torch.no_grad() 

1143 def fraction_of_variance( 

1144 self, 

1145 model: Any, 

1146 prompts: Union[str, torch.Tensor, Sequence[Union[str, torch.Tensor]]], 

1147 layers: Optional[Sequence[int]] = None, 

1148 *, 

1149 k: int = DEFAULT_K, 

1150 skip_first: int = 16, 

1151 positions: Optional[Sequence[int]] = None, 

1152 show_progress: bool = False, 

1153 ) -> JSpaceVarianceProfile: 

1154 """Profile the J-space share of activation variance over a prompt corpus. 

1155 

1156 Each prompt is run once (caching ``blocks.{layer}.hook_out`` for every requested layer). 

1157 At each sampled position the activation is decomposed and its J-space variance fraction 

1158 ``||j_space_component||^2 / ||activation||^2`` is recorded. The numerator is the 

1159 ``j_space_component`` -- the orthogonal projection of the activation onto the span of the 

1160 selected support (the paper's appendix "J-space component"), *not* the nonnegative 

1161 ``reconstruction``; the two coincide only when every selected atom stays active. Per layer 

1162 the profile reports the median of those fractions and the pooled ratio 

1163 ``sum(||j_space_component||^2) / sum(||activation||^2)`` (the paper's "fraction of total 

1164 variance"). 

1165 

1166 A layer that samples no positions -- every prompt shorter than ``skip_first``, or only 

1167 zero-norm activations -- contributes no fractions: its ``median`` and ``pooled`` are 

1168 ``float("nan")`` and its ``per_position`` tensor is empty. 

1169 

1170 Args: 

1171 model: A raw ``TransformerBridge``. 

1172 prompts: A prompt, or a sequence of prompts. Each token tensor must represent exactly 

1173 one prompt and have shape ``[1, seq]``. 

1174 layers: Source layers to profile; defaults to all fitted ``source_layers``. 

1175 k: Number of J-lens vectors per decomposition. 

1176 skip_first: Non-negative index before which positions are skipped (mirrors the fit's 

1177 early-position skip); not used for sampling when ``positions`` is given. 

1178 positions: Explicit positions to sample instead of ``skip_first`` onward. 

1179 show_progress: Show a tqdm progress bar over prompts. 

1180 

1181 Returns: 

1182 A :class:`JSpaceVarianceProfile`. 

1183 

1184 Raises: 

1185 ValueError: On an invalid model, an unfitted layer, an empty corpus, a negative 

1186 ``skip_first``, a token tensor that does not have shape ``[1, seq]``, or a model 

1187 in training mode. 

1188 """ 

1189 self.validate_model(model) 

1190 if skip_first < 0: 

1191 raise ValueError(f"skip_first must be non-negative, got {skip_first}") 

1192 if layers is None: 

1193 resolved_layers = list(self.source_layers) 

1194 else: 

1195 resolved_layers = [_normalize_layer(layer, model.cfg.n_layers) for layer in layers] 

1196 for layer in resolved_layers: 

1197 if layer not in self.jacobians: 

1198 raise ValueError( 

1199 f"layer {layer} is not in this lens's source layers; " 

1200 f"available: {self.source_layers}" 

1201 ) 

1202 prompt_list: List[Union[str, torch.Tensor]] = ( 

1203 [prompts] if isinstance(prompts, (str, torch.Tensor)) else list(prompts) 

1204 ) 

1205 if not prompt_list: 

1206 raise ValueError("prompts must be a non-empty prompt or sequence of prompts") 

1207 require_eval_mode(model, operation=f"{type(self).__name__}.fraction_of_variance()") 

1208 

1209 hook_names = {layer: _resid_post_hook_name(layer) for layer in resolved_layers} 

1210 wanted_hooks = set(hook_names.values()) 

1211 dictionaries = { 

1212 layer: self.lens_vector_dictionary(model, layer) for layer in resolved_layers 

1213 } 

1214 fractions: Dict[int, List[float]] = {layer: [] for layer in resolved_layers} 

1215 pooled_j_space: Dict[int, float] = {layer: 0.0 for layer in resolved_layers} 

1216 pooled_total: Dict[int, float] = {layer: 0.0 for layer in resolved_layers} 

1217 

1218 for prompt in tqdm(prompt_list, desc="J-space variance", disable=not show_progress): 

1219 tokens = model.to_tokens(prompt) if isinstance(prompt, str) else prompt 

1220 if tokens.ndim != 2 or tokens.shape[0] != 1: 

1221 raise ValueError( 

1222 "fraction_of_variance expects each tokenized prompt to have shape " 

1223 f"[1, seq], got {tuple(tokens.shape)}" 

1224 ) 

1225 _, cache = model.run_with_cache(tokens, names_filter=lambda name: name in wanted_hooks) 

1226 seq_len = tokens.shape[1] 

1227 sampled = ( 

1228 list(range(skip_first, seq_len)) 

1229 if positions is None 

1230 else _normalize_positions(positions, seq_len) 

1231 ) 

1232 for layer in resolved_layers: 

1233 dictionary = dictionaries[layer] 

1234 activations = cache[hook_names[layer]][0] # [seq, d_model] 

1235 for position in sampled: 

1236 activation = activations[position].float().to(dictionary.device) 

1237 total = float(activation @ activation) 

1238 if total <= 0.0: 1238 ↛ 1239line 1238 didn't jump to line 1239 because the condition on line 1238 was never true

1239 continue 

1240 decomposition = get_sparse_decomposition(activation, dictionary, k) 

1241 j_space = float( 

1242 decomposition.j_space_component @ decomposition.j_space_component 

1243 ) 

1244 fractions[layer].append(j_space / total) 

1245 pooled_j_space[layer] += j_space 

1246 pooled_total[layer] += total 

1247 

1248 median = { 

1249 layer: float(torch.tensor(fractions[layer]).median()) 

1250 if fractions[layer] 

1251 else float("nan") 

1252 for layer in resolved_layers 

1253 } 

1254 pooled = { 

1255 layer: pooled_j_space[layer] / pooled_total[layer] 

1256 if pooled_total[layer] > 0 

1257 else float("nan") 

1258 for layer in resolved_layers 

1259 } 

1260 per_position = {layer: torch.tensor(fractions[layer]) for layer in resolved_layers} 

1261 return JSpaceVarianceProfile( 

1262 layers=resolved_layers, median=median, pooled=pooled, per_position=per_position 

1263 ) 

1264 

1265 # ------------------------------------------------------------------ # 

1266 # interventions # 

1267 # ------------------------------------------------------------------ # 

1268 

1269 def steering_hooks( 

1270 self, 

1271 model: Any, 

1272 token: ConceptToken, 

1273 layers: Sequence[int], 

1274 *, 

1275 alpha: float = 4.0, 

1276 positions: Optional[Sequence[int]] = None, 

1277 ) -> List[Tuple[str, Any]]: 

1278 """Hooks that steer the residual stream along a token's J-lens vector. 

1279 

1280 At each layer the unit-normalized lens vector is added, scaled by 

1281 ``alpha`` times the activation's **median** per-position residual norm: 

1282 ``h <- h + alpha * median||h|| * v̂``. This norm-matched 

1283 parameterization follows the steering description in the reference 

1284 implementation's experiment protocols; the paper's minimal form is 

1285 the unscaled ``h <- h + alpha * v_t``, recoverable by passing the raw 

1286 :meth:`lens_vectors` output to your own hook. The median (not mean) is 

1287 used so attention-sink positions — whose residual norms run orders of 

1288 magnitude above typical positions — do not inflate the scale. 

1289 

1290 Args: 

1291 model: The model the hooks will run on. 

1292 token: The concept token to steer toward. 

1293 layers: Layers to intervene at. 

1294 alpha: Steering strength scalar; ``0`` disables. Because of the 

1295 norm-matched scale, values of order 1 already perturb the 

1296 stream by roughly its own magnitude. 

1297 positions: Chunk-local positions to steer (negative indices allowed 

1298 and normalized on every hook invocation). Defaults to all. 

1299 

1300 Returns: 

1301 ``[(hook_name, fn), ...]`` for ``model.hooks(fwd_hooks=...)`` or 

1302 ``model.run_with_hooks(fwd_hooks=...)``. 

1303 """ 

1304 self.validate_model(model) 

1305 hooks = [] 

1306 for layer in [_normalize_layer(layer, model.cfg.n_layers) for layer in layers]: 

1307 direction = self.lens_vectors(model, token, layer)[0] 

1308 unit = _unit_rows(direction.unsqueeze(0), layer=layer)[0] 

1309 device_units: Dict[torch.device, torch.Tensor] = {} 

1310 

1311 def transform( 

1312 selected: Float[torch.Tensor, "batch pos d_model"], 

1313 positions: List[int], 

1314 unit: torch.Tensor = unit, 

1315 device_units: Dict[torch.device, torch.Tensor] = device_units, 

1316 ) -> Float[torch.Tensor, "batch pos d_model"]: 

1317 local_unit = _cached_on_device(unit, device_units, selected.device) 

1318 scale = alpha * selected.float().norm(dim=-1).median() 

1319 return selected.float() + scale * local_unit 

1320 

1321 hooks.append( 

1322 ( 

1323 _resid_post_hook_name(layer), 

1324 _make_intervention_hook(transform, positions, model.cfg.d_model), 

1325 ) 

1326 ) 

1327 return hooks 

1328 

1329 def ablation_hooks( 

1330 self, 

1331 model: Any, 

1332 tokens: Union[ConceptToken, Sequence[ConceptToken]], 

1333 layers: Sequence[int], 

1334 *, 

1335 positions: Optional[Sequence[int]] = None, 

1336 ) -> List[Tuple[str, Any]]: 

1337 """Hooks that project token directions out of the residual stream. 

1338 

1339 With an orthonormal basis ``Q`` of the span of the tokens' lens vectors: 

1340 ``h <- h - Q Qᵀ h``, so ``h`` ends with zero projection onto every 

1341 requested vector. For a single token this is ``h - (h·v̂) v̂``. 

1342 

1343 Args: 

1344 model: The model the hooks will run on. 

1345 tokens: Concept token(s) to suppress. 

1346 layers: Layers to intervene at. 

1347 positions: Chunk-local positions to ablate (negative indices allowed 

1348 and normalized on every hook invocation). Defaults to all. 

1349 

1350 Returns: 

1351 ``[(hook_name, fn), ...]`` for ``model.hooks(fwd_hooks=...)``. 

1352 """ 

1353 self.validate_model(model) 

1354 hooks = [] 

1355 for layer in [_normalize_layer(layer, model.cfg.n_layers) for layer in layers]: 

1356 vectors = self.lens_vectors(model, tokens, layer) 

1357 units = _unit_rows(vectors, layer=layer) 

1358 # Lens vectors of related tokens are far from orthogonal, so removing them one 

1359 # at a time would reintroduce components along the earlier ones 

1360 basis = orthonormal_subspace(units.T).basis 

1361 device_bases: Dict[torch.device, torch.Tensor] = {} 

1362 

1363 def transform( 

1364 selected: Float[torch.Tensor, "batch pos d_model"], 

1365 positions: List[int], 

1366 basis: torch.Tensor = basis, 

1367 device_bases: Dict[torch.device, torch.Tensor] = device_bases, 

1368 ) -> Float[torch.Tensor, "batch pos d_model"]: 

1369 local_basis = _cached_on_device(basis, device_bases, selected.device) 

1370 result = selected.float() 

1371 return result - (result @ local_basis) @ local_basis.T 

1372 

1373 hooks.append( 

1374 ( 

1375 _resid_post_hook_name(layer), 

1376 _make_intervention_hook(transform, positions, model.cfg.d_model), 

1377 ) 

1378 ) 

1379 return hooks 

1380 

1381 def swap_hooks( 

1382 self, 

1383 model: Any, 

1384 source_token: ConceptToken, 

1385 target_token: ConceptToken, 

1386 layers: Sequence[int], 

1387 *, 

1388 alpha: float = 1.0, 

1389 positions: Optional[Sequence[int]] = None, 

1390 clamp_to: Optional[Union[ActivationCache, Mapping[str, torch.Tensor]]] = None, 

1391 ) -> List[Tuple[str, Any]]: 

1392 """Hooks that exchange two concepts' coordinates in lens space. 

1393 

1394 The paper's patching-in-lens-coordinates intervention: with 

1395 ``V = [v_s, v_t]`` and lens coordinates ``c = V⁺ h`` (pseudoinverse), 

1396 the update is ``h <- h + alpha * V (c_target - c)``. The component of 

1397 ``h`` orthogonal to ``span{v_s, v_t}`` is untouched. ``alpha=2`` is the 

1398 paper's "double-strength" swap. 

1399 

1400 Without ``clamp_to``, ``c_target = sigma(c)`` exchanges the two *live* 

1401 coordinates, re-read from the activation seen by every hook. It is 

1402 therefore an involution when applied repeatedly in a subspace whose 

1403 coordinates are preserved between layers: a second application can undo 

1404 the first. 

1405 

1406 With ``clamp_to`` (the paper's multi-layer clamp protocol), ``c_target`` 

1407 is the exchange of each layer's *clean-run* coordinates, fixed when the 

1408 hooks are built, so at ``alpha=1`` the update is idempotent at each 

1409 layer: ``h <- h + V (c_target - V⁺h)``. 

1410 

1411 Args: 

1412 model: The model the hooks will run on. 

1413 source_token: The concept to remove (e.g. ``" France"``). 

1414 target_token: The concept to install (e.g. ``" China"``). 

1415 layers: Layers to intervene at. 

1416 alpha: Swap strength; with ``clamp_to``, the fraction of the way to 

1417 the clamped coordinates. 

1418 positions: Chunk-local positions to swap (negative indices allowed 

1419 and normalized on every hook invocation, and against the clean 

1420 activations when clamping). Defaults to all. 

1421 clamp_to: Activations from an unmodified ``run_with_cache`` at each 

1422 requested layer's ``blocks.{layer}.hook_out`` name. Either the 

1423 ``ActivationCache`` it returns by default or the plain dict from 

1424 ``return_cache_object=False`` is accepted. 

1425 

1426 Returns: 

1427 ``[(hook_name, fn), ...]`` for ``model.hooks(fwd_hooks=...)``. 

1428 """ 

1429 self.validate_model(model) 

1430 source_id, target_id = _distinct_ids( 

1431 model, 

1432 source_token, 

1433 target_token, 

1434 operation="swap" if clamp_to is None else "clamp", 

1435 ) 

1436 

1437 hooks = [] 

1438 for layer in [_normalize_layer(layer, model.cfg.n_layers) for layer in layers]: 

1439 hook_name = _resid_post_hook_name(layer) 

1440 clean_selected: Optional[torch.Tensor] = None 

1441 if clamp_to is not None: 

1442 if hook_name not in clamp_to: 

1443 raise ValueError(f"clamp_to is missing activation {hook_name!r}") 

1444 clean = clamp_to[hook_name] 

1445 _validate_residual_activation( 

1446 clean, d_model=model.cfg.d_model, hook_name=f"clamp_to[{hook_name!r}]" 

1447 ) 

1448 normalized = _normalize_positions(positions, clean.shape[1]) 

1449 clean_selected = clean if positions is None else clean[:, normalized, :] 

1450 

1451 vectors = self.lens_vectors(model, [source_id, target_id], layer) 

1452 units = _unit_rows(vectors, layer=layer) 

1453 _diagnose_intervention_pair( 

1454 units, description=f"swap vectors at layer {layer}", stacklevel=3 

1455 ) 

1456 basis = vectors.T # [d, 2] 

1457 pinv = torch.linalg.pinv(basis) # [2, d] 

1458 clamped = None 

1459 if clean_selected is not None: 

1460 clamped = (clean_selected.float() @ pinv.T)[..., [1, 0]] 

1461 device_basis: Dict[torch.device, torch.Tensor] = {} 

1462 device_pinv: Dict[torch.device, torch.Tensor] = {} 

1463 device_clamped: Dict[torch.device, torch.Tensor] = {} 

1464 

1465 def transform( 

1466 selected: Float[torch.Tensor, "batch pos d_model"], 

1467 positions: List[int], 

1468 basis: torch.Tensor = basis, 

1469 pinv: torch.Tensor = pinv, 

1470 clamped: Optional[torch.Tensor] = clamped, 

1471 device_basis: Dict[torch.device, torch.Tensor] = device_basis, 

1472 device_pinv: Dict[torch.device, torch.Tensor] = device_pinv, 

1473 device_clamped: Dict[torch.device, torch.Tensor] = device_clamped, 

1474 ) -> Float[torch.Tensor, "batch pos d_model"]: 

1475 local_basis = _cached_on_device(basis, device_basis, selected.device) 

1476 local_pinv = _cached_on_device(pinv, device_pinv, selected.device) 

1477 coords = selected.float() @ local_pinv.T # [..., 2] 

1478 if clamped is None: 

1479 target = coords[..., [1, 0]] 

1480 else: 

1481 target = _cached_on_device(clamped, device_clamped, selected.device) 

1482 if target.shape[1] != selected.shape[1] or target.shape[0] not in ( 

1483 1, 

1484 selected.shape[0], 

1485 ): 

1486 raise ValueError( 

1487 "clamp_to activation shape is incompatible with the live " 

1488 f"activation: target coordinates have shape {tuple(target.shape)}, " 

1489 f"live activation has shape {tuple(selected.shape)}" 

1490 ) 

1491 delta = alpha * ((target - coords) @ local_basis.T) 

1492 return selected.float() + delta 

1493 

1494 hooks.append( 

1495 (hook_name, _make_intervention_hook(transform, positions, model.cfg.d_model)) 

1496 ) 

1497 return hooks 

1498 

1499 def swap_clamp_hooks( 

1500 self, 

1501 model: Any, 

1502 source_token: ConceptToken, 

1503 target_token: ConceptToken, 

1504 layers: Sequence[int], 

1505 clean_cache: Union[ActivationCache, Mapping[str, torch.Tensor]], 

1506 *, 

1507 positions: Optional[Sequence[int]] = None, 

1508 ) -> List[Tuple[str, Any]]: 

1509 """Deprecated: use :meth:`swap_hooks` with ``clamp_to=clean_cache``.""" 

1510 warnings.warn( 

1511 "JacobianLens.swap_clamp_hooks is deprecated; use swap_hooks(model, source_token, " 

1512 "target_token, layers, clamp_to=clean_cache), which builds the same hooks.", 

1513 DeprecationWarning, 

1514 stacklevel=2, 

1515 ) 

1516 return self.swap_hooks( 

1517 model, source_token, target_token, layers, positions=positions, clamp_to=clean_cache 

1518 ) 

1519 

1520 def coordinate_patch_hooks( 

1521 self, 

1522 model: Any, 

1523 source_token: ConceptToken, 

1524 target_token: ConceptToken, 

1525 layers: Sequence[int], 

1526 *, 

1527 positions: Sequence[int], 

1528 decomposition_cache: Optional[ 

1529 MutableMapping[Tuple[int, int, int], JSpaceDecomposition] 

1530 ] = None, 

1531 k: int = DEFAULT_K, 

1532 mode: str = "substitute", 

1533 alpha: float = 1.0, 

1534 algorithm: str = "nonnegative_orthogonal_matching_pursuit", 

1535 ) -> List[Tuple[str, Any]]: 

1536 """Hooks that anchor-patch one J-space coordinate live, per forward-pass position. 

1537 

1538 Unlike :meth:`coordinate_patch`, which edits one already-captured activation offline, 

1539 this installs a forward hook that solves :func:`solve_coordinate_patch` independently for 

1540 every ``(batch_idx, position)`` pair at each requested layer -- a vocabulary-scale sparse 

1541 decomposition per pair, per hook firing, unless ``decomposition_cache`` supplies one 

1542 already validated for that ``(layer, batch_idx, position)`` key. 

1543 

1544 Args: 

1545 model: The model the hooks will run on. 

1546 source_token: Active source concept, as a single-token string or token id. 

1547 target_token: Distinct target concept, as a single-token string or token id. 

1548 layers: Layers to intervene at. 

1549 positions: Chunk-local positions to patch (negative indices allowed and normalized on 

1550 every hook invocation). Required -- there is no full-sequence default, because a 

1551 silent default would trigger a vocabulary-scale solve at every position. 

1552 decomposition_cache: Optional caller-owned mapping from ``(layer, batch_idx, 

1553 position)`` to a previously validated 

1554 :class:`~transformer_lens.tools.analysis.jacobian_lens_decomposition.JSpaceDecomposition`. 

1555 A hit skips the vocabulary-scale scan; a miss solves and populates the cache. 

1556 Purely a performance path -- correctness does not depend on it. 

1557 k: Sparse-solver upper bound on a cache miss. 

1558 mode: ``"substitute"`` or ``"swap"``. 

1559 alpha: Finite interpolation strength; zero is an exact no-op. 

1560 algorithm: Sparse coefficient-update rule on a cache miss. A cache hit is accepted 

1561 if it fits as either algorithm's output. 

1562 

1563 Returns: 

1564 ``[(hook_name, fn), ...]`` for ``model.hooks(fwd_hooks=...)``. 

1565 

1566 Raises: 

1567 ValueError: If ``positions`` is empty, if ``source_token`` and ``target_token`` 

1568 resolve to the same id, or if ``source_token`` is not in the top-``k`` active 

1569 support of every patched ``(batch_idx, position)`` pair *at the moment its hook 

1570 fires* -- the whole forward pass fails rather than silently patching a subset. 

1571 This precondition is stronger and more order-dependent than "active on a clean 

1572 forward pass": in a band of layers an earlier hook's patch edits the residual 

1573 that a later layer re-decomposes, and ``substitute``/``swap`` zero or move the 

1574 source coordinate, so the source can be removed from a later layer's active 

1575 support even though it was active on an unhooked pass. Stacking layers or 

1576 positions therefore makes this progressively harder to satisfy. 

1577 

1578 Warns: 

1579 UserWarning: Once per call, naming the number of layers and positions that will 

1580 perform a live vocabulary-scale solve on every cache miss. 

1581 """ 

1582 self.validate_model(model) 

1583 if not positions: 

1584 raise ValueError("positions must contain at least one index") 

1585 resolved_layers = [_normalize_layer(layer, model.cfg.n_layers) for layer in layers] 

1586 source_id, target_id = _distinct_ids(model, source_token, target_token, operation="patch") 

1587 warnings.warn( 

1588 f"coordinate_patch_hooks installs {len(resolved_layers)} layer(s) x " 

1589 f"{len(positions)} position(s) of coordinate-patch hooks; every (batch, position) " 

1590 "pair not already present in decomposition_cache performs a vocabulary-scale sparse " 

1591 "decomposition on every forward pass", 

1592 UserWarning, 

1593 stacklevel=2, 

1594 ) 

1595 

1596 hooks = [] 

1597 for layer in resolved_layers: 

1598 dictionary = self.lens_vector_dictionary(model, layer) 

1599 

1600 def transform( 

1601 selected: Float[torch.Tensor, "batch pos d_model"], 

1602 positions: List[int], 

1603 layer: int = layer, 

1604 dictionary: torch.Tensor = dictionary, 

1605 ) -> Float[torch.Tensor, "batch pos d_model"]: 

1606 patched, _ = solve_coordinate_patch_positions( 

1607 selected.float().to(dictionary.device), 

1608 dictionary, 

1609 positions, 

1610 source_id, 

1611 target_id, 

1612 layer=layer, 

1613 decomposition_cache=decomposition_cache, 

1614 k=k, 

1615 mode=mode, 

1616 alpha=alpha, 

1617 algorithm=algorithm, 

1618 ) 

1619 return patched 

1620 

1621 hooks.append( 

1622 ( 

1623 _resid_post_hook_name(layer), 

1624 _make_intervention_hook(transform, positions, model.cfg.d_model), 

1625 ) 

1626 ) 

1627 return hooks 

1628 

1629 # ------------------------------------------------------------------ # 

1630 # fitting # 

1631 # ------------------------------------------------------------------ # 

1632 

1633 @classmethod 

1634 def fit( 

1635 cls, 

1636 model: Any, 

1637 prompts: Sequence[str], 

1638 *, 

1639 corpus: str, 

1640 source_layers: Optional[Sequence[int]] = None, 

1641 dim_batch: int = 8, 

1642 max_seq_len: int = 128, 

1643 skip_first_positions: int = DEFAULT_SKIP_FIRST_POSITIONS, 

1644 show_progress: bool = True, 

1645 metadata: Optional[Dict[str, Any]] = None, 

1646 ) -> "JacobianLens": 

1647 """Fit a Jacobian lens on a hooked model. 

1648 

1649 Implements the reference estimator exactly. For each prompt: one forward 

1650 pass (the prompt replicated ``dim_batch`` times along the batch axis), 

1651 then ``ceil(d_model / dim_batch)`` backward passes. Each backward plants 

1652 a one-hot cotangent for one output dimension at *every* valid target 

1653 position simultaneously — causal attention guarantees the gradient at 

1654 source position ``t`` is then the sum over target positions 

1655 ``t' >= t`` with no explicit masking. Rows are averaged over valid 

1656 source positions (the first ``skip_first_positions`` and the final 

1657 position are excluded), and prompts contribute equally to the final 

1658 mean. There is no randomness: the computation is deterministic given 

1659 the prompts. 

1660 

1661 The reference implementation reports that fit quality saturates 

1662 quickly — on the order of 100 prompts of 128 tokens is usable; the 

1663 published lenses use up to 1000. Use :meth:`merge` to parallelize 

1664 across prompt slices. 

1665 

1666 Args: 

1667 model: A raw ``TransformerBridge``. Model parameters are temporarily 

1668 frozen (``requires_grad=False``) during fitting and restored 

1669 after. Cotangents and activation gradients use the model dtype; 

1670 fit with a float32 model for the highest-fidelity estimator. The 

1671 model and all of its submodules must be in evaluation mode. 

1672 prompts: Prompt strings. Prompts too short to contain a valid 

1673 position (``seq_len <= skip_first_positions + 1``) are skipped 

1674 with a warning and do not count toward ``n_prompts``. 

1675 corpus: Stable identifier for the prompt corpus or slice, recorded 

1676 in artifact provenance. 

1677 source_layers: Layers to fit. Defaults to every layer below 

1678 the final layer. Negative indices count from ``n_layers``. 

1679 dim_batch: Output dimensions per backward pass. Higher is faster 

1680 but replicates the prompt ``dim_batch`` times in memory; total 

1681 backward FLOPs are unchanged. 

1682 max_seq_len: Prompts are truncated to this many tokens. 

1683 skip_first_positions: Leading positions excluded from the source 

1684 average. 

1685 show_progress: Show a tqdm progress bar over prompts. 

1686 metadata: Extra provenance merged into :attr:`metadata`. 

1687 

1688 Returns: 

1689 The fitted :class:`JacobianLens`. 

1690 

1691 Raises: 

1692 TypeError: If model is not a ``TransformerBridge``. 

1693 ValueError: On compatibility mode, training mode, invalid provenance 

1694 or layer indices, or if no prompt was long enough to fit on. 

1695 """ 

1696 options = _prepare_fit( 

1697 model, 

1698 operation=cls.__name__, 

1699 estimator=ESTIMATOR_JACOBIAN, 

1700 corpus=corpus, 

1701 source_layers=source_layers, 

1702 dim_batch=dim_batch, 

1703 max_seq_len=max_seq_len, 

1704 skip_first_positions=skip_first_positions, 

1705 metadata=metadata, 

1706 ) 

1707 transport_matrices, n_done = _fit_transport_matrices( 

1708 model, 

1709 prompts, 

1710 source_layers=list(options.source_layers), 

1711 dim_batch=options.dim_batch, 

1712 max_seq_len=options.max_seq_len, 

1713 skip_first_positions=options.skip_first_positions, 

1714 show_progress=show_progress, 

1715 backward_provider=_ordinary_vjp, 

1716 ) 

1717 return cls( 

1718 transport_matrices, 

1719 n_prompts=n_done, 

1720 d_model=options.d_model, 

1721 metadata=_build_fit_metadata( 

1722 model, options, estimator=ESTIMATOR_JACOBIAN, n_prompts=n_done, metadata=metadata 

1723 ), 

1724 ) 

1725 

1726 

1727# ---------------------------------------------------------------------- # 

1728# helpers # 

1729# ---------------------------------------------------------------------- # 

1730 

1731 

1732@dataclass(frozen=True) 

1733class _FitOptions: 

1734 """Validated options for one fit.""" 

1735 

1736 source_layers: Tuple[int, ...] 

1737 target_layer: int 

1738 d_model: int 

1739 fit_dtype: torch.dtype 

1740 corpus: str 

1741 dim_batch: int 

1742 max_seq_len: int 

1743 skip_first_positions: int 

1744 

1745 

1746def _prepare_fit( 

1747 model: Any, 

1748 *, 

1749 operation: str, 

1750 estimator: str, 

1751 corpus: str, 

1752 source_layers: Optional[Sequence[int]], 

1753 dim_batch: int, 

1754 max_seq_len: int, 

1755 skip_first_positions: int, 

1756 metadata: Optional[Dict[str, Any]], 

1757) -> _FitOptions: 

1758 """Validate the shared fit contract before any tokenization.""" 

1759 _require_raw_bridge(model, estimator=operation) 

1760 require_eval_mode(model, operation=f"{operation}.fit()") 

1761 if not isinstance(corpus, str) or not corpus.strip(): 

1762 raise ValueError("corpus must be a non-empty provenance identifier") 

1763 n_layers = model.cfg.n_layers 

1764 target_layer = n_layers - 1 

1765 if source_layers is None: 

1766 resolved_sources = list(range(target_layer)) 

1767 else: 

1768 resolved_sources = sorted({_normalize_layer(layer, n_layers) for layer in source_layers}) 

1769 if not resolved_sources: 

1770 raise ValueError("source_layers is empty") 

1771 if resolved_sources[-1] >= target_layer: 

1772 raise ValueError( 

1773 f"every source layer must be below target_layer={target_layer}; got {resolved_sources}" 

1774 ) 

1775 if dim_batch < 1: 

1776 raise ValueError(f"dim_batch must be >= 1, got {dim_batch}") 

1777 if skip_first_positions < 0: 

1778 raise ValueError(f"skip_first_positions must be >= 0, got {skip_first_positions}") 

1779 options = _FitOptions( 

1780 source_layers=tuple(resolved_sources), 

1781 target_layer=target_layer, 

1782 d_model=model.cfg.d_model, 

1783 fit_dtype=model.W_U.dtype, 

1784 corpus=corpus, 

1785 dim_batch=dim_batch, 

1786 max_seq_len=max_seq_len, 

1787 skip_first_positions=skip_first_positions, 

1788 ) 

1789 # Preflight so bad provenance fails before the fit, not after it. 

1790 _build_fit_metadata(model, options, estimator=estimator, n_prompts=0, metadata=metadata) 

1791 if options.fit_dtype in (torch.float16, torch.bfloat16): 

1792 warnings.warn( 

1793 f"fitting in {options.fit_dtype} accumulates transport gradients at reduced " 

1794 "precision; use a float32 TransformerBridge for the highest-fidelity fit", 

1795 UserWarning, 

1796 stacklevel=3, 

1797 ) 

1798 return options 

1799 

1800 

1801def _build_fit_metadata( 

1802 model: Any, 

1803 options: _FitOptions, 

1804 *, 

1805 estimator: str, 

1806 n_prompts: int, 

1807 metadata: Optional[Dict[str, Any]], 

1808) -> Dict[str, Any]: 

1809 """Combine validated user provenance with the fit's own provenance fields.""" 

1810 fit_metadata: Dict[str, Any] = { 

1811 "model_name": getattr(model.cfg, "model_name", None), 

1812 "model_revision": _get_model_revision(model), 

1813 "transformer_lens_version": version("transformer-lens"), 

1814 "model_system": "TransformerBridge", 

1815 "estimator": estimator, 

1816 "processing": {"compatibility_mode": False, "weight_basis": "raw_huggingface"}, 

1817 "hook_convention": "blocks.{layer}.hook_out", 

1818 "corpus": options.corpus, 

1819 "n_prompts": n_prompts, 

1820 "fit_dtype": str(options.fit_dtype).removeprefix("torch."), 

1821 "target_layer": options.target_layer, 

1822 "dim_batch": options.dim_batch, 

1823 "max_seq_len": options.max_seq_len, 

1824 "skip_first_positions": options.skip_first_positions, 

1825 "transformer_lens_fit": True, 

1826 } 

1827 reserved = sorted(set(fit_metadata).intersection(metadata or {})) 

1828 if reserved: 

1829 raise ValueError(f"metadata cannot override fit provenance keys: {reserved}") 

1830 full_metadata = dict(metadata or {}) 

1831 full_metadata.update(fit_metadata) 

1832 _validate_metadata(full_metadata) 

1833 return full_metadata 

1834 

1835 

1836def _resid_post_hook_name(layer: int) -> str: 

1837 """Bridge-native hook for the output of block ``layer``.""" 

1838 return f"blocks.{layer}.hook_out" 

1839 

1840 

1841def _get_model_revision(model: Any) -> Optional[str]: 

1842 """Return the resolved Hugging Face commit recorded on a booted model.""" 

1843 original_model = getattr(model, "original_model", None) 

1844 hf_config = getattr(original_model, "config", None) 

1845 revision = getattr(hf_config, "_commit_hash", None) 

1846 return revision if isinstance(revision, str) and revision else None 

1847 

1848 

1849def _require_raw_bridge(model: Any, *, estimator: str) -> None: 

1850 """Require the causal raw-Bridge fit/readout contract; ``estimator`` names the caller.""" 

1851 from transformer_lens.model_bridge import TransformerBridge 

1852 

1853 if not isinstance(model, TransformerBridge): 

1854 raise TypeError( 

1855 f"{estimator} supports TransformerBridge only; load a fresh model with " 

1856 "TransformerBridge.boot_transformers(...)." 

1857 ) 

1858 if getattr(model, "compatibility_mode", False): 

1859 raise ValueError( 

1860 "compatibility mode is enabled on this TransformerBridge and changes " 

1861 "the residual basis. Use a freshly booted " 

1862 "TransformerBridge.boot_transformers(...) model with raw weights." 

1863 ) 

1864 if getattr(model, "_weights_processed", False): 

1865 raise ValueError( 

1866 "process_weights was called on this TransformerBridge and changed the " 

1867 "raw HuggingFace weight basis. Use a freshly booted " 

1868 "TransformerBridge.boot_transformers(...) model." 

1869 ) 

1870 adapter = model.adapter 

1871 if not adapter.supports_generation: 

1872 raise ValueError( 

1873 f"{estimator} requires a causal decoder-only Bridge whose adapter " 

1874 f"supports text generation; {type(adapter).__name__} declares " 

1875 "supports_generation=False." 

1876 ) 

1877 adapter.validate_output_logits_transform() 

1878 attention_dir = getattr(model.cfg, "attention_dir", "causal") 

1879 if attention_dir != "causal": 

1880 raise ValueError( 

1881 f"{estimator} requires causal attention because its estimator relies on " 

1882 "causality to exclude target positions before each source position; " 

1883 f"got attention_dir={attention_dir!r}." 

1884 ) 

1885 total_ut_steps = int(getattr(model.cfg, "total_ut_steps", 1) or 1) 

1886 if total_ut_steps != 1: 

1887 raise ValueError( 

1888 f"{estimator} requires each physical block hook to fire once per forward; " 

1889 f"this looped-depth Bridge runs total_ut_steps={total_ut_steps}." 

1890 ) 

1891 component_mapping = adapter.get_component_mapping() 

1892 required_components = ("blocks", "ln_final", "unembed") 

1893 missing_components = [ 

1894 component 

1895 for component in required_components 

1896 if component not in component_mapping or not hasattr(model, component) 

1897 ] 

1898 if missing_components: 

1899 raise ValueError( 

1900 f"{estimator} requires the standard direct ln_final -> unembed output path; " 

1901 f"this Bridge is missing {missing_components}." 

1902 ) 

1903 blocks_component = component_mapping["blocks"] 

1904 if not getattr(blocks_component, "hook_out_is_single_residual_stream", False): 

1905 raise ValueError( 

1906 f"{estimator} requires single-stream [batch, position, d_model] block " 

1907 f"outputs; {type(blocks_component).__name__} does not provide that " 

1908 "contract." 

1909 ) 

1910 if "project_out" in component_mapping: 

1911 raise ValueError( 

1912 f"{estimator} does not yet support a final output projection between " 

1913 "the residual stream and unembedding." 

1914 ) 

1915 unembed_width = model.W_U.shape[0] 

1916 if unembed_width != model.cfg.d_model: 

1917 raise ValueError( 

1918 f"{estimator} requires a direct d_model-width unembedding after ln_final; " 

1919 f"got W_U input width {unembed_width} for d_model={model.cfg.d_model}. " 

1920 "Architectures with a final output projection are not yet supported." 

1921 ) 

1922 

1923 

1924def _validate_metadata(metadata: Dict[str, Any]) -> None: 

1925 """Reject values that ``torch.load(weights_only=True)`` cannot reload.""" 

1926 

1927 def validate(value: Any, path: str) -> None: 

1928 if value is None or type(value) in (bool, int, float, str): 

1929 return 

1930 if type(value) in (list, tuple): 

1931 for index, item in enumerate(value): 

1932 validate(item, f"{path}[{index}]") 

1933 return 

1934 if type(value) is dict: 

1935 for key, item in value.items(): 

1936 if type(key) is not str: 

1937 raise ValueError( 

1938 f"{path} has non-string key {key!r}; metadata keys must be strings" 

1939 ) 

1940 validate(item, f"{path}.{key}") 

1941 return 

1942 raise ValueError( 

1943 f"{path} has unsupported type {type(value).__name__}; use only " 

1944 "None, bool, int, float, str, lists, tuples, and string-keyed dicts" 

1945 ) 

1946 

1947 validate(metadata, "metadata") 

1948 

1949 

1950def _normalize_positions(positions: Optional[Sequence[int]], seq_len: int) -> List[int]: 

1951 """Normalize negative chunk-local positions and raise before indexing.""" 

1952 if positions is None: 

1953 return list(range(seq_len)) 

1954 normalized = [position + seq_len if position < 0 else position for position in positions] 

1955 out_of_range = [position for position in normalized if not 0 <= position < seq_len] 

1956 if out_of_range: 

1957 raise ValueError( 

1958 f"positions {out_of_range} out of range for an activation chunk of length {seq_len}" 

1959 ) 

1960 return normalized 

1961 

1962 

1963def _cached_on_device( 

1964 tensor: torch.Tensor, 

1965 cache: Dict[torch.device, torch.Tensor], 

1966 device: Union[str, torch.device], 

1967) -> torch.Tensor: 

1968 """Cache a small fp32 intervention tensor on each activation device.""" 

1969 resolved_device = torch.device(device) 

1970 local = cache.get(resolved_device) 

1971 if local is None: 1971 ↛ 1974line 1971 didn't jump to line 1974 because the condition on line 1971 was always true

1972 local = tensor.to(device=resolved_device, dtype=torch.float32) 

1973 cache[resolved_device] = local 

1974 return local 

1975 

1976 

1977def _unit_rows(vectors: torch.Tensor, *, layer: int) -> torch.Tensor: 

1978 """Normalize intervention vectors and reject zero/non-finite directions.""" 

1979 vectors = vectors.float() 

1980 norms = vectors.norm(dim=-1, keepdim=True) 

1981 if (~torch.isfinite(norms) | (norms <= torch.finfo(torch.float32).eps)).any(): 1981 ↛ 1982line 1981 didn't jump to line 1982 because the condition on line 1981 was never true

1982 raise ValueError(f"lens vectors at layer {layer} contain a zero or non-finite direction") 

1983 return vectors / norms 

1984 

1985 

1986def _make_intervention_hook( 

1987 transform: Callable[[torch.Tensor, List[int]], torch.Tensor], 

1988 positions: Optional[Sequence[int]], 

1989 d_model: int, 

1990) -> Callable[..., torch.Tensor]: 

1991 """Apply ``transform(selected, positions)`` with shared position/dtype/device hardening.""" 

1992 requested = None if positions is None else tuple(positions) 

1993 if requested == (): 1993 ↛ anywhereline 1993 didn't jump anywhere: it always raised an exception.

1994 raise ValueError("positions must contain at least one index") 

1995 

1996 def hook_fn( 

1997 activation: Float[torch.Tensor, "batch pos d_model"], hook: Any 

1998 ) -> Float[torch.Tensor, "batch pos d_model"]: 

1999 hook_name = getattr(hook, "name", "intervention hook") 

2000 _validate_residual_activation(activation, d_model=d_model, hook_name=hook_name) 

2001 normalized = _normalize_positions(requested, activation.shape[1]) 

2002 selected = activation if requested is None else activation[:, normalized, :] 

2003 transformed = transform(selected, normalized) 

2004 if transformed.shape != selected.shape: 2004 ↛ 2005line 2004 didn't jump to line 2005 because the condition on line 2004 was never true

2005 raise ValueError( 

2006 f"intervention returned shape {tuple(transformed.shape)}, " 

2007 f"expected {tuple(selected.shape)}" 

2008 ) 

2009 transformed = transformed.to(device=activation.device, dtype=activation.dtype) 

2010 if requested is None: 

2011 return transformed 

2012 output = activation.clone() 

2013 output[:, normalized, :] = transformed 

2014 return output 

2015 

2016 return hook_fn 

2017 

2018 

2019def _distinct_ids( 

2020 model: Any, source_token: ConceptToken, target_token: ConceptToken, *, operation: str 

2021) -> Tuple[int, int]: 

2022 """Resolve a source/target pair, refusing one that names the same token twice.""" 

2023 source_id, target_id = to_token_ids(model, [source_token, target_token]) 

2024 if source_id == target_id: 

2025 raise ValueError( 

2026 "source_token and target_token resolve to the same token id; " 

2027 f"a coordinate {operation} would be a silent no-op" 

2028 ) 

2029 return source_id, target_id 

2030 

2031 

2032def _validate_residual_activation( 

2033 activation: torch.Tensor, 

2034 *, 

2035 d_model: int, 

2036 hook_name: str, 

2037) -> None: 

2038 """Fail before interpreting a non-standard block output as a residual stream.""" 

2039 if activation.ndim != 3 or activation.shape[-1] != d_model: 

2040 raise ValueError( 

2041 f"{hook_name} must have shape [batch, position, {d_model}], " 

2042 f"got {tuple(activation.shape)}" 

2043 ) 

2044 

2045 

2046def _normalize_layer(layer: int, n_layers: int) -> int: 

2047 """Resolve negative layer indices and bounds-check.""" 

2048 resolved = layer + n_layers if layer < 0 else layer 

2049 if not 0 <= resolved < n_layers: 

2050 raise ValueError(f"layer {layer} out of range for a {n_layers}-layer model") 

2051 return resolved 

2052 

2053 

2054class _frozen_parameters: 

2055 """Context manager: freeze all parameters, restore their flags on exit. 

2056 

2057 Freezing keeps the autograd graph rooted at the residual stream rather than 

2058 at the weights, so fitting retains only the blocks between the earliest 

2059 source layer and the target layer. 

2060 """ 

2061 

2062 def __init__(self, model: Any) -> None: 

2063 self.model = model 

2064 self.saved: List[Tuple[torch.nn.Parameter, bool]] = [] 

2065 

2066 def __enter__(self) -> None: 

2067 self.saved = [(param, param.requires_grad) for param in self.model.parameters()] 

2068 for param, _ in self.saved: 

2069 param.requires_grad_(False) 

2070 

2071 def __exit__(self, *exc: Any) -> None: 

2072 for param, flag in self.saved: 

2073 param.requires_grad_(flag) 

2074 

2075 

2076def _ordinary_vjp( 

2077 target: torch.Tensor, 

2078 sources: List[torch.Tensor], 

2079 cotangent: torch.Tensor, 

2080 retain_graph: bool, 

2081) -> Tuple[torch.Tensor, ...]: 

2082 """Ordinary vector-Jacobian product backing the J-lens estimator. 

2083 

2084 Thin wrapper over ``torch.autograd.grad`` so the drive loop takes its 

2085 backward step through the :data:`BackwardProvider` seam. This is the 

2086 identity-preserving provider: routing the ordinary fit through it changes no 

2087 numerics. 

2088 """ 

2089 return torch.autograd.grad( 

2090 outputs=target, 

2091 inputs=sources, 

2092 grad_outputs=cotangent, 

2093 retain_graph=retain_graph, 

2094 ) 

2095 

2096 

2097def _jacobian_for_prompt( 

2098 model: Any, 

2099 tokens: Int[torch.Tensor, "one seq"], 

2100 *, 

2101 source_layers: List[int], 

2102 dim_batch: int, 

2103 skip_first_positions: int, 

2104 backward_provider: BackwardProvider, 

2105) -> Dict[int, Float[torch.Tensor, "d_model d_model"]]: 

2106 """Exact per-prompt Jacobian rows via batched one-hot cotangents. 

2107 

2108 Assumes parameters are already frozen (see :class:`_frozen_parameters`) so 

2109 that marking the earliest source activation ``requires_grad`` roots the 

2110 graph there. 

2111 

2112 The backward pass is taken through ``backward_provider`` rather than calling 

2113 ``torch.autograd.grad`` directly, so an alternate estimator would reuse the 

2114 capture / cotangent-batching / averaging mechanics. The ordinary J-lens path 

2115 passes :func:`_ordinary_vjp`, which reproduces the original numerics exactly. 

2116 """ 

2117 d_model = model.cfg.d_model 

2118 target_layer = model.cfg.n_layers - 1 

2119 seq_len = tokens.shape[1] 

2120 valid_positions = list(range(skip_first_positions, seq_len - 1)) 

2121 replicated = tokens.expand(dim_batch, -1) 

2122 

2123 captured: Dict[str, torch.Tensor] = {} 

2124 root_name = _resid_post_hook_name(min(source_layers)) 

2125 hook_layers = sorted(set(source_layers) | {target_layer}) 

2126 

2127 def capture_fn( 

2128 activation: Float[torch.Tensor, "batch pos d_model"], hook: Any 

2129 ) -> Float[torch.Tensor, "batch pos d_model"]: 

2130 _validate_residual_activation(activation, d_model=d_model, hook_name=hook.name) 

2131 if hook.name == root_name and not activation.requires_grad: 

2132 activation.requires_grad_(True) 

2133 captured[hook.name] = activation 

2134 return activation 

2135 

2136 fwd_hooks = [(_resid_post_hook_name(layer), capture_fn) for layer in hook_layers] 

2137 with torch.enable_grad(), model.hooks(fwd_hooks=fwd_hooks): 

2138 model(replicated, return_type=None) 

2139 

2140 target = captured[_resid_post_hook_name(target_layer)] 

2141 sources = [captured[_resid_post_hook_name(layer)] for layer in source_layers] 

2142 device = target.device 

2143 positions_index = torch.tensor(valid_positions, device=device) 

2144 batch_index = torch.arange(dim_batch, device=device) 

2145 

2146 jacobians = { 

2147 layer: torch.zeros(d_model, d_model, dtype=torch.float32) for layer in source_layers 

2148 } 

2149 cotangent = torch.zeros_like(target) 

2150 n_passes = -(-d_model // dim_batch) # ceil division 

2151 for pass_index in range(n_passes): 

2152 dim_start = pass_index * dim_batch 

2153 n_dims = min(dim_batch, d_model - dim_start) 

2154 cotangent.zero_() 

2155 cotangent[ 

2156 batch_index[:n_dims, None], 

2157 positions_index[None, :], 

2158 dim_start + batch_index[:n_dims, None], 

2159 ] = 1.0 

2160 grads = backward_provider( 

2161 target, 

2162 sources, 

2163 cotangent, 

2164 pass_index < n_passes - 1, 

2165 ) 

2166 for layer, grad in zip(source_layers, grads): 

2167 # each gradient lives on its layer's device under sharded/device_map setups 

2168 rows = grad[:n_dims, positions_index.to(grad.device), :].float().mean(dim=1) 

2169 jacobians[layer][dim_start : dim_start + n_dims, :] = rows.cpu() 

2170 del grads 

2171 return jacobians 

2172 

2173 

2174def _fit_transport_matrices( 

2175 model: Any, 

2176 prompts: Sequence[str], 

2177 *, 

2178 source_layers: List[int], 

2179 dim_batch: int, 

2180 max_seq_len: int, 

2181 skip_first_positions: int, 

2182 show_progress: bool, 

2183 backward_provider: BackwardProvider, 

2184) -> Tuple[Dict[int, Float[torch.Tensor, "d_model d_model"]], int]: 

2185 """Estimator-independent J-lens drive loop. 

2186 

2187 Owns the fit mechanics: the frozen-parameter lifecycle, the per-prompt 

2188 forward/backward accumulation (source/target residual capture, one-hot 

2189 cotangent batching, ``dim_batch`` chunking, valid-position selection, and 

2190 source-position averaging all live in :func:`_jacobian_for_prompt`), the 

2191 prompt-accumulation running sum, and the final division into per-prompt 

2192 means. Only the backward step is pluggable: it is taken through 

2193 ``backward_provider``, so an alternate estimator would reuse this loop 

2194 unchanged. Callers own input validation, provenance, and lens construction. 

2195 

2196 Args: 

2197 model: A raw ``TransformerBridge`` whose parameters are frozen for the 

2198 duration of the loop. 

2199 prompts: Prompt strings; prompts too short to contain a valid position 

2200 (``seq_len <= skip_first_positions + 1``) are skipped with a warning 

2201 and do not count toward the returned prompt total. 

2202 source_layers: Resolved, in-range source layers to fit. 

2203 dim_batch: Output dimensions per backward pass. 

2204 max_seq_len: Prompts are truncated to this many tokens. 

2205 skip_first_positions: Leading positions excluded from the source average. 

2206 show_progress: Show a tqdm progress bar over prompts. 

2207 backward_provider: Backward-step seam; pass :func:`_ordinary_vjp` for the 

2208 ordinary J-lens numerics. 

2209 

2210 Returns: 

2211 ``(transport_matrices, n_prompts)`` where ``transport_matrices`` maps each 

2212 source layer to its prompt-averaged ``[d_model, d_model]`` matrix and 

2213 ``n_prompts`` is the number of prompts that contributed. 

2214 

2215 Raises: 

2216 ValueError: If no prompt was long enough to contribute valid positions. 

2217 """ 

2218 d_model = model.cfg.d_model 

2219 jacobian_sum = { 

2220 layer: torch.zeros(d_model, d_model, dtype=torch.float32) for layer in source_layers 

2221 } 

2222 n_done = 0 

2223 iterator = tqdm(prompts, desc="fitting J-lens", disable=not show_progress) 

2224 with _frozen_parameters(model): 

2225 for prompt in iterator: 

2226 tokens = model.to_tokens(prompt)[:, :max_seq_len] 

2227 seq_len = tokens.shape[1] 

2228 if seq_len <= skip_first_positions + 1: 

2229 warnings.warn( 

2230 f"skipping prompt with only {seq_len} tokens " 

2231 f"(need > {skip_first_positions + 1})", 

2232 stacklevel=3, 

2233 ) 

2234 continue 

2235 per_prompt = _jacobian_for_prompt( 

2236 model, 

2237 tokens, 

2238 source_layers=source_layers, 

2239 dim_batch=dim_batch, 

2240 skip_first_positions=skip_first_positions, 

2241 backward_provider=backward_provider, 

2242 ) 

2243 for layer in source_layers: 

2244 jacobian_sum[layer] += per_prompt[layer] 

2245 n_done += 1 

2246 if n_done == 0: 

2247 raise ValueError( 

2248 "every prompt was too short to contribute valid positions; nothing was fitted" 

2249 ) 

2250 means = {layer: jacobian_sum[layer] / n_done for layer in source_layers} 

2251 return means, n_done